Skip to content
Merged
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
91 changes: 90 additions & 1 deletion apps/code/src/renderer/features/sessions/service/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const mockSessionStoreSetters = vi.hoisted(() => ({
enqueueMessage: vi.fn(),
removeQueuedMessage: vi.fn(),
clearMessageQueue: vi.fn(),
dequeueMessagesAsText: vi.fn(() => null),
dequeueMessagesAsText: vi.fn((): string | null => null),
dequeueMessages: vi.fn(
() =>
[] as Array<{
Expand Down Expand Up @@ -3027,6 +3027,95 @@ describe("SessionService", () => {
});
});

describe("local turn_complete + JSON-RPC response ordering", () => {
it("drains queued messages when turn_complete arrives before the JSON-RPC response (local Codex regression)", async () => {
const service = getSessionService();

let session: AgentSession | undefined;
mockSessionStoreSetters.getSessionByTaskId.mockImplementation(
() => session,
);
mockSessionStoreSetters.getSessions.mockImplementation(() =>
session ? { "run-123": session } : {},
);
mockSessionStoreSetters.updateSession.mockImplementation(
(_taskRunId, updates) => {
if (session) session = { ...session, ...updates };
},
);
mockSessionStoreSetters.setSession.mockImplementation((next) => {
session = next as AgentSession;
});
mockSessionStoreSetters.dequeueMessagesAsText.mockReturnValue(
"follow up",
);

mockBuildAuthenticatedClient.mockReturnValue({
...mockAuthenticatedClient,
createTaskRun: vi.fn().mockResolvedValue({ id: "run-123" }),
appendTaskRunLog: vi.fn(),
});
mockTrpcAgent.start.mutate.mockResolvedValue({
channel: "agent-event:run-123",
configOptions: [],
});
mockTrpcAgent.prompt.mutate.mockResolvedValue({ stopReason: "end_turn" });

await service.connectToTask({
task: createMockTask(),
repoPath: "/repo",
});

const onData = mockTrpcAgent.onSessionEvent.subscribe.mock.calls.at(
-1,
)?.[1]?.onData as ((payload: unknown) => void) | undefined;
expect(onData).toBeDefined();

const queuedMessage = {
id: "q-1",
content: "follow up",
queuedAt: 1700000000,
};
session = createMockSession({
taskRunId: "run-123",
taskId: "task-123",
status: "connected",
isCloud: false,
currentPromptId: 42,
isPromptPending: true,
messageQueue: [queuedMessage],
});

onData?.({
type: "acp_message",
ts: 1700000001,
message: {
jsonrpc: "2.0",
method: "_posthog/turn_complete",
params: { sessionId: "acp-session", stopReason: "end_turn" },
},
});

expect(session?.currentPromptId).toBe(42);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The test only asserts currentPromptId is preserved after turn_complete for local sessions, but the fix also skips clearing isPromptPending and promptStartedAt. If a future change incorrectly clears those two fields on turn_complete for local sessions, this test would not catch it. Adding assertions for all three fields would make the regression spec fully self-describing.

Suggested change
expect(session?.currentPromptId).toBe(42);
expect(session?.currentPromptId).toBe(42);
expect(session?.isPromptPending).toBe(true);
expect(session?.promptStartedAt).not.toBeNull();
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/sessions/service/service.test.ts
Line: 3099

Comment:
The test only asserts `currentPromptId` is preserved after `turn_complete` for local sessions, but the fix also skips clearing `isPromptPending` and `promptStartedAt`. If a future change incorrectly clears those two fields on `turn_complete` for local sessions, this test would not catch it. Adding assertions for all three fields would make the regression spec fully self-describing.

```suggestion
      expect(session?.currentPromptId).toBe(42);
      expect(session?.isPromptPending).toBe(true);
      expect(session?.promptStartedAt).not.toBeNull();
```

How can I resolve this? If you propose a fix, please make it concise.


onData?.({
type: "acp_message",
ts: 1700000002,
message: {
jsonrpc: "2.0",
id: 42,
result: { stopReason: "end_turn" },
},
});

await vi.waitFor(() => {
expect(mockTrpcAgent.prompt.mutate).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: "run-123" }),
);
});
});
});

describe("cancelPrompt", () => {
it("returns false if no session exists", async () => {
const service = getSessionService();
Expand Down
20 changes: 13 additions & 7 deletions apps/code/src/renderer/features/sessions/service/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ function buildCloudDefaultConfigOptions(
];
}

function isCloudTurnCompleteEvent(event: AcpMessage): boolean {
function isTurnCompleteEvent(event: AcpMessage): boolean {
const msg = event.message;
return (
"method" in msg &&
Expand Down Expand Up @@ -1111,12 +1111,18 @@ export class SessionService {
currentPromptId: null,
});
}
if (isCloudTurnCompleteEvent(acpMsg)) {
sessionStoreSetters.updateSession(taskRunId, {
isPromptPending: false,
promptStartedAt: null,
currentPromptId: null,
});
if (isTurnCompleteEvent(acpMsg)) {
// Local sessions use the JSON-RPC response as the canonical turn-done
// signal; clearing currentPromptId here would race the id-match guard
// above. Cloud sessions never see that response.
const session = this.getSessionByRunId(taskRunId);
if (session?.isCloud) {
sessionStoreSetters.updateSession(taskRunId, {
isPromptPending: false,
promptStartedAt: null,
currentPromptId: null,
});
}
}
// Lifecycle handshake from the agent — flip status to "connected"
// so the UI can release the queue-while-not-ready guard. This is
Expand Down
Loading