mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Prevent concurrent chat operations from overwriting active work
This commit is contained in:
parent
a1ad965dcc
commit
26adfa4d46
4 changed files with 64 additions and 2 deletions
|
|
@ -44,6 +44,7 @@ export interface ThreadManagementActions {
|
|||
interface ThreadManagementPanelScope {
|
||||
targetThreadId: string;
|
||||
initialActiveThreadId: string | null;
|
||||
initialTurnLifecycle: ChatState["turn"]["lifecycle"];
|
||||
}
|
||||
|
||||
export function createThreadManagementActions(host: ThreadManagementActionsHost): ThreadManagementActions {
|
||||
|
|
@ -240,11 +241,13 @@ function captureThreadManagementPanelScope(host: ThreadManagementActionsHost, ta
|
|||
return {
|
||||
targetThreadId,
|
||||
initialActiveThreadId: threadManagementState(host).activeThread.id,
|
||||
initialTurnLifecycle: threadManagementState(host).turn.lifecycle,
|
||||
};
|
||||
}
|
||||
|
||||
function threadManagementScopeStillTargetsPanel(host: ThreadManagementActionsHost, scope: ThreadManagementPanelScope): boolean {
|
||||
return threadManagementState(host).activeThread.id === scope.targetThreadId;
|
||||
const state = threadManagementState(host);
|
||||
return state.activeThread.id === scope.targetThreadId && state.turn.lifecycle === scope.initialTurnLifecycle;
|
||||
}
|
||||
|
||||
function threadManagementScopeStillTargetsOriginalPanel(host: ThreadManagementActionsHost, scope: ThreadManagementPanelScope): boolean {
|
||||
|
|
|
|||
|
|
@ -52,8 +52,17 @@ export interface TurnSubmissionRequest {
|
|||
}
|
||||
|
||||
export function createTurnSubmissionActions(host: TurnSubmissionActionsHost): TurnSubmissionActions {
|
||||
let submissionInFlight = false;
|
||||
return {
|
||||
sendTurnText: (request) => sendTurnText(host, host.localItemIds, request),
|
||||
sendTurnText: async (request) => {
|
||||
if (submissionInFlight) return false;
|
||||
submissionInFlight = true;
|
||||
try {
|
||||
return await sendTurnText(host, host.localItemIds, request);
|
||||
} finally {
|
||||
submissionInFlight = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -450,6 +450,38 @@ describe("thread management actions", () => {
|
|||
expect(host.refreshAfterThreadMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores rollback responses after a new turn starts in the same thread", async () => {
|
||||
const rollback = deferred<ThreadRollbackSnapshot | null>();
|
||||
const host = hostMock({ items: turnItems() });
|
||||
host.threadTransport.rollbackThread.mockReturnValue(rollback.promise);
|
||||
host.stateStore.dispatch({
|
||||
type: "active-thread/resumed",
|
||||
approvalPolicyKnown: true,
|
||||
sandboxPolicyKnown: true,
|
||||
permissionProfileKnown: true,
|
||||
approvalPolicy: null,
|
||||
sandboxPolicy: null,
|
||||
activePermissionProfile: null,
|
||||
thread: panelThread("source"),
|
||||
cwd: "/vault",
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
serviceTier: null,
|
||||
approvalsReviewer: null,
|
||||
});
|
||||
host.stateStore.dispatch({ type: "thread-stream/items-replaced", items: turnItems(), historyCursor: null, loadingHistory: false });
|
||||
const controller = threadManagementActions(host);
|
||||
|
||||
const pendingRollback = controller.rollbackThread("source");
|
||||
await waitForAsyncWork(() => expect(host.threadTransport.rollbackThread).toHaveBeenCalledOnce());
|
||||
host.stateStore.dispatch({ type: "turn/started", threadId: "source", turnId: "new-turn" });
|
||||
rollback.resolve(rollbackSnapshot());
|
||||
await pendingRollback;
|
||||
|
||||
expect(host.stateStore.getState().turn.lifecycle).toEqual({ kind: "running", turnId: "new-turn" });
|
||||
expect(host.setComposerText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores rollback when the transport has no result", async () => {
|
||||
const host = hostMock({
|
||||
items: turnItems(),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
createTurnSubmissionActions,
|
||||
type TurnSubmissionActionsHost,
|
||||
} from "../../../../../src/features/chat/application/turns/turn-submission-actions";
|
||||
import { deferred } from "../../../../support/async";
|
||||
import { chatStateThreadStreamItems } from "../../support/thread-stream";
|
||||
|
||||
const textInput = (text: string): CodexInput => [{ type: "text", text }];
|
||||
|
|
@ -290,6 +291,23 @@ describe("TurnSubmissionActions", () => {
|
|||
expect(startTurn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a second submission while the first submission is still preparing", async () => {
|
||||
const settings = deferred<boolean>();
|
||||
const { host, startTurn, stateStore } = createHost({ applyPendingThreadSettings: vi.fn(() => settings.promise) });
|
||||
resumeThread(stateStore);
|
||||
const actions = createTurnSubmissionActions(host);
|
||||
|
||||
const first = actions.sendTurnText({ text: "first" });
|
||||
await Promise.resolve();
|
||||
const second = actions.sendTurnText({ text: "second" });
|
||||
settings.resolve(true);
|
||||
|
||||
await expect(first).resolves.toBe(true);
|
||||
await expect(second).resolves.toBe(false);
|
||||
expect(startTurn).toHaveBeenCalledOnce();
|
||||
expect(startTurn).toHaveBeenCalledWith(expect.objectContaining({ input: textInput("first") }));
|
||||
});
|
||||
|
||||
it("keeps local user ids distinct when submissions share the same timestamp", async () => {
|
||||
const now = vi.spyOn(Date, "now").mockReturnValue(1234);
|
||||
try {
|
||||
|
|
|
|||
Loading…
Reference in a new issue