Prevent concurrent chat operations from overwriting active work

This commit is contained in:
murashit 2026-07-12 08:44:39 +09:00
parent a1ad965dcc
commit 26adfa4d46
4 changed files with 64 additions and 2 deletions

View file

@ -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 {

View file

@ -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;
}
},
};
}

View file

@ -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(),

View file

@ -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 {