Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions src/features/chat/lib/__tests__/steerCore.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useChatStore } from "../../stores/chatStore";
import { useChatSessionStore } from "../../stores/chatSessionStore";
import type { ChatSession } from "../../stores/chatSessionStore";
import { MAX_PROMPT_ATTACHMENT_BYTES } from "../attachmentPayloadBudget";

const mockAcpSteerMessage = vi.fn();
const mockUnarchiveSession = vi.fn();

vi.mock("@/shared/api/acp", () => ({
acpSteerMessage: (...args: unknown[]) => mockAcpSteerMessage(...args),
}));

vi.mock("@/shared/api/acpApi", () => ({
archiveSession: vi.fn().mockResolvedValue(undefined),
unarchiveSession: (...args: unknown[]) => mockUnarchiveSession(...args),
renameSession: vi.fn().mockResolvedValue(undefined),
updateSessionProject: vi.fn().mockResolvedValue(undefined),
updateWorkingDir: vi.fn().mockResolvedValue(undefined),
}));

vi.mock("@/shared/i18n", () => ({
i18n: {
t: (key: string, params?: Record<string, unknown>) =>
Expand All @@ -29,6 +40,68 @@ function oversizedImageDraft() {
};
}

function seedArchivedSession() {
useChatSessionStore.setState({
sessions: [
{
id: "session-1",
title: "Archived",
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
archivedAt: "2026-04-02T00:00:00.000Z",
messageCount: 1,
},
],
archiveMutationBySessionId: {},
});
}

describe("steerPromptInSession archived session restore", () => {
beforeEach(() => {
vi.clearAllMocks();
useChatSessionStore.setState({
sessions: [],
archiveMutationBySessionId: {},
});
mockUnarchiveSession.mockResolvedValue(undefined);
mockAcpSteerMessage.mockResolvedValue({
runId: "run-1",
messageId: "msg-1",
});
});

it("does not restore an empty steer", async () => {
seedArchivedSession();
await expect(steerPromptInSession("session-1", "")).resolves.toBe(false);
expect(mockUnarchiveSession).not.toHaveBeenCalled();
expect(mockAcpSteerMessage).not.toHaveBeenCalled();
});

it("does not restore an oversized steer", async () => {
seedArchivedSession();
await expect(
steerPromptInSession("session-1", "look", [oversizedImageDraft()]),
).resolves.toBe(false);
expect(mockUnarchiveSession).not.toHaveBeenCalled();
expect(mockAcpSteerMessage).not.toHaveBeenCalled();
});

it("restores only after validation and waits for durable success", async () => {
seedArchivedSession();
let resolveRestore!: () => void;
const restore = new Promise<void>((resolve) => {
resolveRestore = resolve;
});
mockUnarchiveSession.mockReturnValueOnce(restore);
const pending = steerPromptInSession("session-1", "look");
await Promise.resolve();
expect(mockAcpSteerMessage).not.toHaveBeenCalled();
resolveRestore();
await pending;
expect(mockAcpSteerMessage).toHaveBeenCalledTimes(1);
});
});

describe("steerPromptInSession payload budget", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -273,3 +346,77 @@ describe("steerPromptInSession voice no-op", () => {
});
});
});

// Steering is a send: an archived chat must be restored before the steer is
// injected, mirroring the dispatchPrompt restore.
describe("steerPromptInSession archived session restore", () => {
const ARCHIVED_AT = "2026-04-02T00:00:00.000Z";

beforeEach(() => {
vi.clearAllMocks();
mockAcpSteerMessage.mockResolvedValue({
runId: "run-1",
messageId: "msg-1",
});
mockUnarchiveSession.mockResolvedValue(undefined);
useChatStore.setState({
messagesBySession: {},
sessionStateById: {},
activeSessionId: null,
isConnected: true,
});
useChatSessionStore.setState({
sessions: [],
activeSessionId: null,
activeWorkspaceBySession: {},
archiveMutationBySessionId: {},
});
});

it("restores an archived session before steering", async () => {
useChatSessionStore.setState((state) => ({
sessions: [
{
id: "session-1",
title: "Test Session",
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
messageCount: 1,
archivedAt: ARCHIVED_AT,
},
...state.sessions,
],
}));

const accepted = await steerPromptInSession("session-1", "one more thing");

expect(accepted).toBe(true);
expect(mockUnarchiveSession).toHaveBeenCalledWith("session-1");
expect(
useChatSessionStore.getState().getSession("session-1")?.archivedAt,
).toBeUndefined();
expect(mockUnarchiveSession.mock.invocationCallOrder[0]).toBeLessThan(
mockAcpSteerMessage.mock.invocationCallOrder[0],
);
});

it("does not restore an active session", async () => {
useChatSessionStore.setState((state) => ({
sessions: [
{
id: "session-1",
title: "Test Session",
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
messageCount: 1,
} satisfies ChatSession,
...state.sessions,
],
}));

const accepted = await steerPromptInSession("session-1", "one more thing");

expect(accepted).toBe(true);
expect(mockUnarchiveSession).not.toHaveBeenCalled();
});
});
140 changes: 140 additions & 0 deletions src/features/chat/lib/sendCore.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useChatStore } from "@/features/chat/stores/chatStore";
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
import type { ChatSession } from "@/features/chat/stores/chatSessionStore";
import type { SessionChatRuntime } from "@/shared/types/chat";
import { QueuedMessageOwnershipLostError } from "./preCommitSendRejection";
import { dispatchPrompt } from "./sendCore";
Expand All @@ -10,13 +11,23 @@ import { setVoiceConversationMode } from "@/features/voice-conversation/lib/voic
const mocks = vi.hoisted(() => ({
acpExportSession: vi.fn(),
acpSendMessage: vi.fn(),
archiveSession: vi.fn(),
unarchiveSession: vi.fn(),
}));

vi.mock("@/shared/api/acp", () => ({
acpExportSession: (...args: unknown[]) => mocks.acpExportSession(...args),
acpSendMessage: (...args: unknown[]) => mocks.acpSendMessage(...args),
}));

vi.mock("@/shared/api/acpApi", () => ({
archiveSession: (...args: unknown[]) => mocks.archiveSession(...args),
unarchiveSession: (...args: unknown[]) => mocks.unarchiveSession(...args),
renameSession: vi.fn().mockResolvedValue(undefined),
updateSessionProject: vi.fn().mockResolvedValue(undefined),
updateWorkingDir: vi.fn().mockResolvedValue(undefined),
}));

describe("dispatchPrompt pre-commit rejection", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -634,3 +645,132 @@ describe("dispatchPrompt realtime Master transcript recovery", () => {
release();
});
});

describe("dispatchPrompt archived session restore", () => {
const ARCHIVED_AT = "2026-04-02T00:00:00.000Z";

function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}

function seedSession(overrides: Partial<ChatSession> = {}): ChatSession {
const session: ChatSession = {
id: "session-1",
title: "Test Session",
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
messageCount: 1,
...overrides,
};
useChatSessionStore.setState((state) => ({
sessions: [
session,
...state.sessions.filter((candidate) => candidate.id !== session.id),
],
}));
return session;
}

beforeEach(() => {
vi.clearAllMocks();
mocks.acpSendMessage.mockResolvedValue(undefined);
mocks.unarchiveSession.mockResolvedValue(undefined);
useChatSessionStore.setState({
sessions: [],
activeSessionId: null,
activeWorkspaceBySession: {},
archiveMutationBySessionId: {},
});
});

it("restores an archived session before dispatching the prompt", async () => {
seedSession({ archivedAt: ARCHIVED_AT });

await dispatchPrompt("session-1", "hello again", {});

expect(mocks.unarchiveSession).toHaveBeenCalledTimes(1);
expect(mocks.unarchiveSession).toHaveBeenCalledWith("session-1");
expect(
useChatSessionStore.getState().getSession("session-1")?.archivedAt,
).toBeUndefined();
expect(mocks.acpSendMessage).toHaveBeenCalledTimes(1);
expect(mocks.unarchiveSession.mock.invocationCallOrder[0]).toBeLessThan(
mocks.acpSendMessage.mock.invocationCallOrder[0],
);
});

it("waits for a shared durable restore before dispatching", async () => {
seedSession({ archivedAt: ARCHIVED_AT });
const restore = deferred<void>();
mocks.unarchiveSession.mockReturnValue(restore.promise);

const firstSend = dispatchPrompt("session-1", "one", {});
const secondSend = dispatchPrompt("session-1", "two", {});
await Promise.resolve();

expect(mocks.unarchiveSession).toHaveBeenCalledTimes(1);
expect(mocks.acpSendMessage).not.toHaveBeenCalled();
restore.resolve(undefined);
await Promise.all([firstSend, secondSend]);
expect(mocks.acpSendMessage).toHaveBeenCalledTimes(2);
});

it("does not dispatch if a newer archive wins the restore race", async () => {
seedSession({ archivedAt: ARCHIVED_AT });
const restore = deferred<void>();
mocks.unarchiveSession.mockReturnValue(restore.promise);

const send = dispatchPrompt("session-1", "hello", {});
await Promise.resolve();
await useChatSessionStore.getState().archiveSession("session-1");
restore.resolve(undefined);

await expect(send).rejects.toThrow("was archived");
expect(mocks.acpSendMessage).not.toHaveBeenCalled();
expect(
useChatSessionStore.getState().getSession("session-1")?.archivedAt,
).toEqual(expect.any(String));
});

it("leaves active sessions untouched", async () => {
seedSession();

await dispatchPrompt("session-1", "hello", {});

expect(mocks.unarchiveSession).not.toHaveBeenCalled();
expect(mocks.acpSendMessage).toHaveBeenCalledTimes(1);
});

it("does not dispatch when the restore fails", async () => {
seedSession({ archivedAt: ARCHIVED_AT });
mocks.unarchiveSession.mockRejectedValue(new Error("backend down"));

await expect(
dispatchPrompt("session-1", "hello again", {}),
).rejects.toThrow("backend down");
expect(mocks.acpSendMessage).not.toHaveBeenCalled();
expect(
useChatSessionStore.getState().getSession("session-1")?.archivedAt,
).toBe(ARCHIVED_AT);
});

it("does not restore a rejected preparation", async () => {
seedSession({ archivedAt: ARCHIVED_AT });

await expect(
dispatchPrompt("session-1", "stale", {
prepare: () => {
throw new Error("superseded");
},
}),
).rejects.toThrow("superseded");
expect(mocks.unarchiveSession).not.toHaveBeenCalled();
expect(mocks.acpSendMessage).not.toHaveBeenCalled();
});
});
16 changes: 15 additions & 1 deletion src/features/chat/lib/sendCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,13 @@ export function resolveAssistantCancellation(
finalizeAssistantCancellationRace(promptOwner);
}

/** Restore an archived chat and require durable success before sending. */
export async function restoreArchivedSessionBeforeSend(
sessionId: string,
): Promise<void> {
await useChatSessionStore.getState().ensureSessionActive(sessionId);
}

/**
* Foreground send core: commits the user message, drives the
* thinking-to-streaming-to-idle chat-state transitions, patches the session
Expand Down Expand Up @@ -445,6 +452,10 @@ export async function dispatchPrompt(
await prepare?.();
throwIfAborted(signal);

await restoreArchivedSessionBeforeSend(sessionId);
throwIfAborted(signal);
useChatSessionStore.getState().assertSessionActive(sessionId);

const commitUserMessage = () => {
throwIfAborted(signal);
beforeUserMessageCommitted?.();
Expand Down Expand Up @@ -538,7 +549,10 @@ export async function dispatchPrompt(
images: images?.map(
(img) => [img.base64, img.mimeType] as [string, string],
),
onPromptDispatching: commitUserMessage,
onPromptDispatching: () => {
useChatSessionStore.getState().assertSessionActive(sessionId);
commitUserMessage();
},
onPromptDispatched: () => {
onPromptDispatched?.();
},
Expand Down
3 changes: 3 additions & 0 deletions src/features/chat/lib/steerCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from "./attachments";
import { isSessionRunning } from "./sessionActivity";
import { getSessionPromptOwner } from "./sessionPromptOwnership";
import { restoreArchivedSessionBeforeSend } from "./sendCore";
import { isVoiceConversationEmptyResponse } from "./voiceConversationNoop";
import { i18n } from "@/shared/i18n";

Expand Down Expand Up @@ -126,6 +127,8 @@ export async function steerPromptInSession(
});

try {
await restoreArchivedSessionBeforeSend(sessionId);
useChatSessionStore.getState().assertSessionActive(sessionId);
const steerResponse = await acpSteerMessage(
sessionId,
activeRunId,
Expand Down
Loading