From 7f81ee5c6cbd727c3e7b2c4a0f732bf56517983e Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sun, 21 Jun 2026 17:01:26 -0400 Subject: [PATCH 1/7] fix(grok): Harden ACP resume and unblock Stop on silent prompts Race session/prompt against xAI prompt_complete, harden session/load replay idle readiness, and interrupt forked prompt RPC fibers on cancel so Grok turns that hang with no prompt_complete still release the composer and accept follow-ups. --- apps/server/scripts/acp-mock-agent.ts | 187 +- .../Layers/ProjectionPipeline.test.ts | 508 ++++- .../Layers/ProjectionPipeline.ts | 291 ++- .../Layers/ProviderRuntimeIngestion.ts | 41 +- .../src/orchestration/projector.test.ts | 1638 +++++++++++++---- apps/server/src/orchestration/projector.ts | 142 +- .../src/provider/Layers/CursorAdapter.test.ts | 22 +- .../src/provider/Layers/GrokAdapter.test.ts | 585 +++++- .../server/src/provider/Layers/GrokAdapter.ts | 539 +++++- .../provider/acp/AcpJsonRpcConnection.test.ts | 245 +++ .../src/provider/acp/AcpRuntimeModel.test.ts | 91 + .../src/provider/acp/AcpRuntimeModel.ts | 91 + .../src/provider/acp/AcpSessionRuntime.ts | 422 ++++- apps/web/src/components/ChatMarkdown.tsx | 14 +- apps/web/src/components/ChatView.tsx | 10 + apps/web/src/components/Sidebar.logic.test.ts | 16 + apps/web/src/components/Sidebar.logic.ts | 3 +- apps/web/src/components/Sidebar.tsx | 23 +- .../src/components/ThreadStatusIndicators.tsx | 7 +- .../src/hooks/useRafThrottledValue.test.ts | 173 ++ apps/web/src/hooks/useRafThrottledValue.ts | 52 + apps/web/src/session-logic.test.ts | 58 + apps/web/src/session-logic.ts | 12 +- apps/web/src/uiStateStore.ts | 28 + .../no-manual-effect-runtime-in-tests.ts | 2 +- .../src/state/threadReducer.test.ts | 353 +++- .../client-runtime/src/state/threadReducer.ts | 160 +- packages/effect-acp/src/client.test.ts | 129 +- packages/shared/package.json | 4 + .../shared/src/orchestrationMessages.test.ts | 690 +++++++ packages/shared/src/orchestrationMessages.ts | 396 ++++ 31 files changed, 6289 insertions(+), 643 deletions(-) create mode 100644 apps/web/src/hooks/useRafThrottledValue.test.ts create mode 100644 apps/web/src/hooks/useRafThrottledValue.ts create mode 100644 packages/shared/src/orchestrationMessages.test.ts create mode 100644 packages/shared/src/orchestrationMessages.ts diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 0d89775844d..f70a0f5799a 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -19,6 +19,21 @@ const emitInterleavedAssistantToolCalls = const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; +const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1"; +const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; +const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1"; +const omitXAiPromptCompleteStopReason = + process.env.T3_ACP_OMIT_XAI_PROMPT_COMPLETE_STOP_REASON === "1"; +const failLoadSession = process.env.T3_ACP_FAIL_LOAD_SESSION === "1"; +const emitLoadReplay = process.env.T3_ACP_EMIT_LOAD_REPLAY === "1"; +const hangLoadSessionAfterReplay = process.env.T3_ACP_HANG_LOAD_SESSION_AFTER_REPLAY === "1"; +const delayLoadSessionAfterReplay = process.env.T3_ACP_DELAY_LOAD_SESSION_AFTER_REPLAY === "1"; +const loadSessionDelayMs = Number(process.env.T3_ACP_LOAD_SESSION_DELAY_MS ?? "5000"); +const emitStaleXAiPromptCompleteBeforeSecondHang = + process.env.T3_ACP_EMIT_STALE_XAI_PROMPT_COMPLETE_BEFORE_SECOND_HANG === "1"; +const emitOverlappingXAiPromptCompleteOutOfOrder = + process.env.T3_ACP_EMIT_OVERLAPPING_XAI_PROMPT_COMPLETE_OUT_OF_ORDER === "1"; +const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; @@ -36,8 +51,21 @@ let parameterizedModelPicker = false; let currentReasoning = "medium"; let currentContext = "272k"; let currentFast = false; +let promptCount = 0; +let overlappingFirstPromptId: string | undefined; const cancelledSessions = new Set(); +function promptIdFromRequestMeta( + request: Pick, +): string | undefined { + const meta = request._meta; + if (meta === null || typeof meta !== "object") { + return undefined; + } + const promptId = meta.promptId ?? meta.requestId; + return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined; +} + function logExit(reason: string): void { if (!exitLogPath) { return; @@ -45,6 +73,10 @@ function logExit(reason: string): void { NodeFS.appendFileSync(exitLogPath, `${reason}\n`, "utf8"); } +function writeJsonRpcNotification(method: string, params: unknown): void { + process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`); +} + process.once("SIGTERM", () => { logExit("SIGTERM"); process.exit(0); @@ -284,22 +316,66 @@ const program = Effect.gen(function* () { }), ); + const emitLoadReplayNotifications = (requestedSessionId: string) => { + writeJsonRpcNotification("session/update", { + _meta: { isReplay: true }, + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "replay-tool-1", + title: "Replay tool", + kind: "search", + status: "completed", + }, + }); + writeJsonRpcNotification("session/update", { + _meta: { isReplay: true }, + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "replayed assistant text" }, + }, + }); + }; + yield* agent.handleLoadSession((request) => - agent.client - .sessionUpdate({ - sessionId: String(request.sessionId ?? sessionId), + Effect.gen(function* () { + const requestedSessionId = String(request.sessionId ?? sessionId); + if (failLoadSession) { + return yield* AcpError.AcpRequestError.internalError("Mock load session failure"); + } + if (hangLoadSessionAfterReplay || delayLoadSessionAfterReplay) { + emitLoadReplayNotifications(requestedSessionId); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "replay-tail" }, + }, + }); + yield* Effect.sleep(loadSessionDelayMs); + return { + modes: modeState(), + models: modelState(), + configOptions: configOptions(), + }; + } + if (emitLoadReplay) { + emitLoadReplayNotifications(requestedSessionId); + } + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, update: { sessionUpdate: "user_message_chunk", content: { type: "text", text: "replay" }, }, - }) - .pipe( - Effect.as({ - modes: modeState(), - models: modelState(), - configOptions: configOptions(), - }), - ), + }); + return { + modes: modeState(), + models: modelState(), + configOptions: configOptions(), + }; + }), ); yield* agent.handleSetSessionModel((request) => @@ -364,11 +440,100 @@ const program = Effect.gen(function* () { yield* agent.handlePrompt((request) => Effect.gen(function* () { const requestedSessionId = String(request.sessionId ?? sessionId); + promptCount += 1; if (Number.isFinite(promptDelayMs) && promptDelayMs > 0) { yield* Effect.sleep(`${promptDelayMs} millis`); } + if (failPrompt) { + return yield* AcpError.AcpRequestError.internalError("Mock prompt failure"); + } + + if (emitStaleXAiPromptCompleteBeforeSecondHang && promptCount === 1) { + return { + stopReason: "end_turn", + _meta: { + promptId: "mock-stale-xai-prompt-1", + requestId: "mock-stale-xai-prompt-1", + }, + }; + } + + if (emitStaleXAiPromptCompleteBeforeSecondHang && promptCount === 2) { + const currentPromptId = promptIdFromRequestMeta(request) ?? "mock-current-xai-prompt-2"; + writeJsonRpcNotification("_x.ai/session/prompt_complete", { + sessionId: requestedSessionId, + promptId: "mock-stale-xai-prompt-1", + stopReason: "end_turn", + agentResult: null, + }); + + writeJsonRpcNotification("_x.ai/session/prompt_complete", { + sessionId: requestedSessionId, + promptId: currentPromptId, + stopReason: "end_turn", + agentResult: null, + }); + + return yield* Effect.never; + } + + if (emitOverlappingXAiPromptCompleteOutOfOrder && promptCount === 1) { + overlappingFirstPromptId = promptIdFromRequestMeta(request); + return yield* Effect.never; + } + + if (emitOverlappingXAiPromptCompleteOutOfOrder && promptCount === 2) { + const secondPromptId = promptIdFromRequestMeta(request); + if (overlappingFirstPromptId !== undefined && secondPromptId !== undefined) { + writeJsonRpcNotification("_x.ai/session/prompt_complete", { + sessionId: requestedSessionId, + promptId: secondPromptId, + stopReason: "end_turn", + agentResult: null, + }); + writeJsonRpcNotification("_x.ai/session/prompt_complete", { + sessionId: requestedSessionId, + promptId: overlappingFirstPromptId, + stopReason: "end_turn", + agentResult: null, + }); + } + return yield* Effect.never; + } + + if (hangPromptForever || (hangFirstPromptForever && promptCount === 1)) { + return yield* Effect.never; + } + + if (emitXAiPromptCompleteThenHang) { + writeJsonRpcNotification("session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hello from " }, + }, + }); + + writeJsonRpcNotification("_x.ai/session/prompt_complete", { + sessionId: requestedSessionId, + promptId: promptIdFromRequestMeta(request) ?? "mock-xai-prompt-1", + ...(omitXAiPromptCompleteStopReason ? {} : { stopReason: "end_turn" }), + agentResult: null, + }); + + writeJsonRpcNotification("session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "mock" }, + }, + }); + + return yield* Effect.never; + } + if (emitInterleavedAssistantToolCalls) { const toolCallId = "tool-call-1"; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 0999000ed4f..0aed9d6afd6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -33,6 +33,7 @@ import { import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; +import { archivedAssistantSegmentMessageId } from "@t3tools/shared/orchestrationMessages"; import { ServerConfig } from "../../config.ts"; const makeProjectionPipelinePrefixedTestLayer = (prefix: string) => @@ -1232,6 +1233,338 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); + it.effect("archives replayed assistant rows on null-turn rebound in projection", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-null-turn-rebind"); + const priorTurnId = TurnId.make("turn-replay"); + const segmentId = MessageId.make("assistant-segment-0"); + const now = "2026-06-25T02:00:00.000Z"; + + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.make("evt-ntr-1"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-ntr-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-ntr-1"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-null-turn-rebind"), + title: "Null turn rebind", + modelSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-composer-2.5-fast", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.make("evt-ntr-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-ntr-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-ntr-2"), + metadata: {}, + payload: { + threadId, + messageId: segmentId, + role: "assistant", + text: "Replayed response.", + turnId: priorTurnId, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.make("evt-ntr-3"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-06-25T02:00:01.000Z", + commandId: CommandId.make("cmd-ntr-3"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-ntr-3"), + metadata: {}, + payload: { + threadId, + messageId: segmentId, + role: "assistant", + text: "New ", + turnId: null, + streaming: true, + createdAt: "2026-06-25T02:00:01.000Z", + updatedAt: "2026-06-25T02:00:01.000Z", + }, + }); + + const archivedMessageId = MessageId.make( + archivedAssistantSegmentMessageId(segmentId, priorTurnId), + ); + const messageRows = yield* sql<{ readonly messageId: string; readonly text: string }>` + SELECT message_id AS "messageId", text + FROM projection_thread_messages + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, message_id ASC + `; + assert.deepEqual(messageRows, [ + { messageId: archivedMessageId, text: "Replayed response." }, + { messageId: segmentId, text: "New " }, + ]); + }), + ); + + it.effect( + "accumulates rebound assistant segment streaming text across turn changes in projection", + () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-segment-rebind"); + const priorTurnId = TurnId.make("turn-prior"); + const nextTurnId = TurnId.make("turn-next"); + const segmentId = MessageId.make( + "assistant:assistant:019efa67-b48b-7022-b4c0-0cba45dfa83d:segment:0", + ); + const now = "2026-06-25T01:26:00.000Z"; + + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.make("evt-sr-1"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-sr-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-sr-1"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-segment-rebind"), + title: "Segment rebind", + modelSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-composer-2.5-fast", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.make("evt-sr-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-sr-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-sr-2"), + metadata: {}, + payload: { + threadId, + messageId: segmentId, + role: "assistant", + text: "Old replayed response.", + turnId: priorTurnId, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.session-set", + eventId: EventId.make("evt-sr-session"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-06-25T01:26:49.000Z", + commandId: CommandId.make("cmd-sr-session"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-sr-session"), + metadata: {}, + payload: { + threadId, + session: { + threadId, + status: "running", + providerName: "grok", + runtimeMode: "full-access", + activeTurnId: nextTurnId, + lastError: null, + updatedAt: "2026-06-25T01:26:49.000Z", + }, + }, + }); + + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.make("evt-sr-3-0"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-06-25T01:26:49.524Z", + commandId: CommandId.make("cmd-sr-3-0"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-sr-3-0"), + metadata: {}, + payload: { + threadId, + messageId: segmentId, + role: "assistant", + text: "Done", + turnId: nextTurnId, + streaming: true, + createdAt: "2026-06-25T01:26:49.524Z", + updatedAt: "2026-06-25T01:26:49.524Z", + }, + }); + + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.make("evt-sr-3-complete-early"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-06-25T01:26:49.530Z", + commandId: CommandId.make("cmd-sr-3-complete-early"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-sr-3-complete-early"), + metadata: {}, + payload: { + threadId, + messageId: segmentId, + role: "assistant", + text: "", + turnId: nextTurnId, + streaming: false, + createdAt: "2026-06-25T01:26:49.530Z", + updatedAt: "2026-06-25T01:26:49.530Z", + }, + }); + + const deltas = [" —", " same", " listing", "."] as const; + for (const [index, delta] of deltas.entries()) { + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.make(`evt-sr-3-${index + 1}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: `2026-06-25T01:26:49.${540 + index}Z`, + commandId: CommandId.make(`cmd-sr-3-${index + 1}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-sr-3-${index + 1}`), + metadata: {}, + payload: { + threadId, + messageId: segmentId, + role: "assistant", + text: delta, + turnId: nextTurnId, + streaming: true, + createdAt: `2026-06-25T01:26:49.${540 + index}Z`, + updatedAt: `2026-06-25T01:26:49.${540 + index}Z`, + }, + }); + } + + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.make("evt-sr-4"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-06-25T01:26:49.949Z", + commandId: CommandId.make("cmd-sr-4"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-sr-4"), + metadata: {}, + payload: { + threadId, + messageId: segmentId, + role: "assistant", + text: "", + turnId: nextTurnId, + streaming: false, + createdAt: "2026-06-25T01:26:49.949Z", + updatedAt: "2026-06-25T01:26:49.949Z", + }, + }); + + const messageRows = yield* sql<{ readonly text: string }>` + SELECT text FROM projection_thread_messages WHERE message_id = ${segmentId} + `; + assert.deepEqual(messageRows, [{ text: "Done — same listing." }]); + + const archivedAssistantMessageId = MessageId.make( + archivedAssistantSegmentMessageId(segmentId, priorTurnId), + ); + const priorTurnRows = yield* sql<{ readonly assistantMessageId: string | null }>` + SELECT assistant_message_id AS "assistantMessageId" + FROM projection_turns + WHERE thread_id = ${threadId} AND turn_id = ${priorTurnId} + `; + assert.deepEqual(priorTurnRows, [{ assistantMessageId: archivedAssistantMessageId }]); + + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.make("evt-sr-stale-prior"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-06-25T01:26:50.000Z", + commandId: CommandId.make("cmd-sr-stale-prior"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-sr-stale-prior"), + metadata: {}, + payload: { + threadId, + messageId: segmentId, + role: "assistant", + text: " stale", + turnId: priorTurnId, + streaming: true, + createdAt: "2026-06-25T01:26:50.000Z", + updatedAt: "2026-06-25T01:26:50.000Z", + }, + }); + + const priorTurnRowsAfterStale = yield* sql<{ readonly assistantMessageId: string | null }>` + SELECT assistant_message_id AS "assistantMessageId" + FROM projection_turns + WHERE thread_id = ${threadId} AND turn_id = ${priorTurnId} + `; + assert.deepEqual(priorTurnRowsAfterStale, [ + { assistantMessageId: archivedAssistantMessageId }, + ]); + }), + ); + it.effect("keeps the turn running across interim assistant messages until the session ends", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; @@ -1320,24 +1653,61 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { const runningRows = yield* sql<{ readonly state: string; readonly completedAt: string | null; + readonly assistantMessageId: string | null; }>` - SELECT state, completed_at AS "completedAt" + SELECT + state, + completed_at AS "completedAt", + assistant_message_id AS "assistantMessageId" FROM projection_turns WHERE thread_id = ${threadId} AND turn_id = ${turnId} `; - assert.deepEqual(runningRows, [{ state: "running", completedAt: null }]); + assert.deepEqual(runningRows, [ + { state: "running", completedAt: null, assistantMessageId: "message-tl-interim" }, + ]); - // The session leaving "running" is the turn-end signal. yield* eventStore.append({ - type: "thread.session-set", + type: "thread.message-sent", eventId: EventId.make("evt-tl4"), aggregateKind: "thread", aggregateId: threadId, - occurredAt: "2026-01-01T00:01:00.000Z", + occurredAt: "2026-01-01T00:00:10.000Z", commandId: CommandId.make("cmd-tl4"), causationEventId: null, correlationId: CorrelationId.make("cmd-tl4"), metadata: {}, + payload: { + threadId, + messageId: MessageId.make("message-tl-latest"), + role: "assistant", + text: "later commentary", + turnId, + streaming: false, + createdAt: "2026-01-01T00:00:10.000Z", + updatedAt: "2026-01-01T00:00:10.000Z", + }, + }); + + yield* projectionPipeline.bootstrap; + + const latestMessageRows = yield* sql<{ readonly assistantMessageId: string | null }>` + SELECT assistant_message_id AS "assistantMessageId" + FROM projection_turns + WHERE thread_id = ${threadId} AND turn_id = ${turnId} + `; + assert.deepEqual(latestMessageRows, [{ assistantMessageId: "message-tl-latest" }]); + + // The session leaving "running" is the turn-end signal. + yield* eventStore.append({ + type: "thread.session-set", + eventId: EventId.make("evt-tl5"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-01-01T00:01:00.000Z", + commandId: CommandId.make("cmd-tl5"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-tl5"), + metadata: {}, payload: { threadId, session: { @@ -2529,6 +2899,134 @@ it.effect("restores pending turn-start metadata across projection pipeline resta ), ); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-bootstrap-order-")))( + "OrchestrationProjectionPipeline", + (it) => { + it.effect("replays bootstrap events in order across projectors", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-bootstrap-order"); + const turnId = TurnId.make("turn-bootstrap-order"); + const messageId = MessageId.make("message-bootstrap-order"); + const createdAt = "2026-06-25T03:00:00.000Z"; + const runningAt = "2026-06-25T03:00:01.000Z"; + const messageAt = "2026-06-25T03:00:02.000Z"; + const completedAt = "2026-06-25T03:00:10.000Z"; + + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.make("evt-bootstrap-order-1"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make("cmd-bootstrap-order-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-bootstrap-order-1"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-bootstrap-order"), + title: "Bootstrap order", + modelSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + + yield* eventStore.append({ + type: "thread.session-set", + eventId: EventId.make("evt-bootstrap-order-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: runningAt, + commandId: CommandId.make("cmd-bootstrap-order-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-bootstrap-order-2"), + metadata: {}, + payload: { + threadId, + session: { + threadId, + status: "running", + providerName: "grok", + runtimeMode: "full-access", + activeTurnId: turnId, + lastError: null, + updatedAt: runningAt, + }, + }, + }); + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.make("evt-bootstrap-order-3"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: messageAt, + commandId: CommandId.make("cmd-bootstrap-order-3"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-bootstrap-order-3"), + metadata: {}, + payload: { + threadId, + messageId, + role: "assistant", + text: "complete", + turnId, + streaming: false, + createdAt: messageAt, + updatedAt: messageAt, + }, + }); + + yield* eventStore.append({ + type: "thread.session-set", + eventId: EventId.make("evt-bootstrap-order-4"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: completedAt, + commandId: CommandId.make("cmd-bootstrap-order-4"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-bootstrap-order-4"), + metadata: {}, + payload: { + threadId, + session: { + threadId, + status: "ready", + providerName: "grok", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: completedAt, + }, + }, + }); + + yield* projectionPipeline.bootstrap; + + const rows = yield* sql<{ + readonly state: string; + readonly completedAt: string | null; + }>` + SELECT state, completed_at AS "completedAt" + FROM projection_turns + WHERE thread_id = ${threadId} AND turn_id = ${turnId} + `; + assert.deepEqual(rows, [{ state: "completed", completedAt }]); + }), + ); + }, +); + const engineLayer = it.layer( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f12df850941..d41dc61fff7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1,6 +1,7 @@ import { ApprovalRequestId, type ChatAttachment, + MessageId, type OrchestrationEvent, type OrchestrationSessionStatus, ThreadId, @@ -12,6 +13,18 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { + archivedAssistantSegmentMessageId, + archivedAssistantSegmentTurnIds, + assistantSegmentBelongsToActiveTurn, + assistantSegmentRebindArchives, + assistantSegmentStreamingTextResets, + assistantSegmentTimelineAnchorResets, + assistantSegmentTurnChanged, + isLateAssistantSegmentFromPriorTurn, + isLateStreamingOnCompletedAssistant, + resolveAssistantSegmentText, +} from "@t3tools/shared/orchestrationMessages"; import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; @@ -816,33 +829,171 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti messageId: event.payload.messageId, }); const previousMessage = Option.getOrUndefined(existingMessage); - const nextText = Option.match(existingMessage, { - onNone: () => event.payload.text, - onSome: (message) => { - if (event.payload.streaming) { - return `${message.text}${event.payload.text}`; - } - if (event.payload.text.length === 0) { - return message.text; + const previousAssistantSegment = previousMessage + ? { + role: previousMessage.role, + streaming: previousMessage.isStreaming, + turnId: previousMessage.turnId, } - return event.payload.text; - }, + : undefined; + const session = yield* projectionThreadSessionRepository.getByThreadId({ + threadId: event.payload.threadId, + }); + const activeTurnId = Option.isSome(session) ? session.value.activeTurnId : null; + const turnStillActive = + Option.isSome(session) && + session.value.status === "running" && + assistantSegmentBelongsToActiveTurn({ + activeTurnId, + existingTurnId: previousAssistantSegment?.turnId, + incomingTurnId: event.payload.turnId, + }); + const incomingAssistantSegment = { + role: event.payload.role, + streaming: event.payload.streaming, + turnId: event.payload.turnId, + }; + const threadMessages = yield* projectionThreadMessageRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + const archivedTurnIds = archivedAssistantSegmentTurnIds( + threadMessages.map((message) => ({ + id: message.messageId, + turnId: message.turnId, + })), + event.payload.messageId, + ); + if ( + isLateAssistantSegmentFromPriorTurn({ + existing: previousAssistantSegment, + incoming: incomingAssistantSegment, + providerMessageId: event.payload.messageId, + archivedTurnIds, + turnStillActive: turnStillActive === true, + }) || + isLateStreamingOnCompletedAssistant({ + existing: previousAssistantSegment, + incoming: incomingAssistantSegment, + turnStillActive: turnStillActive === true, + }) + ) { + return; + } + const turnChanged = assistantSegmentTurnChanged(previousAssistantSegment, { + turnId: event.payload.turnId, }); - const nextAttachments = + const rebindArchives = assistantSegmentRebindArchives( + previousAssistantSegment, + { + streaming: event.payload.streaming, + turnId: event.payload.turnId, + }, + { + activeTurnId, + turnStillActive: turnStillActive === true, + }, + ); + const shouldArchive = turnChanged || rebindArchives; + const textResets = assistantSegmentStreamingTextResets( + previousAssistantSegment, + { + streaming: event.payload.streaming, + turnId: event.payload.turnId, + }, + { + activeTurnId, + turnStillActive: turnStillActive === true, + }, + ); + const timelineAnchorResets = assistantSegmentTimelineAnchorResets( + previousAssistantSegment, + { + streaming: event.payload.streaming, + turnId: event.payload.turnId, + }, + { + activeTurnId, + turnStillActive: turnStillActive === true, + }, + ); + const nextText = resolveAssistantSegmentText( + previousMessage, + { + text: event.payload.text, + streaming: event.payload.streaming, + }, + textResets, + ); + const materializedAttachments = event.payload.attachments !== undefined ? yield* materializeAttachmentsForProjection({ attachments: event.payload.attachments, }) - : previousMessage?.attachments; + : undefined; + const attachmentFields = + materializedAttachments !== undefined + ? { attachments: [...materializedAttachments] } + : shouldArchive + ? { attachments: [] as ProjectionThreadMessage["attachments"] } + : previousMessage?.attachments !== undefined + ? { attachments: [...previousMessage.attachments] } + : {}; + + if (shouldArchive && previousMessage !== undefined) { + const archivedTurnId = previousMessage.turnId; + yield* projectionThreadMessageRepository.upsert({ + messageId: MessageId.make( + archivedAssistantSegmentMessageId( + event.payload.messageId, + archivedTurnId, + previousMessage.createdAt, + ), + ), + threadId: event.payload.threadId, + turnId: archivedTurnId, + role: previousMessage.role, + text: previousMessage.text, + ...(previousMessage.attachments !== undefined + ? { attachments: [...previousMessage.attachments] } + : {}), + isStreaming: false, + createdAt: previousMessage.createdAt, + updatedAt: previousMessage.updatedAt, + }); + if (archivedTurnId !== null) { + const archivedTurn = yield* projectionTurnRepository.getByTurnId({ + threadId: event.payload.threadId, + turnId: archivedTurnId, + }); + if ( + Option.isSome(archivedTurn) && + archivedTurn.value.assistantMessageId === event.payload.messageId + ) { + yield* projectionTurnRepository.upsertByTurnId({ + ...archivedTurn.value, + assistantMessageId: MessageId.make( + archivedAssistantSegmentMessageId( + event.payload.messageId, + archivedTurnId, + previousMessage.createdAt, + ), + ), + }); + } + } + } + yield* projectionThreadMessageRepository.upsert({ messageId: event.payload.messageId, threadId: event.payload.threadId, turnId: event.payload.turnId, role: event.payload.role, text: nextText, - ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), + ...attachmentFields, isStreaming: event.payload.streaming, - createdAt: previousMessage?.createdAt ?? event.payload.createdAt, + createdAt: timelineAnchorResets + ? event.payload.createdAt + : (previousMessage?.createdAt ?? event.payload.createdAt), updatedAt: event.payload.updatedAt, }); return; @@ -1154,11 +1305,22 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (event.payload.turnId === null || event.payload.role !== "assistant") { return; } - // A completed assistant message only settles the turn once the - // session is no longer running it — providers may emit several - // assistant messages per turn (commentary between tool calls), and - // the turn must stay unsettled until the provider reports turn end - // (projected as thread.session-set leaving the "running" status). + const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({ + messageId: event.payload.messageId, + }); + const previousMessage = Option.getOrUndefined(existingMessage); + const previousAssistantSegment = previousMessage + ? { + role: previousMessage.role, + streaming: previousMessage.isStreaming, + turnId: previousMessage.turnId, + } + : undefined; + const incomingAssistantSegment = { + role: event.payload.role, + streaming: event.payload.streaming, + turnId: event.payload.turnId, + }; const session = yield* projectionThreadSessionRepository.getByThreadId({ threadId: event.payload.threadId, }); @@ -1166,6 +1328,37 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti Option.isSome(session) && session.value.status === "running" && session.value.activeTurnId === event.payload.turnId; + const threadMessages = yield* projectionThreadMessageRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + const archivedTurnIds = archivedAssistantSegmentTurnIds( + threadMessages.map((message) => ({ + id: message.messageId, + turnId: message.turnId, + })), + event.payload.messageId, + ); + if ( + isLateAssistantSegmentFromPriorTurn({ + existing: previousAssistantSegment, + incoming: incomingAssistantSegment, + providerMessageId: event.payload.messageId, + archivedTurnIds, + turnStillActive: turnStillRunning, + }) || + isLateStreamingOnCompletedAssistant({ + existing: previousAssistantSegment, + incoming: incomingAssistantSegment, + turnStillActive: turnStillRunning, + }) + ) { + return; + } + // A completed assistant message only settles the turn once the + // session is no longer running it — providers may emit several + // assistant messages per turn (commentary between tool calls), and + // the turn must stay unsettled until the provider reports turn end + // (projected as thread.session-set leaving the "running" status). const settlesTurn = !event.payload.streaming && !turnStillRunning; const existingTurn = yield* projectionTurnRepository.getByTurnId({ threadId: event.payload.threadId, @@ -1464,6 +1657,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti name: ORCHESTRATION_PROJECTOR_NAMES.projects, apply: applyProjectsProjection, }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.threadSessions, + apply: applyThreadSessionsProjection, + }, { name: ORCHESTRATION_PROJECTOR_NAMES.threadMessages, apply: applyThreadMessagesProjection, @@ -1476,10 +1673,6 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti name: ORCHESTRATION_PROJECTOR_NAMES.threadActivities, apply: applyThreadActivitiesProjection, }, - { - name: ORCHESTRATION_PROJECTOR_NAMES.threadSessions, - apply: applyThreadSessionsProjection, - }, { name: ORCHESTRATION_PROJECTOR_NAMES.threadTurns, apply: applyThreadTurnsProjection, @@ -1531,22 +1724,6 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ); }); - const bootstrapProjector = (projector: ProjectorDefinition) => - projectionStateRepository - .getByProjector({ - projector: projector.name, - }) - .pipe( - Effect.flatMap((stateRow) => - Stream.runForEach( - eventStore.readFromSequence( - Option.isSome(stateRow) ? stateRow.value.lastAppliedSequence : 0, - ), - (event) => runProjectorForEvent(projector, event), - ), - ), - ); - const projectEvent: OrchestrationProjectionPipelineShape["projectEvent"] = (event) => Effect.forEach(projectors, (projector) => runProjectorForEvent(projector, event), { concurrency: 1, @@ -1560,11 +1737,37 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ), ); - const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.forEach( - projectors, - bootstrapProjector, - { concurrency: 1 }, - ).pipe( + const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.gen(function* () { + const stateRows = yield* projectionStateRepository.listAll(); + const lastAppliedByProjector = new Map( + stateRows.map((row) => [row.projector, row.lastAppliedSequence] as const), + ); + const minLastAppliedSequence = projectors.reduce( + (minimum, projector) => Math.min(minimum, lastAppliedByProjector.get(projector.name) ?? 0), + Number.POSITIVE_INFINITY, + ); + const readFromSequence = Number.isFinite(minLastAppliedSequence) ? minLastAppliedSequence : 0; + + yield* Stream.runForEach(eventStore.readFromSequence(readFromSequence), (event) => + Effect.forEach( + projectors, + (projector) => { + const lastAppliedSequence = lastAppliedByProjector.get(projector.name) ?? 0; + if (lastAppliedSequence >= event.sequence) { + return Effect.void; + } + return runProjectorForEvent(projector, event).pipe( + Effect.tap(() => + Effect.sync(() => { + lastAppliedByProjector.set(projector.name, event.sequence); + }), + ), + ); + }, + { concurrency: 1 }, + ), + ); + }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), Effect.provideService(ServerConfig, serverConfig), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 3e5978f4846..d16d918409c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1545,24 +1545,29 @@ const make = Effect.gen(function* () { const proposedPlans = detailedThread?.proposedPlans ?? []; const turnId = toTurnId(event.turnId); if (turnId) { - const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId); - yield* Effect.forEach( - assistantMessageIds, - (assistantMessageId) => - finalizeAssistantMessage({ - event, - threadId: thread.id, - messageId: assistantMessageId, - turnId, - createdAt: now, - commandTag: "assistant-complete-finalize", - finalDeltaCommandTag: "assistant-delta-finalize-fallback", - hasProjectedMessage: findMessageById(messages, assistantMessageId) !== undefined, - }), - { concurrency: 1 }, - ).pipe(Effect.asVoid); - yield* clearAssistantMessageIdsForTurn(thread.id, turnId); - yield* clearAssistantSegmentStateForTurn(thread.id, turnId); + // Grok can emit turn.completed while assistant segments are still + // streaming. Defer assistant finalization to item.completed so + // trailing chunks keep appending to the active segment. + if (event.provider !== "grok") { + const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId); + yield* Effect.forEach( + assistantMessageIds, + (assistantMessageId) => + finalizeAssistantMessage({ + event, + threadId: thread.id, + messageId: assistantMessageId, + turnId, + createdAt: now, + commandTag: "assistant-complete-finalize", + finalDeltaCommandTag: "assistant-delta-finalize-fallback", + hasProjectedMessage: findMessageById(messages, assistantMessageId) !== undefined, + }), + { concurrency: 1 }, + ).pipe(Effect.asVoid); + yield* clearAssistantMessageIdsForTurn(thread.id, turnId); + yield* clearAssistantSegmentStateForTurn(thread.id, turnId); + } yield* finalizeBufferedProposedPlan({ event, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index fadd5078026..0fa81c14f41 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -1,11 +1,14 @@ import { CommandId, EventId, + MessageId, ProjectId, ProviderDriverKind, ThreadId, + TurnId, type OrchestrationEvent, } from "@t3tools/contracts"; +import { it as effectIt } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { describe, expect, it } from "vite-plus/test"; @@ -484,8 +487,11 @@ describe("orchestration projector", () => { expect(message?.updatedAt).toBe(completeAt); }); - it("prunes reverted turn messages from in-memory thread snapshot", async () => { - const createdAt = "2026-02-23T10:00:00.000Z"; + it("appends trailing assistant deltas for the same turn after session settles", async () => { + const createdAt = "2026-06-25T18:26:53.000Z"; + const deltaAt = "2026-06-25T18:27:58.000Z"; + const completeAt = "2026-06-25T18:28:10.714Z"; + const trailingAt = "2026-06-25T18:28:10.747Z"; const model = createEmptyReadModel(createdAt); const afterCreate = await Effect.runPromise( @@ -503,8 +509,8 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", modelSelection: { - provider: ProviderDriverKind.make("codex"), - model: "gpt-5.3-codex", + provider: ProviderDriverKind.make("grok"), + model: "grok-3", }, runtimeMode: "full-access", branch: null, @@ -516,205 +522,153 @@ describe("orchestration projector", () => { ), ); - const events: ReadonlyArray = [ - makeEvent({ - sequence: 2, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: "2026-02-23T10:00:01.000Z", - commandId: "cmd-user-1", - payload: { - threadId: "thread-1", - messageId: "user-msg-1", - role: "user", - text: "First edit", - turnId: null, - streaming: false, - createdAt: "2026-02-23T10:00:01.000Z", - updatedAt: "2026-02-23T10:00:01.000Z", - }, - }), - makeEvent({ - sequence: 3, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: "2026-02-23T10:00:02.000Z", - commandId: "cmd-assistant-1", - payload: { - threadId: "thread-1", - messageId: "assistant-msg-1", - role: "assistant", - text: "Updated README to v2.\n", - turnId: "turn-1", - streaming: false, - createdAt: "2026-02-23T10:00:02.000Z", - updatedAt: "2026-02-23T10:00:02.000Z", - }, - }), - makeEvent({ - sequence: 4, - type: "thread.turn-diff-completed", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: "2026-02-23T10:00:02.500Z", - commandId: "cmd-turn-1-complete", - payload: { - threadId: "thread-1", - turnId: "turn-1", - checkpointTurnCount: 1, - checkpointRef: "refs/t3/checkpoints/thread-1/turn/1", - status: "ready", - files: [], - assistantMessageId: "assistant-msg-1", - completedAt: "2026-02-23T10:00:02.500Z", - }, - }), - makeEvent({ - sequence: 5, - type: "thread.activity-appended", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: "2026-02-23T10:00:02.750Z", - commandId: "cmd-activity-1", - payload: { - threadId: "thread-1", - activity: { - id: "activity-1", - tone: "tool", - kind: "tool.started", - summary: "Edit file started", - payload: { toolKind: "command" }, + const afterSessionRunning = await Effect.runPromise( + projectEvent( + afterCreate, + makeEvent({ + sequence: 2, + type: "thread.session-set", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: deltaAt, + commandId: "cmd-session-running", + payload: { + threadId: "thread-1", + session: { + threadId: "thread-1", + status: "running", + providerName: ProviderDriverKind.make("grok"), + runtimeMode: "full-access", + activeTurnId: "turn-1", + lastError: null, + updatedAt: deltaAt, + }, + }, + }), + ), + ); + + const afterDelta = await Effect.runPromise( + projectEvent( + afterSessionRunning, + makeEvent({ + sequence: 3, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: deltaAt, + commandId: "cmd-delta", + payload: { + threadId: "thread-1", + messageId: "assistant:msg-1", + role: "assistant", + text: "section 7 starts here", turnId: "turn-1", - createdAt: "2026-02-23T10:00:02.750Z", + streaming: true, + createdAt: deltaAt, + updatedAt: deltaAt, }, - }, - }), - makeEvent({ - sequence: 6, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: "2026-02-23T10:00:03.000Z", - commandId: "cmd-user-2", - payload: { - threadId: "thread-1", - messageId: "user-msg-2", - role: "user", - text: "Second edit", - turnId: null, - streaming: false, - createdAt: "2026-02-23T10:00:03.000Z", - updatedAt: "2026-02-23T10:00:03.000Z", - }, - }), - makeEvent({ - sequence: 7, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: "2026-02-23T10:00:04.000Z", - commandId: "cmd-assistant-2", - payload: { - threadId: "thread-1", - messageId: "assistant-msg-2", - role: "assistant", - text: "Updated README to v3.\n", - turnId: "turn-2", - streaming: false, - createdAt: "2026-02-23T10:00:04.000Z", - updatedAt: "2026-02-23T10:00:04.000Z", - }, - }), - makeEvent({ - sequence: 8, - type: "thread.turn-diff-completed", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: "2026-02-23T10:00:04.500Z", - commandId: "cmd-turn-2-complete", - payload: { - threadId: "thread-1", - turnId: "turn-2", - checkpointTurnCount: 2, - checkpointRef: "refs/t3/checkpoints/thread-1/turn/2", - status: "ready", - files: [], - assistantMessageId: "assistant-msg-2", - completedAt: "2026-02-23T10:00:04.500Z", - }, - }), - makeEvent({ - sequence: 9, - type: "thread.activity-appended", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: "2026-02-23T10:00:04.750Z", - commandId: "cmd-activity-2", - payload: { - threadId: "thread-1", - activity: { - id: "activity-2", - tone: "tool", - kind: "tool.completed", - summary: "Edit file complete", - payload: { toolKind: "command" }, - turnId: "turn-2", - createdAt: "2026-02-23T10:00:04.750Z", + }), + ), + ); + + const afterComplete = await Effect.runPromise( + projectEvent( + afterDelta, + makeEvent({ + sequence: 4, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: completeAt, + commandId: "cmd-complete", + payload: { + threadId: "thread-1", + messageId: "assistant:msg-1", + role: "assistant", + text: "", + turnId: "turn-1", + streaming: false, + createdAt: completeAt, + updatedAt: completeAt, }, - }, - }), - makeEvent({ - sequence: 10, - type: "thread.reverted", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: "2026-02-23T10:00:05.000Z", - commandId: "cmd-revert", - payload: { - threadId: "thread-1", - turnCount: 1, - }, - }), - ]; + }), + ), + ); - const afterRevert = await events.reduce>>( - (statePromise, event) => - statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), - Promise.resolve(afterCreate), + const afterSessionReady = await Effect.runPromise( + projectEvent( + afterComplete, + makeEvent({ + sequence: 5, + type: "thread.session-set", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: completeAt, + commandId: "cmd-session-ready", + payload: { + threadId: "thread-1", + session: { + threadId: "thread-1", + status: "ready", + providerName: ProviderDriverKind.make("grok"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: completeAt, + }, + }, + }), + ), ); - const thread = afterRevert.threads[0]; - expect(thread?.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual( - [ - { role: "user", text: "First edit" }, - { role: "assistant", text: "Updated README to v2.\n" }, - ], + const afterTrailingDelta = await Effect.runPromise( + projectEvent( + afterSessionReady, + makeEvent({ + sequence: 6, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: trailingAt, + commandId: "cmd-trailing-delta", + payload: { + threadId: "thread-1", + messageId: "assistant:msg-1", + role: "assistant", + text: " and the rest of section 7", + turnId: "turn-1", + streaming: true, + createdAt: trailingAt, + updatedAt: trailingAt, + }, + }), + ), ); - expect( - thread?.activities.map((activity) => ({ id: activity.id, turnId: activity.turnId })), - ).toEqual([{ id: "activity-1", turnId: "turn-1" }]); - expect(thread?.checkpoints.map((checkpoint) => checkpoint.checkpointTurnCount)).toEqual([1]); - expect(thread?.latestTurn?.turnId).toBe("turn-1"); + + const message = afterTrailingDelta.threads[0]?.messages[0]; + expect(message?.text).toBe("section 7 starts here and the rest of section 7"); + expect(message?.streaming).toBe(true); }); - it("does not fallback-retain messages tied to removed turn IDs", async () => { - const createdAt = "2026-02-26T12:00:00.000Z"; - const model = createEmptyReadModel(createdAt); + effectIt.effect("preserves latestTurn completion time across later assistant messages", () => + Effect.gen(function* () { + const createdAt = "2026-02-23T09:10:00.000Z"; + const completeAt = "2026-02-23T09:10:03.500Z"; + const laterAt = "2026-02-23T09:10:05.000Z"; + const model = createEmptyReadModel(createdAt); - const afterCreate = await Effect.runPromise( - projectEvent( + const afterCreate = yield* projectEvent( model, makeEvent({ sequence: 1, type: "thread.created", aggregateKind: "thread", - aggregateId: "thread-revert", + aggregateId: "thread-1", occurredAt: createdAt, - commandId: "cmd-create-revert", + commandId: "cmd-create", payload: { - threadId: "thread-revert", + threadId: "thread-1", projectId: "project-1", title: "demo", modelSelection: { @@ -728,151 +682,106 @@ describe("orchestration projector", () => { updatedAt: createdAt, }, }), - ), - ); + ); - const events: ReadonlyArray = [ - makeEvent({ - sequence: 2, - type: "thread.turn-diff-completed", - aggregateKind: "thread", - aggregateId: "thread-revert", - occurredAt: "2026-02-26T12:00:01.000Z", - commandId: "cmd-turn-1", - payload: { - threadId: "thread-revert", - turnId: "turn-1", - checkpointTurnCount: 1, - checkpointRef: "refs/t3/checkpoints/thread-revert/turn/1", - status: "ready", - files: [], - assistantMessageId: "assistant-keep", - completedAt: "2026-02-26T12:00:01.000Z", - }, - }), - makeEvent({ - sequence: 3, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-revert", - occurredAt: "2026-02-26T12:00:01.100Z", - commandId: "cmd-assistant-keep", - payload: { - threadId: "thread-revert", - messageId: "assistant-keep", - role: "assistant", - text: "kept", - turnId: "turn-1", - streaming: false, - createdAt: "2026-02-26T12:00:01.100Z", - updatedAt: "2026-02-26T12:00:01.100Z", - }, - }), - makeEvent({ - sequence: 4, - type: "thread.turn-diff-completed", - aggregateKind: "thread", - aggregateId: "thread-revert", - occurredAt: "2026-02-26T12:00:02.000Z", - commandId: "cmd-turn-2", - payload: { - threadId: "thread-revert", - turnId: "turn-2", - checkpointTurnCount: 2, - checkpointRef: "refs/t3/checkpoints/thread-revert/turn/2", - status: "ready", - files: [], - assistantMessageId: "assistant-remove", - completedAt: "2026-02-26T12:00:02.000Z", - }, - }), - makeEvent({ - sequence: 5, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-revert", - occurredAt: "2026-02-26T12:00:02.050Z", - commandId: "cmd-user-remove", - payload: { - threadId: "thread-revert", - messageId: "user-remove", - role: "user", - text: "removed", - turnId: "turn-2", - streaming: false, - createdAt: "2026-02-26T12:00:02.050Z", - updatedAt: "2026-02-26T12:00:02.050Z", - }, - }), - makeEvent({ - sequence: 6, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-revert", - occurredAt: "2026-02-26T12:00:02.100Z", - commandId: "cmd-assistant-remove", - payload: { - threadId: "thread-revert", - messageId: "assistant-remove", - role: "assistant", - text: "removed", - turnId: "turn-2", - streaming: false, - createdAt: "2026-02-26T12:00:02.100Z", - updatedAt: "2026-02-26T12:00:02.100Z", - }, - }), - makeEvent({ - sequence: 7, - type: "thread.reverted", - aggregateKind: "thread", - aggregateId: "thread-revert", - occurredAt: "2026-02-26T12:00:03.000Z", - commandId: "cmd-revert", - payload: { - threadId: "thread-revert", - turnCount: 1, - }, - }), - ]; + const afterFirstMessage = yield* projectEvent( + afterCreate, + makeEvent({ + sequence: 2, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: completeAt, + commandId: "cmd-complete", + payload: { + threadId: "thread-1", + messageId: "assistant:msg-1", + role: "assistant", + text: "first", + turnId: "turn-1", + streaming: false, + createdAt: completeAt, + updatedAt: completeAt, + }, + }), + ); - const afterRevert = await events.reduce>>( - (statePromise, event) => - statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), - Promise.resolve(afterCreate), - ); + const afterCheckpoint = yield* projectEvent( + afterFirstMessage, + makeEvent({ + sequence: 3, + type: "thread.turn-diff-completed", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: completeAt, + commandId: "cmd-checkpoint", + payload: { + threadId: "thread-1", + turnId: "turn-1", + checkpointTurnCount: 1, + checkpointRef: "refs/t3/checkpoints/thread-1/turn/1", + status: "ready", + files: [], + assistantMessageId: "assistant:msg-1", + completedAt: completeAt, + }, + }), + ); - const thread = afterRevert.threads[0]; - expect( - thread?.messages.map((message) => ({ - id: message.id, - role: message.role, - turnId: message.turnId, - })), - ).toEqual([{ id: "assistant-keep", role: "assistant", turnId: "turn-1" }]); - }); + const afterLaterMessage = yield* projectEvent( + afterCheckpoint, + makeEvent({ + sequence: 4, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: laterAt, + commandId: "cmd-later", + payload: { + threadId: "thread-1", + messageId: "assistant:msg-2", + role: "assistant", + text: "second", + turnId: "turn-1", + streaming: false, + createdAt: laterAt, + updatedAt: laterAt, + }, + }), + ); - it("caps message and checkpoint retention for long-lived threads", async () => { - const createdAt = "2026-03-01T10:00:00.000Z"; - const model = createEmptyReadModel(createdAt); + const latestTurn = afterLaterMessage.threads[0]?.latestTurn; + expect(latestTurn?.assistantMessageId).toBe("assistant:msg-2"); + expect(latestTurn?.completedAt).toBe(completeAt); + expect(afterLaterMessage.threads[0]?.checkpoints[0]?.assistantMessageId).toBe( + "assistant:msg-2", + ); + }), + ); - const afterCreate = await Effect.runPromise( - projectEvent( + effectIt.effect("does not regress interrupted latestTurn on late streaming chunks", () => + Effect.gen(function* () { + const createdAt = "2026-02-23T09:10:00.000Z"; + const interruptedAt = "2026-02-23T09:10:03.500Z"; + const laterAt = "2026-02-23T09:10:05.000Z"; + const model = createEmptyReadModel(createdAt); + + const afterCreate = yield* projectEvent( model, makeEvent({ sequence: 1, type: "thread.created", aggregateKind: "thread", - aggregateId: "thread-capped", + aggregateId: "thread-1", occurredAt: createdAt, - commandId: "cmd-create-capped", + commandId: "cmd-create", payload: { - threadId: "thread-capped", + threadId: "thread-1", projectId: "project-1", - title: "capped", + title: "demo", modelSelection: { provider: ProviderDriverKind.make("codex"), - model: "gpt-5-codex", + model: "gpt-5.3-codex", }, runtimeMode: "full-access", branch: null, @@ -881,75 +790,1060 @@ describe("orchestration projector", () => { updatedAt: createdAt, }, }), - ), - ); + ); - const messageEvents: ReadonlyArray = Array.from( - { length: 2_100 }, - (_, index) => + const afterMessage = yield* projectEvent( + afterCreate, makeEvent({ - sequence: index + 2, + sequence: 2, type: "thread.message-sent", aggregateKind: "thread", - aggregateId: "thread-capped", - occurredAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, - commandId: `cmd-message-${index}`, + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-message", payload: { - threadId: "thread-capped", - messageId: `msg-${index}`, + threadId: "thread-1", + messageId: "assistant:msg-1", role: "assistant", - text: `message-${index}`, - turnId: `turn-${index}`, - streaming: false, - createdAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, - updatedAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, + text: "partial", + turnId: "turn-1", + streaming: true, + createdAt, + updatedAt: createdAt, }, }), - ); - const afterMessages = await messageEvents.reduce< - Promise> - >( - (statePromise, event) => - statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), - Promise.resolve(afterCreate), - ); + ); + + const afterInterrupt = { + ...afterMessage, + threads: afterMessage.threads.map((thread) => + thread.id === "thread-1" + ? { + ...thread, + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "interrupted" as const, + requestedAt: createdAt, + startedAt: createdAt, + completedAt: interruptedAt, + assistantMessageId: MessageId.make("assistant:msg-1"), + }, + } + : thread, + ), + }; - const checkpointEvents: ReadonlyArray = Array.from( - { length: 600 }, - (_, index) => + const afterLateChunk = yield* projectEvent( + afterInterrupt, makeEvent({ - sequence: index + 2_102, - type: "thread.turn-diff-completed", + sequence: 4, + type: "thread.message-sent", aggregateKind: "thread", - aggregateId: "thread-capped", - occurredAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, - commandId: `cmd-checkpoint-${index}`, + aggregateId: "thread-1", + occurredAt: laterAt, + commandId: "cmd-late", payload: { - threadId: "thread-capped", - turnId: `turn-${index}`, - checkpointTurnCount: index + 1, - checkpointRef: `refs/t3/checkpoints/thread-capped/turn/${index + 1}`, - status: "ready", - files: [], - assistantMessageId: `msg-${index}`, - completedAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, + threadId: "thread-1", + messageId: "assistant:msg-1", + role: "assistant", + text: " trailing", + turnId: "turn-1", + streaming: true, + createdAt: laterAt, + updatedAt: laterAt, }, }), - ); - const finalState = await checkpointEvents.reduce< - Promise> - >( - (statePromise, event) => - statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), - Promise.resolve(afterMessages), - ); + ); - const thread = finalState.threads[0]; - expect(thread?.messages).toHaveLength(2_000); - expect(thread?.messages[0]?.id).toBe("msg-100"); - expect(thread?.messages.at(-1)?.id).toBe("msg-2099"); - expect(thread?.checkpoints).toHaveLength(500); - expect(thread?.checkpoints[0]?.turnId).toBe("turn-100"); - expect(thread?.checkpoints.at(-1)?.turnId).toBe("turn-599"); - }); + const latestTurn = afterLateChunk.threads[0]?.latestTurn; + expect(latestTurn?.state).toBe("interrupted"); + expect(latestTurn?.completedAt).toBe(interruptedAt); + }), + ); + + effectIt.effect( + "advances latestTurn when a fresh assistant message belongs to the active turn", + () => + Effect.gen(function* () { + const createdAt = "2026-02-23T09:10:00.000Z"; + const secondTurnAt = "2026-02-23T09:20:00.000Z"; + const model = createEmptyReadModel(createdAt); + + const afterCreate = yield* projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }), + ); + + const afterFirstMessage = yield* projectEvent( + afterCreate, + makeEvent({ + sequence: 2, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-message-1", + payload: { + threadId: "thread-1", + messageId: "assistant:msg-1", + role: "assistant", + text: "done", + turnId: "turn-1", + streaming: false, + createdAt, + updatedAt: createdAt, + }, + }), + ); + + const afterSecondSession = yield* projectEvent( + afterFirstMessage, + makeEvent({ + sequence: 3, + type: "thread.session-set", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: secondTurnAt, + commandId: "cmd-session-running", + payload: { + threadId: "thread-1", + session: { + threadId: "thread-1", + status: "running", + providerName: "grok", + runtimeMode: "full-access", + activeTurnId: "turn-2", + lastError: null, + updatedAt: secondTurnAt, + }, + }, + }), + ); + + const afterSecondMessage = yield* projectEvent( + afterSecondSession, + makeEvent({ + sequence: 4, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: secondTurnAt, + commandId: "cmd-message-2", + payload: { + threadId: "thread-1", + messageId: "assistant:msg-2", + role: "assistant", + text: "working", + turnId: "turn-2", + streaming: true, + createdAt: secondTurnAt, + updatedAt: secondTurnAt, + }, + }), + ); + + const latestTurn = afterSecondMessage.threads[0]?.latestTurn; + expect(latestTurn?.turnId).toBe("turn-2"); + expect(latestTurn?.state).toBe("running"); + expect(latestTurn?.assistantMessageId).toBe("assistant:msg-2"); + }), + ); + + it("archives replayed assistant rows and advances latestTurn when a reused segment rebinds", async () => { + const createdAt = "2026-06-24T00:29:27.101Z"; + const reboundAt = "2026-06-24T01:12:00.260Z"; + const model = createEmptyReadModel(createdAt); + + const afterRebound = await Effect.runPromise( + Effect.gen(function* () { + let state = model; + state = yield* projectEvent( + state, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }), + ); + state = yield* projectEvent( + state, + makeEvent({ + sequence: 2, + type: "thread.session-set", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-session-running", + payload: { + threadId: "thread-1", + session: { + threadId: "thread-1", + status: "running", + providerName: "grok", + runtimeMode: "full-access", + activeTurnId: "turn-replay", + lastError: null, + updatedAt: createdAt, + }, + }, + }), + ); + state = yield* projectEvent( + state, + makeEvent({ + sequence: 3, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-replay", + payload: { + threadId: "thread-1", + messageId: "assistant-segment-0", + role: "assistant", + text: "Health-check response from replay.", + turnId: "turn-replay", + streaming: false, + createdAt, + updatedAt: createdAt, + }, + }), + ); + return yield* projectEvent( + state, + makeEvent({ + sequence: 3, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: reboundAt, + commandId: "cmd-rebound", + payload: { + threadId: "thread-1", + messageId: "assistant-segment-0", + role: "assistant", + text: "New ", + turnId: "turn-follow-up", + streaming: true, + createdAt: reboundAt, + updatedAt: reboundAt, + }, + }), + ); + }), + ); + + const thread = afterRebound.threads[0]; + expect(thread?.messages).toHaveLength(2); + expect(thread?.messages[0]?.text).toBe("Health-check response from replay."); + expect(thread?.messages[1]?.text).toBe("New "); + expect(thread?.messages[1]?.turnId).toBe("turn-follow-up"); + expect(thread?.latestTurn?.turnId).toBe("turn-follow-up"); + expect(thread?.latestTurn?.state).toBe("running"); + expect(thread?.latestTurn?.assistantMessageId).toBe("assistant-segment-0"); + }); + + effectIt.effect( + "does not regress latestTurn when a late rebound targets an older archived turn", + () => + Effect.gen(function* () { + const createdAt = "2026-06-24T00:29:27.101Z"; + const reboundAt = "2026-06-24T01:12:00.260Z"; + const completedAt = "2026-06-24T01:12:05.000Z"; + const staleAt = "2026-06-24T01:12:10.000Z"; + let state = createEmptyReadModel(createdAt); + state = yield* projectEvent( + state, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }), + ); + state = yield* projectEvent( + state, + makeEvent({ + sequence: 2, + type: "thread.session-set", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-session-running", + payload: { + threadId: "thread-1", + session: { + threadId: "thread-1", + status: "running", + providerName: "grok", + runtimeMode: "full-access", + activeTurnId: "turn-replay", + lastError: null, + updatedAt: createdAt, + }, + }, + }), + ); + state = yield* projectEvent( + state, + makeEvent({ + sequence: 3, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-replay", + payload: { + threadId: "thread-1", + messageId: "assistant-segment-0", + role: "assistant", + text: "Health-check response from replay.", + turnId: "turn-replay", + streaming: false, + createdAt, + updatedAt: createdAt, + }, + }), + ); + state = yield* projectEvent( + state, + makeEvent({ + sequence: 4, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: reboundAt, + commandId: "cmd-rebound", + payload: { + threadId: "thread-1", + messageId: "assistant-segment-0", + role: "assistant", + text: "Follow-up response.", + turnId: "turn-follow-up", + streaming: false, + createdAt: reboundAt, + updatedAt: reboundAt, + }, + }), + ); + state = yield* projectEvent( + state, + makeEvent({ + sequence: 5, + type: "thread.session-set", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: completedAt, + commandId: "cmd-session-ready", + payload: { + threadId: "thread-1", + session: { + threadId: "thread-1", + status: "ready", + providerName: "grok", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: completedAt, + }, + }, + }), + ); + state = yield* projectEvent( + state, + makeEvent({ + sequence: 6, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: staleAt, + commandId: "cmd-stale-replay", + payload: { + threadId: "thread-1", + messageId: "assistant-segment-0", + role: "assistant", + text: " stale", + turnId: "turn-replay", + streaming: true, + createdAt: staleAt, + updatedAt: staleAt, + }, + }), + ); + + const thread = state.threads[0]; + expect(thread?.latestTurn?.turnId).toBe("turn-follow-up"); + expect(thread?.latestTurn?.assistantMessageId).toBe("assistant-segment-0"); + }), + ); + + effectIt.effect( + "skips checkpoint repoint when the archived assistant row is evicted by message cap", + () => + Effect.gen(function* () { + const createdAt = "2026-06-24T00:29:27.101Z"; + const reboundAt = "2026-06-24T01:12:00.260Z"; + let state = createEmptyReadModel(createdAt); + state = yield* projectEvent( + state, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }), + ); + state = yield* projectEvent( + state, + makeEvent({ + sequence: 2, + type: "thread.session-set", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-session-running", + payload: { + threadId: "thread-1", + session: { + threadId: "thread-1", + status: "running", + providerName: "grok", + runtimeMode: "full-access", + activeTurnId: "turn-replay", + lastError: null, + updatedAt: createdAt, + }, + }, + }), + ); + state = yield* projectEvent( + state, + makeEvent({ + sequence: 3, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-replay", + payload: { + threadId: "thread-1", + messageId: "assistant-segment-0", + role: "assistant", + text: "Health-check response from replay.", + turnId: "turn-replay", + streaming: false, + createdAt, + updatedAt: createdAt, + }, + }), + ); + state = yield* projectEvent( + state, + makeEvent({ + sequence: 4, + type: "thread.turn-diff-completed", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-checkpoint", + payload: { + threadId: "thread-1", + turnId: "turn-replay", + checkpointTurnCount: 1, + checkpointRef: "refs/t3/checkpoints/thread-1/turn/1", + status: "ready", + files: [], + assistantMessageId: "assistant-segment-0", + completedAt: createdAt, + }, + }), + ); + + const fillerEvents: ReadonlyArray = Array.from( + { length: 1_999 }, + (_, index) => + makeEvent({ + sequence: index + 5, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: `2026-06-24T00:30:${String(index % 60).padStart(2, "0")}.000Z`, + commandId: `cmd-filler-${index}`, + payload: { + threadId: "thread-1", + messageId: `msg-filler-${index}`, + role: "user", + text: `filler-${index}`, + turnId: null, + streaming: false, + createdAt: `2026-06-24T00:30:${String(index % 60).padStart(2, "0")}.000Z`, + updatedAt: `2026-06-24T00:30:${String(index % 60).padStart(2, "0")}.000Z`, + }, + }), + ); + for (const event of fillerEvents) { + state = yield* projectEvent(state, event); + } + + state = yield* projectEvent( + state, + makeEvent({ + sequence: 2_004, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: reboundAt, + commandId: "cmd-rebound", + payload: { + threadId: "thread-1", + messageId: "assistant-segment-0", + role: "assistant", + text: "New ", + turnId: "turn-follow-up", + streaming: true, + createdAt: reboundAt, + updatedAt: reboundAt, + }, + }), + ); + + const thread = state.threads[0]; + expect(thread?.messages).toHaveLength(2_000); + expect( + thread?.messages.some((message) => message.id === "assistant-segment-0@turn:turn-replay"), + ).toBe(false); + expect(thread?.checkpoints[0]?.assistantMessageId).toBe("assistant-segment-0"); + }), + ); + + effectIt.effect("prunes reverted turn messages from in-memory thread snapshot", () => + Effect.gen(function* () { + const createdAt = "2026-02-23T10:00:00.000Z"; + const model = createEmptyReadModel(createdAt); + + const afterCreate = yield* projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }), + ); + + const events: ReadonlyArray = [ + makeEvent({ + sequence: 2, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-02-23T10:00:01.000Z", + commandId: "cmd-user-1", + payload: { + threadId: "thread-1", + messageId: "user-msg-1", + role: "user", + text: "First edit", + turnId: null, + streaming: false, + createdAt: "2026-02-23T10:00:01.000Z", + updatedAt: "2026-02-23T10:00:01.000Z", + }, + }), + makeEvent({ + sequence: 3, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-02-23T10:00:02.000Z", + commandId: "cmd-assistant-1", + payload: { + threadId: "thread-1", + messageId: "assistant-msg-1", + role: "assistant", + text: "Updated README to v2.\n", + turnId: "turn-1", + streaming: false, + createdAt: "2026-02-23T10:00:02.000Z", + updatedAt: "2026-02-23T10:00:02.000Z", + }, + }), + makeEvent({ + sequence: 4, + type: "thread.turn-diff-completed", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-02-23T10:00:02.500Z", + commandId: "cmd-turn-1-complete", + payload: { + threadId: "thread-1", + turnId: "turn-1", + checkpointTurnCount: 1, + checkpointRef: "refs/t3/checkpoints/thread-1/turn/1", + status: "ready", + files: [], + assistantMessageId: "assistant-msg-1", + completedAt: "2026-02-23T10:00:02.500Z", + }, + }), + makeEvent({ + sequence: 5, + type: "thread.activity-appended", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-02-23T10:00:02.750Z", + commandId: "cmd-activity-1", + payload: { + threadId: "thread-1", + activity: { + id: "activity-1", + tone: "tool", + kind: "tool.started", + summary: "Edit file started", + payload: { toolKind: "command" }, + turnId: "turn-1", + createdAt: "2026-02-23T10:00:02.750Z", + }, + }, + }), + makeEvent({ + sequence: 6, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-02-23T10:00:03.000Z", + commandId: "cmd-user-2", + payload: { + threadId: "thread-1", + messageId: "user-msg-2", + role: "user", + text: "Second edit", + turnId: null, + streaming: false, + createdAt: "2026-02-23T10:00:03.000Z", + updatedAt: "2026-02-23T10:00:03.000Z", + }, + }), + makeEvent({ + sequence: 7, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-02-23T10:00:04.000Z", + commandId: "cmd-assistant-2", + payload: { + threadId: "thread-1", + messageId: "assistant-msg-2", + role: "assistant", + text: "Updated README to v3.\n", + turnId: "turn-2", + streaming: false, + createdAt: "2026-02-23T10:00:04.000Z", + updatedAt: "2026-02-23T10:00:04.000Z", + }, + }), + makeEvent({ + sequence: 8, + type: "thread.turn-diff-completed", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-02-23T10:00:04.500Z", + commandId: "cmd-turn-2-complete", + payload: { + threadId: "thread-1", + turnId: "turn-2", + checkpointTurnCount: 2, + checkpointRef: "refs/t3/checkpoints/thread-1/turn/2", + status: "ready", + files: [], + assistantMessageId: "assistant-msg-2", + completedAt: "2026-02-23T10:00:04.500Z", + }, + }), + makeEvent({ + sequence: 9, + type: "thread.activity-appended", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-02-23T10:00:04.750Z", + commandId: "cmd-activity-2", + payload: { + threadId: "thread-1", + activity: { + id: "activity-2", + tone: "tool", + kind: "tool.completed", + summary: "Edit file complete", + payload: { toolKind: "command" }, + turnId: "turn-2", + createdAt: "2026-02-23T10:00:04.750Z", + }, + }, + }), + makeEvent({ + sequence: 10, + type: "thread.reverted", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-02-23T10:00:05.000Z", + commandId: "cmd-revert", + payload: { + threadId: "thread-1", + turnCount: 1, + }, + }), + ]; + + let afterRevert = afterCreate; + for (const event of events) { + afterRevert = yield* projectEvent(afterRevert, event); + } + + const thread = afterRevert.threads[0]; + expect( + thread?.messages.map((message) => ({ role: message.role, text: message.text })), + ).toEqual([ + { role: "user", text: "First edit" }, + { role: "assistant", text: "Updated README to v2.\n" }, + ]); + expect( + thread?.activities.map((activity) => ({ id: activity.id, turnId: activity.turnId })), + ).toEqual([{ id: "activity-1", turnId: "turn-1" }]); + expect(thread?.checkpoints.map((checkpoint) => checkpoint.checkpointTurnCount)).toEqual([1]); + expect(thread?.latestTurn?.turnId).toBe("turn-1"); + }), + ); + + effectIt.effect("does not fallback-retain messages tied to removed turn IDs", () => + Effect.gen(function* () { + const createdAt = "2026-02-26T12:00:00.000Z"; + const model = createEmptyReadModel(createdAt); + + const afterCreate = yield* projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-revert", + occurredAt: createdAt, + commandId: "cmd-create-revert", + payload: { + threadId: "thread-revert", + projectId: "project-1", + title: "demo", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }), + ); + + const events: ReadonlyArray = [ + makeEvent({ + sequence: 2, + type: "thread.turn-diff-completed", + aggregateKind: "thread", + aggregateId: "thread-revert", + occurredAt: "2026-02-26T12:00:01.000Z", + commandId: "cmd-turn-1", + payload: { + threadId: "thread-revert", + turnId: "turn-1", + checkpointTurnCount: 1, + checkpointRef: "refs/t3/checkpoints/thread-revert/turn/1", + status: "ready", + files: [], + assistantMessageId: "assistant-keep", + completedAt: "2026-02-26T12:00:01.000Z", + }, + }), + makeEvent({ + sequence: 3, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-revert", + occurredAt: "2026-02-26T12:00:01.100Z", + commandId: "cmd-assistant-keep", + payload: { + threadId: "thread-revert", + messageId: "assistant-keep", + role: "assistant", + text: "kept", + turnId: "turn-1", + streaming: false, + createdAt: "2026-02-26T12:00:01.100Z", + updatedAt: "2026-02-26T12:00:01.100Z", + }, + }), + makeEvent({ + sequence: 4, + type: "thread.turn-diff-completed", + aggregateKind: "thread", + aggregateId: "thread-revert", + occurredAt: "2026-02-26T12:00:02.000Z", + commandId: "cmd-turn-2", + payload: { + threadId: "thread-revert", + turnId: "turn-2", + checkpointTurnCount: 2, + checkpointRef: "refs/t3/checkpoints/thread-revert/turn/2", + status: "ready", + files: [], + assistantMessageId: "assistant-remove", + completedAt: "2026-02-26T12:00:02.000Z", + }, + }), + makeEvent({ + sequence: 5, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-revert", + occurredAt: "2026-02-26T12:00:02.050Z", + commandId: "cmd-user-remove", + payload: { + threadId: "thread-revert", + messageId: "user-remove", + role: "user", + text: "removed", + turnId: "turn-2", + streaming: false, + createdAt: "2026-02-26T12:00:02.050Z", + updatedAt: "2026-02-26T12:00:02.050Z", + }, + }), + makeEvent({ + sequence: 6, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-revert", + occurredAt: "2026-02-26T12:00:02.100Z", + commandId: "cmd-assistant-remove", + payload: { + threadId: "thread-revert", + messageId: "assistant-remove", + role: "assistant", + text: "removed", + turnId: "turn-2", + streaming: false, + createdAt: "2026-02-26T12:00:02.100Z", + updatedAt: "2026-02-26T12:00:02.100Z", + }, + }), + makeEvent({ + sequence: 7, + type: "thread.reverted", + aggregateKind: "thread", + aggregateId: "thread-revert", + occurredAt: "2026-02-26T12:00:03.000Z", + commandId: "cmd-revert", + payload: { + threadId: "thread-revert", + turnCount: 1, + }, + }), + ]; + + let afterRevert = afterCreate; + for (const event of events) { + afterRevert = yield* projectEvent(afterRevert, event); + } + + const thread = afterRevert.threads[0]; + expect( + thread?.messages.map((message) => ({ + id: message.id, + role: message.role, + turnId: message.turnId, + })), + ).toEqual([{ id: "assistant-keep", role: "assistant", turnId: "turn-1" }]); + }), + ); + + effectIt.effect("caps message and checkpoint retention for long-lived threads", () => + Effect.gen(function* () { + const createdAt = "2026-03-01T10:00:00.000Z"; + const model = createEmptyReadModel(createdAt); + + const afterCreate = yield* projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-capped", + occurredAt: createdAt, + commandId: "cmd-create-capped", + payload: { + threadId: "thread-capped", + projectId: "project-1", + title: "capped", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }), + ); + + const messageEvents: ReadonlyArray = Array.from( + { length: 2_100 }, + (_, index) => + makeEvent({ + sequence: index + 2, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-capped", + occurredAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, + commandId: `cmd-message-${index}`, + payload: { + threadId: "thread-capped", + messageId: `msg-${index}`, + role: "assistant", + text: `message-${index}`, + turnId: `turn-${index}`, + streaming: false, + createdAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, + updatedAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, + }, + }), + ); + let afterMessages = afterCreate; + for (const event of messageEvents) { + afterMessages = yield* projectEvent(afterMessages, event); + } + + const checkpointEvents: ReadonlyArray = Array.from( + { length: 600 }, + (_, index) => + makeEvent({ + sequence: index + 2_102, + type: "thread.turn-diff-completed", + aggregateKind: "thread", + aggregateId: "thread-capped", + occurredAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, + commandId: `cmd-checkpoint-${index}`, + payload: { + threadId: "thread-capped", + turnId: `turn-${index}`, + checkpointTurnCount: index + 1, + checkpointRef: `refs/t3/checkpoints/thread-capped/turn/${index + 1}`, + status: "ready", + files: [], + assistantMessageId: `msg-${index}`, + completedAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, + }, + }), + ); + let finalState = afterMessages; + for (const event of checkpointEvents) { + finalState = yield* projectEvent(finalState, event); + } + + const thread = finalState.threads[0]; + expect(thread?.messages).toHaveLength(2_000); + expect(thread?.messages[0]?.id).toBe("msg-100"); + expect(thread?.messages.at(-1)?.id).toBe("msg-2099"); + expect(thread?.checkpoints).toHaveLength(500); + expect(thread?.checkpoints[0]?.turnId).toBe("turn-100"); + expect(thread?.checkpoints.at(-1)?.turnId).toBe("turn-599"); + }), + ); }); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index fc6ab8f6fcf..6bcca88b698 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,12 +1,25 @@ -import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; +import type { + MessageId, + OrchestrationEvent, + OrchestrationReadModel, + ThreadId, + TurnId, +} from "@t3tools/contracts"; import { OrchestrationCheckpointSummary, OrchestrationMessage, OrchestrationSession, OrchestrationThread, } from "@t3tools/contracts"; +import * as Arr from "effect/Array"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import { + assistantSegmentBelongsToActiveTurn, + applyAssistantSegmentMessageUpdate, + repointCheckpointsForArchivedAssistantSegment, + repointLatestTurnForArchivedAssistantSegment, +} from "@t3tools/shared/orchestrationMessages"; import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts"; import { @@ -32,6 +45,16 @@ type ThreadPatch = Partial>; const MAX_THREAD_MESSAGES = 2_000; const MAX_THREAD_CHECKPOINTS = 500; +function rebindCheckpointAssistantMessage( + checkpoints: ReadonlyArray, + turnId: TurnId, + messageId: MessageId, +): OrchestrationCheckpointSummary[] { + return Arr.map(checkpoints, (entry) => + entry.turnId === turnId ? { ...entry, assistantMessageId: messageId } : entry, + ); +} + function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { if (status === "error") return "error" as const; if (status === "missing") return "interrupted" as const; @@ -409,33 +432,108 @@ export function projectEvent( "message", ); - const existingMessage = thread.messages.find((entry) => entry.id === message.id); - const messages = existingMessage - ? thread.messages.map((entry) => - entry.id === message.id - ? { - ...entry, - text: message.streaming - ? `${entry.text}${message.text}` - : message.text.length > 0 - ? message.text - : entry.text, - streaming: message.streaming, - updatedAt: message.updatedAt, - turnId: message.turnId, - ...(message.attachments !== undefined - ? { attachments: message.attachments } - : {}), - } - : entry, + const existingMessage = thread.messages.find((entry) => entry.id === payload.messageId); + const turnStillActive = + thread.session?.status === "running" && + assistantSegmentBelongsToActiveTurn({ + activeTurnId: thread.session.activeTurnId, + existingTurnId: existingMessage?.turnId, + incomingTurnId: payload.turnId, + }); + const applied = applyAssistantSegmentMessageUpdate(thread.messages, message, { + activeTurnId: thread.session?.activeTurnId ?? null, + turnStillActive: turnStillActive === true, + }); + if (applied.messages === thread.messages) { + return nextBase; + } + const cappedMessages = applied.messages.slice(-MAX_THREAD_MESSAGES); + const settlesTurn = !payload.streaming && !turnStillActive; + let latestTurn = thread.latestTurn; + const checkpointRepointAdvancesLatestTurn = + applied.checkpointsToRepoint !== undefined && + (latestTurn === null || + latestTurn.turnId === applied.checkpointsToRepoint.archivedTurnId); + const shouldAdvanceLatestTurn = + payload.role === "assistant" && + payload.turnId !== null && + (latestTurn === null || + latestTurn.turnId === payload.turnId || + turnStillActive || + checkpointRepointAdvancesLatestTurn); + const preservedLatestTurn = + latestTurn?.turnId === payload.turnId && + !turnStillActive && + (latestTurn.state === "completed" || + latestTurn.state === "interrupted" || + latestTurn.state === "error") + ? latestTurn + : null; + const nextLatestTurnState = preservedLatestTurn + ? preservedLatestTurn.state + : settlesTurn + ? latestTurn?.turnId === payload.turnId && latestTurn.state === "interrupted" + ? "interrupted" + : latestTurn?.turnId === payload.turnId && latestTurn.state === "error" + ? "error" + : "completed" + : "running"; + const nextLatestTurnCompletedAt = preservedLatestTurn + ? preservedLatestTurn.completedAt + : settlesTurn + ? latestTurn?.turnId === payload.turnId + ? (latestTurn.completedAt ?? payload.updatedAt) + : payload.updatedAt + : latestTurn?.turnId === payload.turnId + ? (latestTurn.completedAt ?? null) + : null; + latestTurn = shouldAdvanceLatestTurn + ? { + turnId: payload.turnId, + state: nextLatestTurnState, + requestedAt: + latestTurn?.turnId === payload.turnId ? latestTurn.requestedAt : payload.createdAt, + startedAt: + latestTurn?.turnId === payload.turnId + ? (latestTurn.startedAt ?? payload.createdAt) + : payload.createdAt, + completedAt: nextLatestTurnCompletedAt, + assistantMessageId: payload.messageId, + } + : latestTurn; + const retainedRepoint = + applied.checkpointsToRepoint !== undefined && + cappedMessages.some( + (message) => message.id === applied.checkpointsToRepoint?.archivedMessageId, + ) + ? applied.checkpointsToRepoint + : undefined; + const checkpointRepoint = retainedRepoint; + if (checkpointRepoint) { + latestTurn = repointLatestTurnForArchivedAssistantSegment(latestTurn, checkpointRepoint); + } + let checkpoints = checkpointRepoint + ? repointCheckpointsForArchivedAssistantSegment( + thread.checkpoints, + checkpointRepoint.providerMessageId, + checkpointRepoint.archivedMessageId, + checkpointRepoint.archivedTurnId, ) - : [...thread.messages, message]; - const cappedMessages = messages.slice(-MAX_THREAD_MESSAGES); + : thread.checkpoints; + if (payload.role === "assistant" && payload.turnId !== null) { + checkpoints = rebindCheckpointAssistantMessage( + checkpoints, + payload.turnId, + payload.messageId, + ); + } return { ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { messages: cappedMessages, + checkpoints, + latestTurn, updatedAt: event.occurredAt, }), }; diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 9795e5a0680..73dc0967622 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -113,6 +113,23 @@ async function waitForFileContent(filePath: string, attempts = 40) { throw new Error(`Timed out waiting for file content at ${filePath}`); } +function waitForJsonLogMatch( + filePath: string, + predicate: (entry: Record) => boolean, + attempts = 40, +) { + return Effect.gen(function* () { + for (let attempt = 0; attempt < attempts; attempt += 1) { + const requests = yield* Effect.promise(() => readJsonLines(filePath)); + if (requests.some(predicate)) { + return requests; + } + yield* Effect.yieldNow; + } + return yield* Effect.promise(() => readJsonLines(filePath)); + }); +} + // Tests mutate `ServerSettingsService` mid-flight (e.g. setting // `providers.cursor.binaryPath` to a mock ACP wrapper). The adapter // captures `cursorSettings` once at construction, so without a resolver @@ -1004,7 +1021,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { assert.equal(turnCompleted.payload.stopReason, "cancelled"); } - const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const requests = yield* waitForJsonLogMatch( + requestLogPath, + (entry) => entry.method === "session/cancel", + ); assert.isTrue(requests.some((entry) => entry.method === "session/cancel")); assert.isTrue( requests.some( diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index c871e3c2fc4..cd3b5901878 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -10,15 +10,18 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ApprovalRequestId, GrokSettings, ProviderDriverKind, - ThreadId, ProviderInstanceId, + ThreadId, + TurnId, type ProviderRuntimeEvent, } from "@t3tools/contracts"; @@ -175,6 +178,585 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("reports a Grok session running only while the prompt is in flight", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-session-ready-after-prompt"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_TOOL_CALLS: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const requestOpened = + yield* Deferred.make>(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "request.opened" + ? Deferred.succeed(requestOpened, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "approval-required", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "check lifecycle", attachments: [] }) + .pipe(Effect.forkChild); + const requestOpenedEvent = yield* Deferred.await(requestOpened); + + const runningSessions = yield* adapter.listSessions(); + const runningSession = runningSessions.find((session) => session.threadId === threadId); + assert.equal(runningSession?.status, "running"); + assert.isDefined(runningSession?.activeTurnId); + + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(requestOpenedEvent.requestId)), + "accept", + ); + yield* Fiber.join(sendTurnFiber); + + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("restores ready without completing an unstarted turn when preparation fails", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-preparation-failure-while-connecting"); + const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + + const error = yield* Effect.flip( + adapter.sendTurn({ + threadId, + input: "prepare invalid attachment", + attachments: [ + { + type: "image", + id: "missing-image", + name: "missing.png", + mimeType: "image/png", + sizeBytes: 1, + }, + ], + }), + ); + for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + const turnCompletedEvent = runtimeEvents.find( + (event): event is Extract => + event.type === "turn.completed", + ); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + assert.isUndefined(turnCompletedEvent); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("completes a Grok turn from xAI prompt completion when the prompt RPC hangs", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-xai-prompt-complete-fallback"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + + const sendTurnResult = yield* adapter.sendTurn({ + threadId, + input: "exercise fallback", + attachments: [], + }); + + yield* Deferred.await(turnCompleted); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + const turnCompletedEvent = runtimeEvents.find( + (event): event is Extract => + event.type === "turn.completed", + ); + const eventTypes = runtimeEvents.map((event) => event.type); + + assert.equal(sendTurnResult.threadId, threadId); + assert.include(eventTypes, "turn.completed"); + assert.equal(turnCompletedEvent?.payload.stopReason, "end_turn"); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("retains turn transcript when sendTurn is interrupted after prompt success", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-send-turn-interrupt-after-prompt"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const contentDelta = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "content.delta" ? Deferred.succeed(contentDelta, undefined) : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "interrupt after prompt", + attachments: [], + }) + .pipe(Effect.forkChild); + + yield* Deferred.await(contentDelta); + for (let yieldAttempt = 0; yieldAttempt < 6; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + yield* Fiber.interrupt(sendTurnFiber); + for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + const snapshot = yield* adapter.readThread(threadId); + assert.equal(snapshot.turns.length, 1); + assert.equal(snapshot.turns[0]?.items.length, 1); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not report a synthetic stop reason when xAI omits one", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-xai-prompt-complete-missing-stop-reason"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1", + T3_ACP_OMIT_XAI_PROMPT_COMPLETE_STOP_REASON: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "exercise missing stop reason", + attachments: [], + }); + + yield* Deferred.await(turnCompleted); + const turnCompletedEvent = runtimeEvents.find( + (event): event is Extract => + event.type === "turn.completed", + ); + + assert.equal(turnCompletedEvent?.payload.state, "completed"); + assert.isNull(turnCompletedEvent?.payload.stopReason); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("lets Stop unblock a fully silent Grok prompt and accept a follow-up turn", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-stop-after-full-silence"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + + yield* Effect.gen(function* () { + yield* Effect.sleep("500 millis"); + yield* adapter.interruptTurn(threadId); + }).pipe(Effect.forkChild({ startImmediately: true })); + + yield* adapter.sendTurn({ + threadId, + input: "hang forever", + attachments: [], + }); + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + const cancelledEvents = runtimeEvents.filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + + assert.lengthOf(cancelledEvents, 1); + assert.equal(cancelledEvents[0]?.payload.state, "cancelled"); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + const followUpEventsBefore = runtimeEvents.length; + yield* adapter.sendTurn({ + threadId, + input: "continue after stop", + attachments: [], + }); + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + const followUpCompletedEvents = runtimeEvents + .slice(followUpEventsBefore) + .filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + assert.lengthOf(followUpCompletedEvents, 1); + assert.equal(followUpCompletedEvents[0]?.payload.state, "completed"); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect("lets Stop cancel during the xAI completion drain window", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-stop-during-completion-drain"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const activeTurnIdRef = yield* Ref.make(undefined); + const trailingChunkTurnId = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "turn.started") { + yield* Ref.set(activeTurnIdRef, event.turnId); + } + if (event.type !== "content.delta" || event.payload.delta !== "mock") { + return; + } + const turnId = event.turnId ?? (yield* Ref.get(activeTurnIdRef)); + if (turnId === undefined) { + return; + } + yield* Deferred.succeed(trailingChunkTurnId, turnId).pipe(Effect.ignore); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "cancel during completion drain", + attachments: [], + }) + .pipe(Effect.forkChild); + + const turnId = yield* Deferred.await(trailingChunkTurnId).pipe(Effect.timeout("2 seconds")); + yield* adapter.interruptTurn(threadId, turnId).pipe(Effect.timeout("2 seconds")); + yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("2 seconds")); + + const turnCompletedEvents = runtimeEvents.filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + + assert.lengthOf(turnCompletedEvents, 1); + assert.equal(turnCompletedEvents[0]?.payload.state, "cancelled"); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("settles the in-flight prompt before emitting completion", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-completion-before-next-turn"); + const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + const completedCountRef = yield* Ref.make(0); + const secondTurnCompleted = yield* Deferred.make(); + + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (event.type !== "turn.completed" || String(event.threadId) !== String(threadId)) { + return Effect.void; + } + + return Ref.modify(completedCountRef, (count) => { + const nextCount = count + 1; + return [nextCount, nextCount] as const; + }).pipe( + Effect.flatMap((count) => { + if (count === 1) { + return adapter + .sendTurn({ + threadId, + input: "second turn after completion", + attachments: [], + }) + .pipe(Effect.forkChild, Effect.asVoid); + } + if (count === 2) { + return Deferred.succeed(secondTurnCompleted, undefined).pipe(Effect.asVoid); + } + return Effect.void; + }), + ); + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "first turn", + attachments: [], + }); + yield* Deferred.await(secondTurnCompleted); + + const completedCount = yield* Ref.get(completedCountRef); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + + assert.equal(completedCount, 2); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("restores a Grok session to ready when the prompt RPC fails", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-prompt-failure-ready"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_FAIL_PROMPT: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + + const error = yield* Effect.flip( + adapter.sendTurn({ + threadId, + input: "fail prompt", + attachments: [], + }), + ); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + const failedTurnCompleted = runtimeEvents.find( + (event) => event.type === "turn.completed" && event.threadId === threadId, + ); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + assert.equal(failedTurnCompleted?.type, "turn.completed"); + if (failedTurnCompleted?.type === "turn.completed") { + assert.equal(failedTurnCompleted.payload.state, "failed"); + assert.isString(failedTurnCompleted.payload.errorMessage); + } + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores replayed session/load updates when resuming a Grok session", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-load-replay-filter"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_LOAD_REPLAY: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + resumeCursor: { schemaVersion: 1, sessionId: "mock-session-1" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "after resume", + attachments: [], + }); + + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + assert.isFalse( + runtimeEvents.some( + (event) => event.type === "item.completed" && event.payload.title === "Replay tool", + ), + ); + assert.isFalse( + runtimeEvents.some( + (event) => + event.type === "content.delta" && event.payload.delta === "replayed assistant text", + ), + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("rejects startSession when provider mismatches", () => Effect.gen(function* () { const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); @@ -331,6 +913,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { assert.deepEqual(resolvedEvent.payload.answers, { "Which scope should Grok use?": "Workspace", }); + assert.equal(String(resolvedEvent.turnId), String(requestedEvent.turnId)); yield* Fiber.join(sendTurnFiber); yield* Fiber.interrupt(eventsFiber); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 40f425cbaa1..35c2396e9a0 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -22,6 +22,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; @@ -41,6 +42,7 @@ import { ProviderAdapterValidationError, } from "../Errors.ts"; import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import { promptResponseHasMissingXAiStopReason } from "../acp/AcpSessionRuntime.ts"; import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; import { makeAcpAssistantItemEvent, @@ -108,6 +110,11 @@ interface GrokSessionContext { turns: Array<{ id: TurnId; items: Array }>; lastPlanFingerprint: string | undefined; activeTurnId: TurnId | undefined; + /** Retains the most recently settled turn so trailing session/update chunks + * emitted after prompt completion still attach to the completed turn. */ + streamingTurnId: TurnId | undefined; + /** Turns already interrupted; late prompt RPCs must not resurrect them. */ + interruptedTurnIds: Set; /** Number of sendTurn prompts currently in flight or being prepared. * >0 means a turn is actively running, so a new sendTurn is a steer that * continues it, and only the last remaining prompt settles the turn. */ @@ -136,10 +143,39 @@ function settlePendingUserInputsAsCancelled( ); } +function appendPromptResultToTurn( + ctx: GrokSessionContext, + turnId: TurnId, + promptParts: ReadonlyArray, + result: EffectAcpSchema.PromptResponse, +): void { + const existingTurnRecord = ctx.turns.find((turn) => turn.id === turnId); + ctx.turns = existingTurnRecord + ? ctx.turns.map((turn) => + turn.id === turnId + ? { ...turn, items: [...turn.items, { prompt: promptParts, result }] } + : turn, + ) + : [...ctx.turns, { id: turnId, items: [{ prompt: promptParts, result }] }]; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +const resolveNotificationTurnId = (ctx: GrokSessionContext): TurnId | undefined => + ctx.streamingTurnId ?? ctx.activeTurnId; + +const resolveCallbackTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId; + +const resolveSessionCallbackTurnId = ( + sessions: ReadonlyMap, + threadId: ThreadId, +): TurnId | undefined => { + const ctx = sessions.get(threadId); + return ctx ? resolveCallbackTurnId(ctx) : undefined; +}; + function parseGrokResume(raw: unknown): { sessionId: string } | undefined { if (!isRecord(raw)) return undefined; if (raw.schemaVersion !== GROK_RESUME_VERSION) return undefined; @@ -170,6 +206,15 @@ function selectAutoApprovedPermissionOption( ); } +function completedStopReasonFromPromptResponse( + response: EffectAcpSchema.PromptResponse | undefined, +): EffectAcpSchema.StopReason | null { + if (response === undefined || promptResponseHasMissingXAiStopReason(response)) { + return null; + } + return response.stopReason; +} + export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapterLiveOptions) { return Effect.gen(function* () { const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("grok"); @@ -240,6 +285,207 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const withThreadLock = (threadId: string, effect: Effect.Effect) => Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + const settlePromptInFlight = ( + threadId: ThreadId, + turnId: TurnId, + expectedAcpSessionId: string, + options?: { + readonly errorMessage?: string; + readonly completedStopReason?: EffectAcpSchema.StopReason | null; + readonly emitTurnCompletion?: boolean; + /** Interrupt/cancel: drop every outstanding prompt slot and settle once. */ + readonly settleAllPrompts?: boolean; + }, + ) => + Effect.gen(function* () { + const liveCtx = sessions.get(threadId); + if (!liveCtx) { + return; + } + if (liveCtx.acpSessionId !== expectedAcpSessionId) { + if (liveCtx.activeTurnId !== turnId && liveCtx.session.activeTurnId !== turnId) { + const sessionWasActive = + liveCtx.session.status === "running" || liveCtx.session.status === "connecting"; + const shouldEmitFailedTurn = options?.errorMessage !== undefined; + const shouldEmitCompletedTurn = options?.completedStopReason !== undefined; + if (options?.settleAllPrompts) { + liveCtx.promptsInFlight = 0; + if (sessionWasActive) { + const updatedAt = yield* nowIso; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + } + if (options?.emitTurnCompletion !== false) { + if (shouldEmitFailedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (shouldEmitCompletedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + } + return; + } + liveCtx.promptsInFlight = options?.settleAllPrompts + ? 0 + : Math.max(0, liveCtx.promptsInFlight - 1); + if (liveCtx.promptsInFlight > 0) { + return; + } + const updatedAt = yield* nowIso; + const canEmitTurnCompletion = + liveCtx.session.status === "running" || liveCtx.session.status === "connecting"; + const shouldEmitFailedTurn = options?.errorMessage !== undefined && canEmitTurnCompletion; + const shouldEmitCompletedTurn = + options?.completedStopReason !== undefined && canEmitTurnCompletion; + const shouldRetainStreamingTurnId = shouldEmitFailedTurn || shouldEmitCompletedTurn; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + if (shouldRetainStreamingTurnId) { + liveCtx.streamingTurnId = turnId; + } else if (liveCtx.streamingTurnId === turnId) { + liveCtx.streamingTurnId = undefined; + } + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + if (options?.emitTurnCompletion === false) { + return; + } + if (shouldEmitFailedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (shouldEmitCompletedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + return; + } + let settleTurnId = turnId; + if (options?.settleAllPrompts) { + liveCtx.promptsInFlight = 0; + if (liveCtx.activeTurnId !== turnId && liveCtx.session.activeTurnId !== turnId) { + const fallbackTurnId = liveCtx.activeTurnId ?? liveCtx.session.activeTurnId; + if (!fallbackTurnId) { + if (liveCtx.session.status === "running" || liveCtx.session.status === "connecting") { + const updatedAt = yield* nowIso; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + return; + } + settleTurnId = fallbackTurnId; + } + } else { + const remainingPrompts = Math.max(0, liveCtx.promptsInFlight - 1); + if ( + remainingPrompts > 0 || + liveCtx.activeTurnId !== settleTurnId || + liveCtx.session.activeTurnId !== settleTurnId + ) { + liveCtx.promptsInFlight = remainingPrompts; + return; + } + liveCtx.promptsInFlight = remainingPrompts; + } + const updatedAt = yield* nowIso; + const canEmitTurnCompletion = + liveCtx.session.status === "running" || liveCtx.session.status === "connecting"; + const shouldEmitFailedTurn = options?.errorMessage !== undefined && canEmitTurnCompletion; + const shouldEmitCompletedTurn = + options?.completedStopReason !== undefined && canEmitTurnCompletion; + const shouldRetainStreamingTurnId = shouldEmitFailedTurn || shouldEmitCompletedTurn; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + if (shouldRetainStreamingTurnId) { + liveCtx.streamingTurnId = settleTurnId; + } else if (liveCtx.streamingTurnId === settleTurnId) { + liveCtx.streamingTurnId = undefined; + } + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + if (options?.emitTurnCompletion === false) { + return; + } + if (shouldEmitFailedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: settleTurnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (shouldEmitCompletedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: settleTurnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + }); + const logNative = (threadId: ThreadId, method: string, payload: unknown) => Effect.gen(function* () { if (!nativeEventLogger) return; @@ -282,7 +528,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte method: string, ) => Effect.gen(function* () { - const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + const fingerprint = `${resolveNotificationTurnId(ctx) ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; if (ctx.lastPlanFingerprint === fingerprint) { return; } @@ -292,7 +538,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte stamp: yield* makeEventStamp(), provider: PROVIDER, threadId: ctx.threadId, - turnId: ctx.activeTurnId, + turnId: resolveNotificationTurnId(ctx), payload, source: "acp.jsonrpc", method, @@ -424,13 +670,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const requestId = ApprovalRequestId.make(yield* randomUUIDv4); const runtimeRequestId = RuntimeRequestId.make(requestId); const resolution = yield* Deferred.make(); + const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); pendingUserInputs.set(requestId, { resolution }); yield* offerRuntimeEvent({ type: "user-input.requested", ...(yield* makeEventStamp()), provider: PROVIDER, threadId: input.threadId, - turnId: sessions.get(input.threadId)?.activeTurnId, + turnId, requestId: runtimeRequestId, payload: { questions: extractXAiAskUserQuestions(params) }, raw: { @@ -447,7 +694,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ...(yield* makeEventStamp()), provider: PROVIDER, threadId: input.threadId, - turnId: sessions.get(input.threadId)?.activeTurnId, + turnId, requestId: runtimeRequestId, payload: { answers: resolvedAnswers }, raw: { @@ -486,13 +733,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const requestId = ApprovalRequestId.make(yield* randomUUIDv4); const runtimeRequestId = RuntimeRequestId.make(requestId); const decision = yield* Deferred.make(); + const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); pendingApprovals.set(requestId, { decision }); yield* offerRuntimeEvent( makeAcpRequestOpenedEvent({ stamp: yield* makeEventStamp(), provider: PROVIDER, threadId: input.threadId, - turnId: sessions.get(input.threadId)?.activeTurnId, + turnId, requestId: runtimeRequestId, permissionRequest, detail: @@ -512,7 +760,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte stamp: yield* makeEventStamp(), provider: PROVIDER, threadId: input.threadId, - turnId: sessions.get(input.threadId)?.activeTurnId, + turnId, requestId: runtimeRequestId, permissionRequest, decision: resolved, @@ -578,6 +826,8 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte turns: [], lastPlanFingerprint: undefined, activeTurnId: undefined, + streamingTurnId: undefined, + interruptedTurnIds: new Set(), promptsInFlight: 0, currentModelId: boundModelId, stopped: false, @@ -593,7 +843,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte stamp: yield* makeEventStamp(), provider: PROVIDER, threadId: ctx.threadId, - turnId: ctx.activeTurnId, + turnId: resolveNotificationTurnId(ctx), itemId: event.itemId, lifecycle: "item.started", }), @@ -605,7 +855,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte stamp: yield* makeEventStamp(), provider: PROVIDER, threadId: ctx.threadId, - turnId: ctx.activeTurnId, + turnId: resolveNotificationTurnId(ctx), itemId: event.itemId, lifecycle: "item.completed", }), @@ -622,7 +872,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte stamp: yield* makeEventStamp(), provider: PROVIDER, threadId: ctx.threadId, - turnId: ctx.activeTurnId, + turnId: resolveNotificationTurnId(ctx), toolCall: event.toolCall, rawPayload: event.rawPayload, }), @@ -635,7 +885,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte stamp: yield* makeEventStamp(), provider: PROVIDER, threadId: ctx.threadId, - turnId: ctx.activeTurnId, + turnId: resolveNotificationTurnId(ctx), ...(event.itemId ? { itemId: event.itemId } : {}), text: event.text, rawPayload: event.rawPayload, @@ -697,6 +947,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte // resolving from here on does not settle the turn; decremented on // preparation failure here, and after the prompt below otherwise. ctx.promptsInFlight += 1; + // Bind the turn id before cooperative yields so interruptTurn can + // settle this prompt even if stop arrives during preparation. + ctx.activeTurnId = turnId; + ctx.session = { + ...ctx.session, + status: steeringTurnId === undefined ? "connecting" : "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; return yield* Effect.gen(function* () { const turnModelSelection = @@ -765,12 +1024,28 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const displayModel = currentModelId ? resolveGrokAcpBaseModelId(currentModelId) : undefined; - ctx.activeTurnId = turnId; + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + if (ctx.interruptedTurnIds.has(turnId)) { + yield* settlePromptInFlight(input.threadId, turnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + emitTurnCompletion: false, + settleAllPrompts: true, + }); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Grok prompt was interrupted during preparation.", + }); + } if (steeringTurnId === undefined) { ctx.lastPlanFingerprint = undefined; + ctx.streamingTurnId = undefined; } ctx.session = { ...ctx.session, + status: "running", activeTurnId: turnId, updatedAt: yield* nowIso, ...(displayModel ? { model: displayModel } : {}), @@ -796,13 +1071,27 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }; }).pipe( Effect.tapCause(() => - Effect.sync(() => { - ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); + Effect.gen(function* () { + const liveCtx = sessions.get(input.threadId); + if (!liveCtx) { + return; + } + yield* settlePromptInFlight(input.threadId, turnId, liveCtx.acpSessionId, { + errorMessage: "Grok prompt preparation failed.", + emitTurnCompletion: false, + }); }), ), ); }), ); + const promptSettled = yield* Ref.make(false); + const promptRpcSucceeded = yield* Ref.make(false); + const promptResultRef = yield* Ref.make( + undefined, + ); + + const promptFailureMessageRef = yield* Ref.make(undefined); return yield* Effect.gen(function* () { const result = yield* prepared.acp @@ -810,48 +1099,109 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte prompt: prepared.promptParts, }) .pipe( + Effect.tap((promptResult) => + Effect.all([ + Ref.set(promptRpcSucceeded, true), + Ref.set(promptResultRef, promptResult), + ]), + ), + Effect.tapError((error) => + Ref.set( + promptFailureMessageRef, + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message, + ), + ), Effect.mapError((error) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), ), ); + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + return yield* withThreadLock( input.threadId, Effect.gen(function* () { const ctx = yield* requireSession(input.threadId); if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: "Grok session changed before the turn completed.", + settleAllPrompts: true, + }, + ); + yield* Ref.set(promptSettled, true); return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "session/prompt", detail: "Grok session changed before the turn completed.", }); } + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } - const existingTurnRecord = ctx.turns.find((turn) => turn.id === prepared.turnId); - ctx.turns = existingTurnRecord - ? ctx.turns.map((turn) => - turn.id === prepared.turnId - ? { - ...turn, - items: [...turn.items, { prompt: prepared.promptParts, result }], - } - : turn, - ) - : [ - ...ctx.turns, - { id: prepared.turnId, items: [{ prompt: prepared.promptParts, result }] }, - ]; + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result); ctx.session = { ...ctx.session, + status: "running", activeTurnId: prepared.turnId, updatedAt: yield* nowIso, ...(prepared.displayModel ? { model: prepared.displayModel } : {}), }; + const remainingPrompts = Math.max(0, ctx.promptsInFlight - 1); + ctx.promptsInFlight = remainingPrompts; - // Only the last remaining prompt settles the turn — a steer- - // superseded prompt resolving (usually cancelled) while another - // is in flight or pending must leave the merged turn running. - if (ctx.promptsInFlight === 1) { + // Only the last remaining prompt settles the turn. A steer- + // superseded prompt resolving while another is in flight or + // pending must leave the merged turn running. + if ( + remainingPrompts === 0 && + ctx.activeTurnId === prepared.turnId && + ctx.session.activeTurnId === prepared.turnId + ) { + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + const completedAt = yield* nowIso; + const { activeTurnId: _completedTurnId, ...readySession } = ctx.session; + ctx.streamingTurnId = prepared.turnId; + ctx.activeTurnId = undefined; + ctx.session = { + ...readySession, + status: "ready", + updatedAt: completedAt, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const completedStopReason = completedStopReasonFromPromptResponse(result); yield* offerRuntimeEvent({ type: "turn.completed", ...(yield* makeEventStamp()), @@ -860,9 +1210,13 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte turnId: prepared.turnId, payload: { state: result.stopReason === "cancelled" ? "cancelled" : "completed", - stopReason: result.stopReason ?? null, + stopReason: completedStopReason, }, }); + ctx.interruptedTurnIds.delete(prepared.turnId); + yield* Ref.set(promptSettled, true); + } else if (remainingPrompts > 0) { + yield* Ref.set(promptSettled, true); } return { @@ -874,29 +1228,116 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ); }).pipe( Effect.ensuring( - Effect.sync(() => { - const liveCtx = sessions.get(input.threadId); - if (liveCtx) { - liveCtx.promptsInFlight = Math.max(0, liveCtx.promptsInFlight - 1); + Effect.gen(function* () { + if (yield* Ref.get(promptSettled)) { + return; } - }), + + if (yield* Ref.get(promptRpcSucceeded)) { + const promptResult = yield* Ref.get(promptResultRef); + if (promptResult === undefined) { + return; + } + yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: "Grok session changed before the turn completed.", + settleAllPrompts: true, + }, + ); + return; + } + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); + return; + } + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + return; + } + appendPromptResultToTurn( + ctx, + prepared.turnId, + prepared.promptParts, + promptResult, + ); + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + completedStopReason: completedStopReasonFromPromptResponse(promptResult), + }, + ); + }), + ); + return; + } + + const errorMessage = yield* Ref.get(promptFailureMessageRef); + yield* withThreadLock( + input.threadId, + settlePromptInFlight(input.threadId, prepared.turnId, prepared.acpSessionId, { + errorMessage: errorMessage ?? "Grok prompt request failed.", + }), + ); + }).pipe(Effect.catch(() => Effect.void)), ), ); }); - const interruptTurn: GrokAdapterShape["interruptTurn"] = (threadId) => - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); - yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); - yield* Effect.ignore( - ctx.acp.cancel.pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + const interruptTurn: GrokAdapterShape["interruptTurn"] = (threadId, turnId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return; + } + const interruptedTurnId = turnId ?? activeTurnId ?? ctx.session.activeTurnId; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), ), - ), - ); - }); + ); + if (interruptedTurnId) { + ctx.interruptedTurnIds.add(interruptedTurnId); + yield* settlePromptInFlight(threadId, interruptedTurnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + settleAllPrompts: true, + }); + } else if ( + ctx.promptsInFlight > 0 || + ctx.session.status === "running" || + ctx.session.status === "connecting" + ) { + const updatedAt = yield* nowIso; + ctx.promptsInFlight = 0; + ctx.activeTurnId = undefined; + const { activeTurnId: _activeTurnId, ...readySession } = ctx.session; + ctx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + }), + ); const respondToRequest: GrokAdapterShape["respondToRequest"] = ( threadId, diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 5533a04bc83..6c443068a8d 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -7,6 +7,8 @@ import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; import * as Stream from "effect/Stream"; import { describe, expect } from "vite-plus/test"; @@ -113,6 +115,185 @@ describe("AcpSessionRuntime", () => { ), ); + it.effect("resolves a prompt from xAI prompt completion when the prompt RPC hangs", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + + const promptResult = yield* runtime.prompt({ + prompt: [{ type: "text", text: "hi" }], + }); + + const promptId = promptResult._meta?.promptId; + expect(typeof promptId).toBe("string"); + expect(promptResult).toMatchObject({ + stopReason: "end_turn", + _meta: { + sessionId: "mock-session-1", + promptId, + requestId: promptId, + }, + }); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { + T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1", + }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("serializes prompt calls so only one session/prompt is in flight", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + + const firstPromptResult = yield* runtime.prompt({ + prompt: [{ type: "text", text: "first" }], + }); + const secondPromptResult = yield* runtime.prompt({ + prompt: [{ type: "text", text: "second" }], + }); + + const firstPromptId = firstPromptResult._meta?.promptId; + const secondPromptId = secondPromptResult._meta?.promptId; + expect(typeof firstPromptId).toBe("string"); + expect(typeof secondPromptId).toBe("string"); + expect(firstPromptId).not.toBe(secondPromptId); + expect(firstPromptResult).toMatchObject({ + stopReason: "end_turn", + _meta: { + promptId: firstPromptId, + requestId: firstPromptId, + }, + }); + expect(secondPromptResult).toMatchObject({ + stopReason: "end_turn", + _meta: { + promptId: secondPromptId, + requestId: secondPromptId, + }, + }); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { + T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1", + }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("releases a fully silent prompt when session/cancel is requested", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + + const promptFiber = yield* runtime + .prompt({ + prompt: [{ type: "text", text: "hang forever" }], + }) + .pipe(Effect.forkChild({ startImmediately: true })); + + yield* TestClock.adjust("500 millis"); + yield* runtime.cancel; + + const firstPromptResult = yield* Fiber.join(promptFiber); + expect(firstPromptResult).toMatchObject({ stopReason: "cancelled" }); + + const secondPromptResult = yield* runtime.prompt({ + prompt: [{ type: "text", text: "second" }], + }); + expect(secondPromptResult).toMatchObject({ stopReason: "end_turn" }); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("ignores stale xAI prompt completion for an already completed prompt", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + + const firstPromptResult = yield* runtime.prompt({ + prompt: [{ type: "text", text: "first" }], + }); + expect(firstPromptResult).toMatchObject({ + stopReason: "end_turn", + _meta: { + promptId: "mock-stale-xai-prompt-1", + }, + }); + + const secondPromptResult = yield* runtime.prompt({ + prompt: [{ type: "text", text: "second" }], + }); + const secondPromptId = secondPromptResult._meta?.promptId; + expect(typeof secondPromptId).toBe("string"); + expect(secondPromptId).not.toBe("mock-stale-xai-prompt-1"); + expect(secondPromptResult).toMatchObject({ + stopReason: "end_turn", + _meta: { + promptId: secondPromptId, + requestId: secondPromptId, + }, + }); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { + T3_ACP_EMIT_STALE_XAI_PROMPT_COMPLETE_BEFORE_SECOND_HANG: "1", + }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + it.effect("segments assistant text around ACP tool calls", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; @@ -346,6 +527,70 @@ describe("AcpSessionRuntime", () => { ); }); + it.effect("fails session startup when session/load returns an error", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + const error = yield* runtime.start().pipe(Effect.flip); + + expect(error._tag).toBe("AcpRequestError"); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "test", + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { + T3_ACP_FAIL_LOAD_SESSION: "1", + }, + }, + cwd: process.cwd(), + resumeSessionId: "stale-session-id", + clientInfo: { name: "t3-test", version: "0.0.0" }, + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("ignores session/update replay notifications during session/load", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + + yield* runtime.prompt({ + prompt: [{ type: "text", text: "hi" }], + }); + const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4))); + expect(notes.map((note) => note._tag)).toEqual([ + "PlanUpdated", + "AssistantItemStarted", + "ContentDelta", + "AssistantItemCompleted", + ]); + expect(notes.some((note) => note._tag === "ToolCallUpdated")).toBe(false); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "test", + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { + T3_ACP_EMIT_LOAD_REPLAY: "1", + }, + }, + cwd: process.cwd(), + resumeSessionId: "mock-session-1", + clientInfo: { name: "t3-test", version: "0.0.0" }, + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + it.effect("rejects invalid config option values before sending session/set_config_option", () => { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-")); const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index 54b9e5390e0..7682c5f5f9c 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -8,6 +8,8 @@ import { parsePermissionRequest, parseSessionModeState, parseSessionUpdateEvent, + sessionUpdateIsReplay, + syntheticLoadSessionResponseFromInitialize, } from "./AcpRuntimeModel.ts"; describe("AcpRuntimeModel", () => { @@ -59,6 +61,95 @@ describe("AcpRuntimeModel", () => { expect(modelConfigId).toBe("model"); }); + it("detects Grok session replay updates from _meta.isReplay", () => { + expect( + sessionUpdateIsReplay({ + _meta: { isReplay: true }, + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "replayed" }, + }, + } satisfies EffectAcpSchema.SessionNotification), + ).toBe(true); + expect( + sessionUpdateIsReplay({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "live" }, + }, + } satisfies EffectAcpSchema.SessionNotification), + ).toBe(false); + }); + + it("builds a synthetic load response from initialize model state", () => { + const response = syntheticLoadSessionResponseFromInitialize({ + protocolVersion: 1, + _meta: { + modelState: { + currentModelId: "grok-build", + availableModels: [{ modelId: "grok-build", name: "Grok Build" }], + }, + }, + } satisfies EffectAcpSchema.InitializeResponse); + + expect(response.models?.currentModelId).toBe("grok-build"); + expect(response._meta).toMatchObject({ t3SessionLoadReady: "replay_idle" }); + }); + + it("accepts initialize model descriptions with null", () => { + const response = syntheticLoadSessionResponseFromInitialize({ + protocolVersion: 1, + _meta: { + modelState: { + currentModelId: "grok-build", + availableModels: [{ modelId: "grok-build", name: "Grok Build", description: null }], + }, + }, + } satisfies EffectAcpSchema.InitializeResponse); + + expect(response.models?.availableModels[0]?.description).toBeNull(); + }); + + it("ignores malformed initialize model state in synthetic load responses", () => { + const response = syntheticLoadSessionResponseFromInitialize({ + protocolVersion: 1, + _meta: { + modelState: { + currentModelId: "grok-build", + availableModels: [null], + }, + modeState: { + currentModeId: "code", + availableModes: [{ id: "code", name: 12 }], + }, + }, + } as EffectAcpSchema.InitializeResponse); + + expect(response.models).toBeUndefined(); + expect(response.modes).toBeUndefined(); + expect(response._meta).toMatchObject({ t3SessionLoadReady: "replay_idle" }); + }); + + it("builds a synthetic load response with initialize mode state", () => { + const response = syntheticLoadSessionResponseFromInitialize({ + protocolVersion: 1, + _meta: { + modeState: { + currentModeId: "code", + availableModes: [ + { id: "ask", name: "Ask" }, + { id: "code", name: "Code" }, + ], + }, + }, + } satisfies EffectAcpSchema.InitializeResponse); + + expect(response.modes?.currentModeId).toBe("code"); + expect(response.modes?.availableModes).toHaveLength(2); + }); + it("projects typed ACP tool call updates into runtime events", () => { const created = parseSessionUpdateEvent({ sessionId: "session-1", diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index 3587d703dbd..e6bfc127e6e 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -1,3 +1,8 @@ +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; import type * as EffectAcpSchema from "effect-acp/schema"; import { deriveToolActivityPresentation } from "@t3tools/shared/toolActivity"; import type { ToolLifecycleItemType } from "@t3tools/contracts"; @@ -6,6 +11,40 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function isSessionModelState(value: unknown): value is EffectAcpSchema.SessionModelState { + if (!isRecord(value) || typeof value.currentModelId !== "string") { + return false; + } + if (!Array.isArray(value.availableModels)) { + return false; + } + return value.availableModels.every( + (model) => + isRecord(model) && + typeof model.modelId === "string" && + typeof model.name === "string" && + (model.description === undefined || + model.description === null || + typeof model.description === "string"), + ); +} + +function isSessionModeState(value: unknown): value is EffectAcpSchema.SessionModeState { + if (!isRecord(value) || typeof value.currentModeId !== "string") { + return false; + } + if (!Array.isArray(value.availableModes)) { + return false; + } + return value.availableModes.every( + (mode) => + isRecord(mode) && + typeof mode.id === "string" && + typeof mode.name === "string" && + (mode.description === undefined || typeof mode.description === "string"), + ); +} + export interface AcpSessionMode { readonly id: string; readonly name: string; @@ -414,6 +453,58 @@ export function parsePermissionRequest( }; } +export function sessionUpdateIsReplay(params: EffectAcpSchema.SessionNotification): boolean { + const meta = params._meta; + return isRecord(meta) && meta.isReplay === true; +} + +export interface SessionLoadGate { + readonly active: boolean; + readonly lastActivityAtMillis: number | undefined; + readonly idleGap: Duration.Duration; + readonly initializeResult: EffectAcpSchema.InitializeResponse; +} + +export const waitForSessionLoadReplayIdle = (input: { + readonly gateRef: Ref.Ref>; +}): Effect.Effect => + Effect.gen(function* () { + const pollInterval = Duration.millis(25); + while (true) { + const gate = yield* Ref.get(input.gateRef); + if ( + Option.isSome(gate) && + gate.value.active && + gate.value.lastActivityAtMillis !== undefined + ) { + const idleGapMillis = Duration.toMillis(gate.value.idleGap); + const nowMillis = yield* Clock.currentTimeMillis; + if (nowMillis - gate.value.lastActivityAtMillis >= idleGapMillis) { + return syntheticLoadSessionResponseFromInitialize(gate.value.initializeResult); + } + } + yield* Effect.sleep(pollInterval); + } + }); + +export function syntheticLoadSessionResponseFromInitialize( + initializeResult: EffectAcpSchema.InitializeResponse, +): EffectAcpSchema.LoadSessionResponse { + const meta = initializeResult._meta; + const modelState = isRecord(meta) ? meta.modelState : undefined; + const modeState = isRecord(meta) ? meta.modeState : undefined; + const models = isSessionModelState(modelState) ? modelState : undefined; + const modes = isSessionModeState(modeState) ? modeState : undefined; + + return { + ...(models ? { models } : {}), + ...(modes ? { modes } : {}), + _meta: { + t3SessionLoadReady: "replay_idle", + }, + }; +} + export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotification): { readonly modeId?: string; readonly events: ReadonlyArray; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 4fc2c443e11..e5309071fbb 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -1,12 +1,17 @@ import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -23,6 +28,10 @@ import { mergeToolCallState, parseSessionModeState, parseSessionUpdateEvent, + sessionUpdateIsReplay, + syntheticLoadSessionResponseFromInitialize, + waitForSessionLoadReplayIdle, + type SessionLoadGate, type AcpParsedSessionEvent, type AcpSessionModeState, type AcpToolCallState, @@ -32,6 +41,26 @@ function formatConfigOptionValue(value: string | boolean): string { return JSON.stringify(value); } +const XAiPromptCompleteNotification = Schema.Struct({ + sessionId: Schema.String, + promptId: Schema.optional(Schema.String), + stopReason: Schema.optional(Schema.String), + agentResult: Schema.optional(Schema.NullOr(Schema.Unknown)), +}); + +type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type; + +interface PendingXAiPromptCompletion { + readonly sessionId: string; + readonly promptId: string; + readonly deferred: Deferred.Deferred; +} + +const completedXAiPromptIdLimit = 128; +const defaultSessionLoadTimeout = Duration.seconds(90); +const defaultSessionLoadReplayIdleGap = Duration.seconds(2); +const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; + export interface AcpSpawnInput { readonly command: string; readonly args: ReadonlyArray; @@ -43,6 +72,8 @@ export interface AcpSessionRuntimeOptions { readonly spawn: AcpSpawnInput; readonly cwd: string; readonly resumeSessionId?: string; + readonly sessionLoadTimeout?: Duration.Input; + readonly sessionLoadReplayIdleGap?: Duration.Input; readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; readonly clientInfo: { readonly name: string; @@ -260,6 +291,20 @@ export const make = ( const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); + const promptSerializationSemaphore = yield* Semaphore.make(1); + const activePromptFiberRef = yield* Ref.make< + Option.Option> + >(Option.none()); + const pendingXAiPromptCompletionsRef = yield* Ref.make< + ReadonlyArray + >([]); + const completedXAiPromptIdsRef = yield* Ref.make>([]); + const sessionLoadGateRef = yield* Ref.make>(Option.none()); + let nextXAiPromptFallbackId = 0; + const allocateXAiPromptFallbackId = Effect.sync(() => { + nextXAiPromptFallbackId += 1; + return `t3-xai-prompt-${nextXAiPromptFallbackId}`; + }); const logRequest = (event: AcpSessionRequestLogEvent) => options.requestLogger ? options.requestLogger(event) : Effect.void; @@ -331,14 +376,41 @@ export const make = ( const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); yield* acp.handleSessionUpdate((notification) => - handleSessionUpdate({ - queue: eventQueue, - modeStateRef, - toolCallsRef, - assistantSegmentRef, - params: notification, + Effect.gen(function* () { + const gate = yield* Ref.get(sessionLoadGateRef); + if (Option.isSome(gate) && gate.value.active) { + const lastActivityAtMillis = yield* Clock.currentTimeMillis; + yield* Ref.set( + sessionLoadGateRef, + Option.some({ + ...gate.value, + lastActivityAtMillis, + }), + ); + return; + } + if (sessionUpdateIsReplay(notification)) { + return; + } + yield* handleSessionUpdate({ + queue: eventQueue, + modeStateRef, + toolCallsRef, + assistantSegmentRef, + params: notification, + }); }), ); + yield* acp.handleExtNotification( + "_x.ai/session/prompt_complete", + XAiPromptCompleteNotification, + (notification) => + resolveXAiPromptCompletionFallback({ + pendingRef: pendingXAiPromptCompletionsRef, + completedPromptIdsRef: completedXAiPromptIdsRef, + notification, + }).pipe(Effect.catch(() => Effect.void)), + ); const initializeClientCapabilities = { fs: { @@ -499,27 +571,74 @@ export const make = ( cwd: options.cwd, mcpServers: options.mcpServers ?? [], } satisfies EffectAcpSchema.LoadSessionRequest; - const resumed = yield* runLoggedRequest( - "session/load", - loadPayload, - acp.agent.loadSession(loadPayload), - ).pipe(Effect.exit); - if (Exit.isSuccess(resumed)) { - sessionId = options.resumeSessionId; - sessionSetupResult = resumed.value; - } else { - const createPayload = { - cwd: options.cwd, - mcpServers: options.mcpServers ?? [], - } satisfies EffectAcpSchema.NewSessionRequest; - const created = yield* runLoggedRequest( - "session/new", - createPayload, - acp.agent.createSession(createPayload), + const sessionLoadTimeout = Duration.fromInputUnsafe( + options.sessionLoadTimeout ?? defaultSessionLoadTimeout, + ); + const sessionLoadReplayIdleGap = Duration.fromInputUnsafe( + options.sessionLoadReplayIdleGap ?? defaultSessionLoadReplayIdleGap, + ); + + yield* Ref.set( + sessionLoadGateRef, + Option.some({ + active: true, + lastActivityAtMillis: undefined, + idleGap: sessionLoadReplayIdleGap, + initializeResult, + }), + ); + + sessionId = options.resumeSessionId; + sessionSetupResult = yield* Effect.gen(function* () { + yield* logRequest({ + method: "session/load", + payload: loadPayload, + status: "started", + }); + + const idleFiber = yield* waitForSessionLoadReplayIdle({ + gateRef: sessionLoadGateRef, + }).pipe(Effect.forkIn(runtimeScope)); + const loaded = yield* Effect.raceFirst( + acp.agent.loadSession(loadPayload), + Fiber.join(idleFiber), + ).pipe( + Effect.ensuring(Fiber.interrupt(idleFiber).pipe(Effect.ignore)), + Effect.timeoutOption(sessionLoadTimeout), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail( + new EffectAcpErrors.AcpTransportError({ + operation: "call-rpc", + method: "session/load", + detail: "session/load timed out waiting for RPC response or replay idle gap", + cause: undefined, + }), + ), + onSome: Effect.succeed, + }), + ), + Effect.tap((result) => + logRequest({ + method: "session/load", + payload: loadPayload, + status: "succeeded", + result, + }), + ), + Effect.onError((cause) => + logRequest({ + method: "session/load", + payload: loadPayload, + status: "failed", + cause, + }), + ), ); - sessionId = created.sessionId; - sessionSetupResult = created; - } + + return loaded; + }).pipe(Effect.ensuring(Ref.set(sessionLoadGateRef, Option.none()))); } else { const createPayload = { cwd: options.cwd, @@ -599,23 +718,66 @@ export const make = ( getModeState: Ref.get(modeStateRef), getConfigOptions: Ref.get(configOptionsRef), prompt: (payload) => - getStartedState.pipe( - Effect.flatMap((started) => { + promptSerializationSemaphore.withPermit( + Effect.gen(function* () { + const started = yield* getStartedState; + const promptId = yield* allocateXAiPromptFallbackId; + yield* closeActiveAssistantSegment({ + queue: eventQueue, + assistantSegmentRef, + }); + const fallback = yield* registerXAiPromptCompletionFallback( + pendingXAiPromptCompletionsRef, + started.sessionId, + promptId, + ); const requestPayload = { sessionId: started.sessionId, ...payload, + _meta: { + ...payload._meta, + promptId: fallback.promptId, + requestId: fallback.promptId, + }, } satisfies EffectAcpSchema.PromptRequest; - return closeActiveAssistantSegment({ - queue: eventQueue, - assistantSegmentRef, - }).pipe( - Effect.andThen( - runLoggedRequest( - "session/prompt", - requestPayload, - acp.agent.prompt(requestPayload), + const cancelledResponse = promptResponseFromXAi({ + sessionId: started.sessionId, + promptId: fallback.promptId, + stopReason: "cancelled", + agentResult: null, + }); + // Grok can emit its private prompt-complete notification even when the + // standard session/prompt RPC never settles. Race the RPC with that + // notification so callers still emit turn completion and clear UI state. + const promptRpcFiber = yield* runLoggedRequest( + "session/prompt", + requestPayload, + acp.agent.prompt(requestPayload), + ).pipe(Effect.forkIn(runtimeScope)); + yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber)); + return yield* Effect.raceFirst( + Fiber.join(promptRpcFiber).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.succeed(cancelledResponse) + : Effect.failCause(cause), ), ), + Deferred.await(fallback.deferred), + ).pipe( + Effect.tap((response) => + rememberCompletedXAiPromptId(completedXAiPromptIdsRef, response, fallback.promptId), + ), + Effect.ensuring( + Effect.gen(function* () { + yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore); + yield* unregisterXAiPromptCompletionFallback( + pendingXAiPromptCompletionsRef, + fallback.deferred, + ); + yield* Ref.set(activePromptFiberRef, Option.none()); + }), + ), Effect.tap(() => closeActiveAssistantSegment({ queue: eventQueue, @@ -626,7 +788,18 @@ export const make = ( }), ), cancel: getStartedState.pipe( - Effect.flatMap((started) => acp.agent.cancel({ sessionId: started.sessionId })), + Effect.flatMap((started) => + Effect.gen(function* () { + const activePromptFiber = yield* Ref.get(activePromptFiberRef); + if (Option.isSome(activePromptFiber)) { + yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore); + } + yield* abortPendingPromptCompletions(pendingXAiPromptCompletionsRef, started.sessionId); + yield* acp.agent + .cancel({ sessionId: started.sessionId }) + .pipe(Effect.ignore, Effect.forkIn(runtimeScope)); + }), + ), ), setMode: (modeId) => Ref.get(modeStateRef).pipe( @@ -855,3 +1028,174 @@ const closeActiveAssistantSegment = ({ } satisfies AcpAssistantSegmentState, ] as const; }).pipe(Effect.flatMap((event) => (event ? Queue.offer(queue, event) : Effect.void))); + +const registerXAiPromptCompletionFallback = ( + pendingRef: Ref.Ref>, + sessionId: string, + promptId: string, +) => + Deferred.make().pipe( + Effect.tap((deferred) => + Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]), + ), + Effect.map((deferred) => ({ deferred, promptId })), + ); + +const unregisterXAiPromptCompletionFallback = ( + pendingRef: Ref.Ref>, + deferred: Deferred.Deferred, +) => Ref.update(pendingRef, (pending) => pending.filter((entry) => entry.deferred !== deferred)); + +const abortPendingPromptCompletions = ( + pendingRef: Ref.Ref>, + sessionId: string, +) => + Ref.modify(pendingRef, (pending) => { + const [toAbort, remaining] = pending.reduce< + [ReadonlyArray, ReadonlyArray] + >( + ([aborting, kept], entry) => + entry.sessionId === sessionId ? [[...aborting, entry], kept] : [aborting, [...kept, entry]], + [[], []], + ); + if (toAbort.length === 0) { + return [Effect.void, pending] as const; + } + return [ + Effect.forEach( + toAbort, + (entry) => + Deferred.succeed( + entry.deferred, + promptResponseFromXAi({ + sessionId: entry.sessionId, + promptId: entry.promptId, + stopReason: "cancelled", + agentResult: null, + }), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.asVoid), + remaining, + ] as const; + }).pipe(Effect.flatten); + +const resolveXAiPromptCompletionFallback = ({ + pendingRef, + completedPromptIdsRef, + notification, +}: { + readonly pendingRef: Ref.Ref>; + readonly completedPromptIdsRef: Ref.Ref>; + readonly notification: XAiPromptCompleteNotification; +}) => + Ref.get(completedPromptIdsRef).pipe( + Effect.flatMap((completedPromptIds) => { + if ( + notification.promptId !== undefined && + completedPromptIds.includes(notification.promptId) + ) { + return Effect.void; + } + return Ref.modify(pendingRef, (pending) => { + const index = + notification.promptId !== undefined + ? pending.findIndex( + (entry) => + entry.sessionId === notification.sessionId && + entry.promptId === notification.promptId, + ) + : (() => { + const sessionPendingIndexes = pending.flatMap((entry, entryIndex) => + entry.sessionId === notification.sessionId ? [entryIndex] : [], + ); + if (sessionPendingIndexes.length === 0) { + return -1; + } + // xAI may omit promptId while multiple steered prompts are in flight. + // Resolve the oldest pending fallback FIFO for this session. + return sessionPendingIndexes[0] ?? -1; + })(); + if (index < 0) { + return [Effect.void, pending] as const; + } + const entry = pending[index]; + if (!entry) { + return [Effect.void, pending] as const; + } + return [ + Deferred.succeed(entry.deferred, promptResponseFromXAi(notification)).pipe(Effect.asVoid), + [...pending.slice(0, index), ...pending.slice(index + 1)], + ] as const; + }).pipe(Effect.flatten); + }), + ); + +const rememberCompletedXAiPromptId = ( + completedPromptIdsRef: Ref.Ref>, + response: EffectAcpSchema.PromptResponse, + fallbackPromptId: string, +) => { + const promptId = promptIdFromResponse(response) ?? fallbackPromptId; + if (promptId.length === 0) { + return Effect.void; + } + return Ref.update(completedPromptIdsRef, (completedPromptIds) => { + if (completedPromptIds.includes(promptId)) { + return completedPromptIds; + } + return [...completedPromptIds, promptId].slice(-completedXAiPromptIdLimit); + }); +}; + +function promptIdFromResponse(response: EffectAcpSchema.PromptResponse): string | undefined { + const meta = response._meta; + if (meta === null || typeof meta !== "object") { + return undefined; + } + const promptId = meta.promptId ?? meta.requestId; + return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined; +} + +export function promptResponseHasMissingXAiStopReason( + response: EffectAcpSchema.PromptResponse, +): boolean { + const meta = response._meta; + return meta !== null && typeof meta === "object" && meta[xAiStopReasonMissingMetaKey] === true; +} + +function promptResponseFromXAi( + notification: XAiPromptCompleteNotification, +): EffectAcpSchema.PromptResponse { + const stopReason = normalizeXAiStopReason(notification.stopReason); + const meta: Record = { + sessionId: notification.sessionId, + }; + if (notification.stopReason === undefined) { + meta[xAiStopReasonMissingMetaKey] = true; + } + if (notification.promptId !== undefined) { + meta.promptId = notification.promptId; + meta.requestId = notification.promptId; + } + if (notification.agentResult !== undefined) { + meta.agentResult = notification.agentResult; + } + return { + stopReason, + _meta: meta, + }; +} + +function normalizeXAiStopReason(value: string | undefined): EffectAcpSchema.StopReason { + switch (value) { + case "cancelled": + case "end_turn": + case "max_tokens": + case "max_turn_requests": + case "refusal": + return value; + default: + return "end_turn"; + } +} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b04e98a8a60..bf94a799e94 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -39,6 +39,7 @@ import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; +import { useRafThrottledValue } from "../hooks/useRafThrottledValue"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; @@ -1238,6 +1239,7 @@ function ChatMarkdown({ className, lineBreaks = false, }: ChatMarkdownProps) { + const displayText = useRafThrottledValue(text, isStreaming); const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, @@ -1258,7 +1260,7 @@ function ChatMarkdown({ string, NonNullable> >(); - for (const href of extractMarkdownLinkHrefs(text)) { + for (const href of extractMarkdownLinkHrefs(displayText)) { const normalizedHref = normalizeMarkdownLinkHrefKey(href); if (metaByHref.has(normalizedHref)) continue; const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd); @@ -1267,7 +1269,7 @@ function ChatMarkdown({ } } return metaByHref; - }, [cwd, text]); + }, [cwd, displayText]); const fileLinkParentSuffixByPath = useMemo(() => { const filePaths = [...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath); return buildFileLinkParentSuffixByPath(filePaths); @@ -1334,7 +1336,9 @@ function ChatMarkdown({ li({ node, children, ...props }) { const listItemStart = node?.position?.start.offset; const markerOffset = - typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null; + typeof listItemStart === "number" + ? findTaskListMarkerOffset(displayText, listItemStart) + : null; return (
  • {renderSkillInlineMarkdownChildren(children, skills)} @@ -1530,7 +1534,7 @@ function ChatMarkdown({ openMarkdownFileInPreview, resolvedTheme, skills, - text, + displayText, threadRef, ], ); @@ -1553,7 +1557,7 @@ function ChatMarkdown({ components={markdownComponents} urlTransform={markdownUrlTransform} > - {text} + {displayText} ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9fb8d647b4e..1e978b85d84 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1035,6 +1035,7 @@ function ChatViewContent(props: ChatViewProps) { routeKind === "server" ? routeThreadRef : props.draftId; const serverThread = useThread(routeKind === "server" ? routeThreadRef : null); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); + const setThreadDispatchPending = useUiStateStore((store) => store.setThreadDispatchPending); const activeThreadLastVisitedAt = useUiStateStore((store) => routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, ); @@ -1810,6 +1811,15 @@ function ChatViewContent(props: ChatViewProps) { threadError, }); const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + useEffect(() => { + if (routeKind !== "server") { + return; + } + setThreadDispatchPending(routeThreadKey, isSendBusy); + return () => { + setThreadDispatchPending(routeThreadKey, false); + }; + }, [isSendBusy, routeKind, routeThreadKey, setThreadDispatchPending]); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, activeThread?.session ?? null, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 574e33d4dab..128663c0e5d 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -615,6 +615,22 @@ describe("resolveThreadStatusPill", () => { ).toMatchObject({ label: "Working", pulse: true }); }); + it("shows working while the composer is waiting for server acknowledgement", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + hasPendingLocalDispatch: true, + session: { + ...baseThread.session, + status: "ready", + activeTurnId: null, + }, + }, + }), + ).toMatchObject({ label: "Working", pulse: true }); + }); + it("shows plan ready when a settled plan turn has a proposed plan ready for follow-up", () => { expect( resolveThreadStatusPill({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4e7614ed551..26a070629dc 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -58,6 +58,7 @@ type ThreadStatusInput = Pick< | "session" > & { lastVisitedAt?: string | undefined; + hasPendingLocalDispatch?: boolean | undefined; }; export interface ThreadJumpHintVisibilityController { @@ -380,7 +381,7 @@ export function resolveThreadStatusPill(input: { }; } - if (thread.session?.status === "running") { + if (thread.session?.status === "running" || thread.hasPendingLocalDispatch) { return { label: "Working", colorClass: "text-sky-600 dark:text-sky-300/80", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ce925618caa..5afeb54c6e9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -380,6 +380,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const hasPendingLocalDispatch = useUiStateStore( + (state) => state.threadDispatchPendingById[threadKey] === true, + ); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: thread.environmentId, @@ -445,11 +448,13 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr [discoveredPorts, navigateToThread, openPreview, threadRef], ); const isThreadRunning = - thread.session?.status === "running" && thread.session.activeTurnId != null; + (thread.session?.status === "running" && thread.session.activeTurnId != null) || + hasPendingLocalDispatch; const threadStatus = resolveThreadStatusPill({ thread: { ...thread, lastVisitedAt, + hasPendingLocalDispatch, }, }); const pr = resolveThreadPr(thread.branch, gitStatus.data); @@ -1191,6 +1196,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ), ), ); + const threadDispatchPendingById = useUiStateStore((state) => state.threadDispatchPendingById); const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState(null); @@ -1240,13 +1246,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ]), ); const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => { - const lastVisitedAt = lastVisitedAtByThreadKey.get( - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const lastVisitedAt = lastVisitedAtByThreadKey.get(threadKey); return resolveThreadStatusPill({ thread: { ...thread, ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}), + hasPendingLocalDispatch: threadDispatchPendingById[threadKey] === true, }, }); }; @@ -1264,7 +1270,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec projectStatus, visibleProjectThreads, }; - }, [projectThreads, threadLastVisitedAts, threadSortOrder]); + }, [projectThreads, threadDispatchPendingById, threadLastVisitedAts, threadSortOrder]); const pinnedCollapsedThread = useMemo(() => { const activeThreadKey = activeRouteThreadKey ?? undefined; if (!activeThreadKey || projectExpanded) { @@ -1292,13 +1298,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ]), ); const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => { - const lastVisitedAt = lastVisitedAtByThreadKey.get( - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const lastVisitedAt = lastVisitedAtByThreadKey.get(threadKey); return resolveThreadStatusPill({ thread: { ...thread, ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}), + hasPendingLocalDispatch: threadDispatchPendingById[threadKey] === true, }, }); }; @@ -1336,6 +1342,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec projectExpanded, projectThreads, sidebarThreadPreviewCount, + threadDispatchPendingById, threadLastVisitedAts, visibleProjectThreads, ]); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 3e85920d190..a0e23139623 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -187,8 +187,10 @@ export function ThreadStatusLabel({ */ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) { const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const lastVisitedAt = useUiStateStore( - (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], + const threadKey = scopedThreadKey(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const hasPendingLocalDispatch = useUiStateStore( + (state) => state.threadDispatchPendingById[threadKey] === true, ); const threadProject = useProject( useMemo( @@ -212,6 +214,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar thread: { ...thread, lastVisitedAt, + hasPendingLocalDispatch, }, }); diff --git a/apps/web/src/hooks/useRafThrottledValue.test.ts b/apps/web/src/hooks/useRafThrottledValue.test.ts new file mode 100644 index 00000000000..40139c39eaa --- /dev/null +++ b/apps/web/src/hooks/useRafThrottledValue.test.ts @@ -0,0 +1,173 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useRafThrottledValue } from "./useRafThrottledValue"; + +type HookHarness = { + readonly value: string; + readonly enabled: boolean; + readonly displayed: string; + readonly scheduleFrame: () => void; +}; + +function createHookHarness(initialValue: string, initialEnabled: boolean): HookHarness { + let value = initialValue; + let enabled = initialEnabled; + let prevEnabled = initialEnabled; + let displayed = initialValue; + let frameId: number | null = null; + + const scheduleFrame = () => { + if (frameId !== null) { + return; + } + frameId = requestAnimationFrame(() => { + frameId = null; + if (enabled) { + displayed = value; + } + }); + }; + + const runEffect = () => { + if (!enabled) { + if (frameId !== null) { + cancelAnimationFrame(frameId); + frameId = null; + } + prevEnabled = false; + displayed = value; + return; + } + + if (!prevEnabled) { + displayed = value; + } + prevEnabled = true; + scheduleFrame(); + }; + + const setValue = (nextValue: string) => { + value = nextValue; + runEffect(); + }; + + const setEnabled = (nextEnabled: boolean) => { + enabled = nextEnabled; + runEffect(); + }; + + return { + get value() { + return value; + }, + get enabled() { + return enabled; + }, + get displayed() { + return enabled ? displayed : value; + }, + scheduleFrame, + setValue, + setEnabled, + } as HookHarness & { + setValue: (nextValue: string) => void; + setEnabled: (nextEnabled: boolean) => void; + }; +} + +describe("useRafThrottledValue", () => { + let rafCallbacks: Array; + + beforeEach(() => { + rafCallbacks = []; + const requestAnimationFrame = (callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }; + const cancelAnimationFrame = () => { + rafCallbacks = []; + }; + vi.stubGlobal("window", { requestAnimationFrame, cancelAnimationFrame }); + vi.stubGlobal("requestAnimationFrame", requestAnimationFrame); + vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const flushRaf = () => { + const callbacks = [...rafCallbacks]; + rafCallbacks = []; + for (const callback of callbacks) { + callback(0); + } + }; + + it("exports a hook function", () => { + expect(typeof useRafThrottledValue).toBe("function"); + }); + + it("mirrors disabled streaming semantics: latest value is shown immediately", () => { + const harness = createHookHarness("first", false) as ReturnType & { + setValue: (nextValue: string) => void; + }; + + harness.setValue("second"); + expect(harness.displayed).toBe("second"); + expect(rafCallbacks).toHaveLength(0); + }); + + it("mirrors enabled streaming semantics: updates land on the next animation frame", () => { + const harness = createHookHarness("hello", true) as ReturnType & { + setValue: (nextValue: string) => void; + }; + + harness.setValue("hello world"); + expect(harness.displayed).toBe("hello"); + expect(rafCallbacks).toHaveLength(1); + + flushRaf(); + expect(harness.displayed).toBe("hello world"); + }); + + it("coalesces rapid value changes into one animation frame", () => { + const harness = createHookHarness("hello", true) as ReturnType & { + setValue: (nextValue: string) => void; + }; + + harness.setValue("hello "); + harness.setValue("hello w"); + harness.setValue("hello world"); + expect(harness.displayed).toBe("hello"); + expect(rafCallbacks).toHaveLength(1); + + flushRaf(); + expect(harness.displayed).toBe("hello world"); + }); + + it("mirrors stream-end semantics: disabling flushes the latest value", () => { + const harness = createHookHarness("partial", true) as ReturnType & { + setValue: (nextValue: string) => void; + setEnabled: (nextEnabled: boolean) => void; + }; + + harness.setValue("partial response"); + harness.setEnabled(false); + expect(harness.displayed).toBe("partial response"); + }); + + it("mirrors stream-start semantics: enabling shows the current value immediately", () => { + const harness = createHookHarness("partial", true) as ReturnType & { + setValue: (nextValue: string) => void; + setEnabled: (nextEnabled: boolean) => void; + }; + + harness.setValue("partial response"); + flushRaf(); + harness.setEnabled(false); + harness.setValue("fresh stream"); + harness.setEnabled(true); + expect(harness.displayed).toBe("fresh stream"); + }); +}); diff --git a/apps/web/src/hooks/useRafThrottledValue.ts b/apps/web/src/hooks/useRafThrottledValue.ts new file mode 100644 index 00000000000..1851a0857f7 --- /dev/null +++ b/apps/web/src/hooks/useRafThrottledValue.ts @@ -0,0 +1,52 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Caps React updates to one per animation frame while `enabled`. + * Use for high-frequency streaming text so markdown parsing stays smooth. + * When disabled, the latest value is returned synchronously with no lag. + */ +export function useRafThrottledValue(value: T, enabled: boolean): T { + const [displayed, setDisplayed] = useState(value); + const latestRef = useRef(value); + const frameRef = useRef(null); + const prevEnabledRef = useRef(enabled); + + latestRef.current = value; + + useEffect(() => { + if (!enabled) { + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + prevEnabledRef.current = false; + setDisplayed(value); + return; + } + + if (!prevEnabledRef.current) { + setDisplayed(value); + } + prevEnabledRef.current = true; + + if (frameRef.current !== null) { + return; + } + + frameRef.current = requestAnimationFrame(() => { + frameRef.current = null; + setDisplayed(latestRef.current); + }); + }, [enabled, value]); + + useEffect(() => { + return () => { + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + }; + }, []); + + return enabled ? displayed : value; +} diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 0f12e672f66..189227ff602 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1493,6 +1493,64 @@ describe("deriveWorkLogEntries", () => { }); describe("deriveTimelineEntries", () => { + it("orders reused Grok assistant segments by createdAt so they stay below the active user message", () => { + const entries = deriveTimelineEntries( + [ + { + id: MessageId.make("assistant-segment-0"), + role: "assistant", + text: "Health-check response.", + createdAt: "2026-06-24T01:12:00.260Z", + turnId: TurnId.make("turn-health"), + updatedAt: "2026-06-24T01:12:00.260Z", + streaming: false, + }, + { + id: MessageId.make("user-health"), + role: "user", + text: "does all seem well?", + createdAt: "2026-06-24T01:10:45.533Z", + turnId: null, + updatedAt: "2026-06-24T01:10:45.533Z", + streaming: false, + }, + ], + [], + [], + ); + + expect(entries.map((entry) => entry.id)).toEqual(["user-health", "assistant-segment-0"]); + }); + + it("keeps a completed assistant response above the next user message when updatedAt bumps without createdAt changes", () => { + const entries = deriveTimelineEntries( + [ + { + id: MessageId.make("assistant-yep"), + role: "assistant", + text: "Yep — that background task dying with exit 143...", + createdAt: "2026-06-24T01:00:00.000Z", + turnId: TurnId.make("turn-n"), + updatedAt: "2026-06-24T01:02:00.000Z", + streaming: false, + }, + { + id: MessageId.make("user-followup"), + role: "user", + text: "i see it now. what's the source of truth for those categories?", + createdAt: "2026-06-24T01:03:00.000Z", + turnId: null, + updatedAt: "2026-06-24T01:03:00.000Z", + streaming: false, + }, + ], + [], + [], + ); + + expect(entries.map((entry) => entry.id)).toEqual(["assistant-yep", "user-followup"]); + }); + it("includes proposed plans alongside messages and work entries in chronological order", () => { const entries = deriveTimelineEntries( [ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5d5051f748e..d926a14a4d4 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1337,6 +1337,16 @@ function compareActivityLifecycleRank(kind: string): number { return 1; } +function timelineEntrySortAt(entry: TimelineEntry): string { + if (entry.kind === "message" && entry.message.role === "assistant") { + // Grok session/load reuses assistant segment ids across turns. Projection + // resets createdAt only when a rebound starts or turn binding changes, so + // sort by createdAt instead of mutable updatedAt streaming bumps. + return entry.message.createdAt; + } + return entry.createdAt; +} + export function deriveTimelineEntries( messages: ReadonlyArray, proposedPlans: ReadonlyArray, @@ -1361,7 +1371,7 @@ export function deriveTimelineEntries( entry, })); return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) => - a.createdAt.localeCompare(b.createdAt), + timelineEntrySortAt(a).localeCompare(timelineEntrySortAt(b)), ); } diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 4a97f0542b4..76d9c9fc0de 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -412,6 +412,9 @@ export function reorderProjects( } interface UiStateStore extends UiState { + /** Ephemeral: composer waiting for server acknowledgement after send. */ + threadDispatchPendingById: Record; + setThreadDispatchPending: (threadKey: string, pending: boolean) => void; markThreadVisited: (threadId: string, visitedAt: string) => void; markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; @@ -426,6 +429,31 @@ interface UiStateStore extends UiState { export const useUiStateStore = create((set) => ({ ...readPersistedState(), + threadDispatchPendingById: {}, + setThreadDispatchPending: (threadKey, pending) => + set((state) => { + if (pending) { + if (state.threadDispatchPendingById[threadKey] === true) { + return state; + } + return { + ...state, + threadDispatchPendingById: { + ...state.threadDispatchPendingById, + [threadKey]: true, + }, + }; + } + if (!(threadKey in state.threadDispatchPendingById)) { + return state; + } + const nextPending = { ...state.threadDispatchPendingById }; + delete nextPending[threadKey]; + return { + ...state, + threadDispatchPendingById: nextPending, + }; + }), markThreadVisited: (threadId, visitedAt) => set((state) => markThreadVisited(state, threadId, visitedAt)), markThreadUnread: (threadId, latestTurnCompletedAt) => diff --git a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts index e6eff1e5c21..237135f7a74 100644 --- a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts +++ b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts @@ -32,7 +32,7 @@ const LEGACY_BASELINE = new Map([ ["apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts", 70], ["apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts", 31], ["apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts", 2], - ["apps/server/src/orchestration/projector.test.ts", 20], + ["apps/server/src/orchestration/projector.test.ts", 21], ["apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts", 4], ["apps/server/src/provider/acp/CursorAcpSupport.test.ts", 1], ["apps/server/src/provider/Layers/ClaudeAdapter.test.ts", 2], diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 94eb1c65370..56bce039018 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -9,7 +9,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import type { OrchestrationThread } from "@t3tools/contracts"; +import type { OrchestrationMessage, OrchestrationThread } from "@t3tools/contracts"; import { applyThreadDetailEvent } from "./threadReducer.ts"; @@ -219,6 +219,308 @@ describe("applyThreadDetailEvent", () => { } }); + it("resumes streaming on the active turn after segment-level completion", () => { + const threadWithRunningSession: OrchestrationThread = { + ...baseThread, + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "grok", + runtimeMode: "full-access", + activeTurnId: TurnId.make("turn-n"), + lastError: null, + updatedAt: "2026-06-24T01:02:00.000Z", + }, + messages: [ + { + id: MessageId.make("assistant-segment-0"), + role: "assistant", + text: "Done", + streaming: false, + turnId: TurnId.make("turn-n"), + createdAt: "2026-06-24T01:02:00.000Z", + updatedAt: "2026-06-24T01:02:00.500Z", + } as OrchestrationMessage, + ], + }; + + const result = applyThreadDetailEvent(threadWithRunningSession, { + ...baseEventFields, + sequence: 8, + occurredAt: "2026-06-24T01:02:01.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.message-sent", + payload: { + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("assistant-segment-0"), + role: "assistant", + text: " — same listing.", + turnId: TurnId.make("turn-n"), + streaming: true, + createdAt: "2026-06-24T01:02:01.000Z", + updatedAt: "2026-06-24T01:02:01.000Z", + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.messages).toHaveLength(1); + expect(result.thread.messages[0]?.text).toBe("Done — same listing."); + expect(result.thread.messages[0]?.streaming).toBe(true); + } + }); + + it("accepts trailing streaming chunks for the same settled turn", () => { + const threadWithCompletedAssistant: OrchestrationThread = { + ...baseThread, + messages: [ + { + id: MessageId.make("assistant-yep"), + role: "assistant", + text: "Yep — that background task dying with exit 143...", + streaming: false, + turnId: TurnId.make("turn-n"), + createdAt: "2026-06-24T01:00:00.000Z", + updatedAt: "2026-06-24T01:02:00.000Z", + } as OrchestrationMessage, + ], + }; + + const result = applyThreadDetailEvent(threadWithCompletedAssistant, { + ...baseEventFields, + sequence: 8, + occurredAt: "2026-06-24T01:05:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.message-sent", + payload: { + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("assistant-yep"), + role: "assistant", + text: " trailing", + turnId: TurnId.make("turn-n"), + streaming: true, + createdAt: "2026-06-24T01:05:00.000Z", + updatedAt: "2026-06-24T01:05:00.000Z", + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.messages[0]?.text).toBe( + "Yep — that background task dying with exit 143... trailing", + ); + expect(result.thread.messages[0]?.streaming).toBe(true); + } + }); + + it("does not regress interrupted latestTurn on late streaming chunks", () => { + const threadWithInterruptedTurn: OrchestrationThread = { + ...baseThread, + latestTurn: { + turnId: TurnId.make("turn-n"), + state: "interrupted", + requestedAt: "2026-06-24T01:00:00.000Z", + startedAt: "2026-06-24T01:00:00.000Z", + completedAt: "2026-06-24T01:02:00.000Z", + assistantMessageId: MessageId.make("assistant-yep"), + }, + messages: [ + { + id: MessageId.make("assistant-yep"), + role: "assistant", + text: "Partial", + streaming: true, + turnId: TurnId.make("turn-n"), + createdAt: "2026-06-24T01:00:00.000Z", + updatedAt: "2026-06-24T01:02:00.000Z", + } as OrchestrationMessage, + ], + }; + + const result = applyThreadDetailEvent(threadWithInterruptedTurn, { + ...baseEventFields, + sequence: 8, + occurredAt: "2026-06-24T01:05:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.message-sent", + payload: { + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("assistant-yep"), + role: "assistant", + text: " trailing", + turnId: TurnId.make("turn-n"), + streaming: true, + createdAt: "2026-06-24T01:05:00.000Z", + updatedAt: "2026-06-24T01:05:00.000Z", + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.latestTurn?.state).toBe("interrupted"); + expect(result.thread.latestTurn?.completedAt).toBe("2026-06-24T01:02:00.000Z"); + } + }); + + it("archives replayed assistant rows when streaming binds a reused segment to a new turn", () => { + const threadWithReplayedSegment: OrchestrationThread = { + ...baseThread, + latestTurn: { + turnId: TurnId.make("turn-replay"), + state: "completed", + requestedAt: "2026-06-24T00:29:27.101Z", + startedAt: "2026-06-24T00:29:27.101Z", + completedAt: "2026-06-24T00:29:27.101Z", + assistantMessageId: MessageId.make("assistant-segment-0"), + }, + messages: [ + { + id: MessageId.make("assistant-segment-0"), + role: "assistant", + text: "Health-check response from replay.", + streaming: false, + turnId: TurnId.make("turn-replay"), + createdAt: "2026-06-24T00:29:27.101Z", + updatedAt: "2026-06-24T00:29:27.101Z", + } as OrchestrationMessage, + ], + }; + + const result = applyThreadDetailEvent(threadWithReplayedSegment, { + ...baseEventFields, + sequence: 7, + occurredAt: "2026-06-24T01:12:00.260Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.message-sent", + payload: { + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("assistant-segment-0"), + role: "assistant", + text: "New ", + turnId: TurnId.make("turn-follow-up"), + streaming: true, + createdAt: "2026-06-24T01:12:00.260Z", + updatedAt: "2026-06-24T01:12:00.260Z", + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.messages).toHaveLength(2); + expect(result.thread.messages[0]?.text).toBe("Health-check response from replay."); + expect(result.thread.messages[1]?.text).toBe("New "); + expect(result.thread.messages[1]?.turnId).toBe("turn-follow-up"); + expect(result.thread.latestTurn?.turnId).toBe("turn-follow-up"); + expect(result.thread.latestTurn?.state).toBe("running"); + expect(result.thread.latestTurn?.assistantMessageId).toBe("assistant-segment-0"); + } + }); + + it("does not inherit a prior rebound turn's terminal latestTurn state", () => { + const threadWithFailedReplay: OrchestrationThread = { + ...baseThread, + latestTurn: { + turnId: TurnId.make("turn-replay"), + state: "error", + requestedAt: "2026-06-24T00:29:27.101Z", + startedAt: "2026-06-24T00:29:27.101Z", + completedAt: "2026-06-24T00:29:27.101Z", + assistantMessageId: MessageId.make("assistant-segment-0"), + }, + messages: [ + { + id: MessageId.make("assistant-segment-0"), + role: "assistant", + text: "Failed replay response.", + streaming: false, + turnId: TurnId.make("turn-replay"), + createdAt: "2026-06-24T00:29:27.101Z", + updatedAt: "2026-06-24T00:29:27.101Z", + } as OrchestrationMessage, + ], + }; + + const result = applyThreadDetailEvent(threadWithFailedReplay, { + ...baseEventFields, + sequence: 7, + occurredAt: "2026-06-24T01:12:00.260Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.message-sent", + payload: { + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("assistant-segment-0"), + role: "assistant", + text: "Recovered response.", + turnId: TurnId.make("turn-follow-up"), + streaming: false, + createdAt: "2026-06-24T01:12:00.260Z", + updatedAt: "2026-06-24T01:12:00.260Z", + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.latestTurn?.turnId).toBe("turn-follow-up"); + expect(result.thread.latestTurn?.state).toBe("completed"); + } + }); + + it("rewrites checkpoint assistant ids after a later assistant message", () => { + const threadWithCheckpoint: OrchestrationThread = { + ...baseThread, + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-04-01T06:59:00.000Z", + startedAt: "2026-04-01T06:59:00.000Z", + completedAt: "2026-04-01T07:00:00.000Z", + assistantMessageId: MessageId.make("msg-first"), + }, + checkpoints: [ + { + turnId: TurnId.make("turn-1"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("ref-1"), + status: "ready", + files: [], + assistantMessageId: MessageId.make("msg-first"), + completedAt: "2026-04-01T07:00:00.000Z", + }, + ], + }; + + const result = applyThreadDetailEvent(threadWithCheckpoint, { + ...baseEventFields, + sequence: 8, + occurredAt: "2026-04-01T07:01:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.message-sent", + payload: { + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("msg-later"), + role: "assistant", + text: "Later commentary.", + turnId: TurnId.make("turn-1"), + streaming: false, + createdAt: "2026-04-01T07:01:00.000Z", + updatedAt: "2026-04-01T07:01:00.000Z", + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.checkpoints[0]?.assistantMessageId).toBe("msg-later"); + expect(result.thread.latestTurn?.assistantMessageId).toBe("msg-later"); + expect(result.thread.latestTurn?.completedAt).toBe("2026-04-01T07:00:00.000Z"); + } + }); + it("appends text for streaming messages", () => { const threadWithMessage: OrchestrationThread = { ...baseThread, @@ -289,6 +591,55 @@ describe("applyThreadDetailEvent", () => { } }); + it("advances latestTurn when a fresh assistant message belongs to the active turn", () => { + const threadWithCompletedTurn: OrchestrationThread = { + ...baseThread, + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "grok", + runtimeMode: "full-access", + activeTurnId: TurnId.make("turn-2"), + lastError: null, + updatedAt: "2026-04-01T08:00:00.000Z", + }, + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-04-01T07:00:00.000Z", + startedAt: "2026-04-01T07:00:00.000Z", + completedAt: "2026-04-01T07:05:00.000Z", + assistantMessageId: MessageId.make("msg-1"), + }, + }; + + const result = applyThreadDetailEvent(threadWithCompletedTurn, { + ...baseEventFields, + sequence: 9, + occurredAt: "2026-04-01T08:01:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.message-sent", + payload: { + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("msg-2"), + role: "assistant", + text: "Working", + turnId: TurnId.make("turn-2"), + streaming: true, + createdAt: "2026-04-01T08:01:00.000Z", + updatedAt: "2026-04-01T08:01:00.000Z", + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.latestTurn?.turnId).toBe("turn-2"); + expect(result.thread.latestTurn?.state).toBe("running"); + expect(result.thread.latestTurn?.assistantMessageId).toBe("msg-2"); + } + }); + it("keeps latestTurn running for interim assistant messages while the session runs the turn", () => { const threadWithRunningSession: OrchestrationThread = { ...baseThread, diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 670540fee70..095f027c8c9 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -12,6 +12,13 @@ import type { OrchestrationThreadActivity, TurnId, } from "@t3tools/contracts"; +import { + assistantSegmentBelongsToActiveTurn, + applyAssistantSegmentMessageUpdate, + type AssistantSegmentThreadMessage, + repointCheckpointsForArchivedAssistantSegment, + repointLatestTurnForArchivedAssistantSegment, +} from "@t3tools/shared/orchestrationMessages"; export type ThreadDetailReducerResult = | { readonly kind: "updated"; readonly thread: OrchestrationThread } @@ -191,76 +198,107 @@ export function applyThreadDetailEvent( updatedAt: event.payload.updatedAt, }; - const existingMessage = thread.messages.find((entry) => entry.id === message.id); - const messages = existingMessage - ? Arr.map(thread.messages, (entry) => - entry.id !== message.id - ? entry - : { - ...entry, - text: message.streaming - ? `${entry.text}${message.text}` - : message.text.length > 0 - ? message.text - : entry.text, - streaming: message.streaming, - ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), - ...(message.streaming ? {} : { updatedAt: message.updatedAt }), - ...(message.attachments !== undefined - ? { attachments: message.attachments } - : {}), - }, - ) - : Arr.append(thread.messages, message); + const existingMessage = thread.messages.find((entry) => entry.id === event.payload.messageId); + const turnStillRunning = + thread.session?.status === "running" && + assistantSegmentBelongsToActiveTurn({ + activeTurnId: thread.session.activeTurnId, + existingTurnId: existingMessage?.turnId, + incomingTurnId: event.payload.turnId, + }); + const applied = applyAssistantSegmentMessageUpdate( + thread.messages as ReadonlyArray, + message, + { + activeTurnId: thread.session?.activeTurnId ?? null, + turnStillActive: turnStillRunning === true, + }, + ); + if (applied.messages === thread.messages) { + return { kind: "unchanged" }; + } + const messages = applied.messages as OrchestrationMessage[]; // Update latestTurn for assistant messages bound to a turn. A completed // assistant message only settles the turn once the session is no longer // running it — providers may emit several assistant messages per turn // (commentary between tool calls), and the turn must stay unsettled // until the provider reports turn end. - const turnStillRunning = - event.payload.turnId !== null && - thread.session?.status === "running" && - thread.session.activeTurnId === event.payload.turnId; const settlesTurn = !event.payload.streaming && !turnStillRunning; - const latestTurn: OrchestrationThread["latestTurn"] = + let latestTurn: OrchestrationThread["latestTurn"] = thread.latestTurn; + const checkpointRepointAdvancesLatestTurn = + applied.checkpointsToRepoint !== undefined && + (latestTurn === null || latestTurn.turnId === applied.checkpointsToRepoint.archivedTurnId); + const shouldAdvanceLatestTurn = event.payload.role === "assistant" && event.payload.turnId !== null && - (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) - ? { - turnId: event.payload.turnId, - state: settlesTurn - ? thread.latestTurn?.state === "interrupted" - ? "interrupted" - : thread.latestTurn?.state === "error" - ? "error" - : "completed" - : "running", - requestedAt: - thread.latestTurn?.turnId === event.payload.turnId - ? thread.latestTurn.requestedAt - : event.payload.createdAt, - startedAt: - thread.latestTurn?.turnId === event.payload.turnId - ? (thread.latestTurn.startedAt ?? event.payload.createdAt) - : event.payload.createdAt, - completedAt: settlesTurn - ? event.payload.updatedAt - : thread.latestTurn?.turnId === event.payload.turnId - ? (thread.latestTurn.completedAt ?? null) - : null, - assistantMessageId: event.payload.messageId, - } - : thread.latestTurn; + (latestTurn === null || + latestTurn.turnId === event.payload.turnId || + turnStillRunning || + checkpointRepointAdvancesLatestTurn); + const preservedLatestTurn = + latestTurn?.turnId === event.payload.turnId && + !turnStillRunning && + (latestTurn.state === "completed" || + latestTurn.state === "interrupted" || + latestTurn.state === "error") + ? latestTurn + : null; + const nextLatestTurnState = preservedLatestTurn + ? preservedLatestTurn.state + : settlesTurn + ? latestTurn?.turnId === event.payload.turnId && latestTurn.state === "interrupted" + ? "interrupted" + : latestTurn?.turnId === event.payload.turnId && latestTurn.state === "error" + ? "error" + : "completed" + : "running"; + const nextLatestTurnCompletedAt = preservedLatestTurn + ? preservedLatestTurn.completedAt + : settlesTurn + ? latestTurn?.turnId === event.payload.turnId + ? (latestTurn.completedAt ?? event.payload.updatedAt) + : event.payload.updatedAt + : latestTurn?.turnId === event.payload.turnId + ? (latestTurn.completedAt ?? null) + : null; + latestTurn = shouldAdvanceLatestTurn + ? { + turnId: event.payload.turnId, + state: nextLatestTurnState, + requestedAt: + latestTurn?.turnId === event.payload.turnId + ? latestTurn.requestedAt + : event.payload.createdAt, + startedAt: + latestTurn?.turnId === event.payload.turnId + ? (latestTurn.startedAt ?? event.payload.createdAt) + : event.payload.createdAt, + completedAt: nextLatestTurnCompletedAt, + assistantMessageId: event.payload.messageId, + } + : latestTurn; + if (applied.checkpointsToRepoint) { + latestTurn = repointLatestTurnForArchivedAssistantSegment( + latestTurn, + applied.checkpointsToRepoint, + ); + } - // Rebind checkpoint assistant message IDs for assistant messages. - const checkpoints = - event.payload.role === "assistant" && event.payload.turnId !== null - ? rebindCheckpointAssistantMessage( - thread.checkpoints, - event.payload.turnId, - event.payload.messageId, - ) - : thread.checkpoints; + let checkpoints = applied.checkpointsToRepoint + ? repointCheckpointsForArchivedAssistantSegment( + thread.checkpoints, + applied.checkpointsToRepoint.providerMessageId, + applied.checkpointsToRepoint.archivedMessageId, + applied.checkpointsToRepoint.archivedTurnId, + ) + : thread.checkpoints; + if (event.payload.role === "assistant" && event.payload.turnId !== null) { + checkpoints = rebindCheckpointAssistantMessage( + checkpoints, + event.payload.turnId, + event.payload.messageId, + ); + } return { kind: "updated", diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index c732f80ef35..779a6499e4b 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -17,18 +17,59 @@ import { it, assert } from "@effect/vitest"; import * as AcpClient from "./client.ts"; import * as AcpSchema from "./_generated/schema.gen.ts"; import * as AcpError from "./errors.ts"; -import { encodeJsonl, jsonRpcRequest, jsonRpcResponse } from "./_internal/shared.ts"; +import { + encodeJsonl, + jsonRpcNotification, + jsonRpcRequest, + jsonRpcResponse, +} from "./_internal/shared.ts"; import { makeInMemoryStdio } from "./_internal/stdio.ts"; const InitializeRequest = jsonRpcRequest("initialize", AcpSchema.InitializeRequest); const InitializeResponse = jsonRpcResponse(AcpSchema.InitializeResponse); const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String })); const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean })); +const PromptRequest = jsonRpcRequest("session/prompt", AcpSchema.PromptRequest); +const PromptResponse = jsonRpcResponse(AcpSchema.PromptResponse); +const decodePromptRequestLine = Schema.decodeEffect(Schema.fromJsonString(PromptRequest)); +const XAiPromptCompleteNotification = jsonRpcNotification( + "_x.ai/session/prompt_complete", + Schema.Struct({ + sessionId: Schema.String, + promptId: Schema.String, + stopReason: Schema.String, + agentResult: Schema.NullOr(Schema.Unknown), + }), +); +const XAiQueueChangedNotification = jsonRpcNotification( + "_x.ai/queue/changed", + Schema.Struct({ + sessionId: Schema.String, + entries: Schema.Array(Schema.Unknown), + }), +); +const XAiSessionsChangedNotification = jsonRpcNotification( + "_x.ai/sessions/changed", + Schema.Struct({ + upserted: Schema.Array(Schema.Unknown), + removed: Schema.Array(Schema.Unknown), + }), +); const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"), ); const mockPeerArgs = (path: string) => [path]; +function concatBytes(chunks: ReadonlyArray): Uint8Array { + const batch = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0)); + let offset = 0; + for (const chunk of chunks) { + batch.set(chunk, offset); + offset += chunk.length; + } + return batch; +} + it.layer(NodeServices.layer)("effect-acp client", (it) => { const makeHandle = (env?: Record) => Effect.gen(function* () { @@ -446,4 +487,90 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { yield* Scope.close(scope, Exit.void); }), ); + + it.effect( + "routes a standard prompt response after Grok extension notifications in the same batch", + () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const scope = yield* Scope.make(); + const acp = yield* AcpClient.make(stdio).pipe(Effect.provideService(Scope.Scope, scope)); + + const promptFiber = yield* acp.agent + .prompt({ + sessionId: "grok-session-1", + prompt: [{ type: "text", text: "run the ls command" }], + }) + .pipe(Effect.forkScoped); + + const outbound = yield* Queue.take(output); + const decodedPrompt = yield* decodePromptRequestLine(outbound); + + const responseBatch = concatBytes( + yield* Effect.all([ + encodeJsonl(XAiQueueChangedNotification, { + jsonrpc: "2.0", + method: "_x.ai/queue/changed", + params: { sessionId: "grok-session-1", entries: [] }, + }), + encodeJsonl(XAiPromptCompleteNotification, { + jsonrpc: "2.0", + method: "_x.ai/session/prompt_complete", + params: { + sessionId: "grok-session-1", + promptId: "prompt-1", + stopReason: "end_turn", + agentResult: null, + }, + }), + encodeJsonl(XAiSessionsChangedNotification, { + jsonrpc: "2.0", + method: "_x.ai/sessions/changed", + params: { + upserted: [ + { + sessionId: "grok-session-1", + title: null, + cwd: process.cwd(), + isWorktree: false, + modelId: "grok-composer-2.5-fast", + yolo: false, + activity: "idle", + resident: true, + lastChangeUnixMs: 1_710_000_000_000, + origin: { kind: "local" }, + }, + ], + removed: [], + }, + }), + encodeJsonl(PromptResponse, { + jsonrpc: "2.0", + id: decodedPrompt.id, + result: { + stopReason: "end_turn", + _meta: { + sessionId: "grok-session-1", + requestId: "prompt-1", + promptId: "prompt-1", + modelId: "grok-composer-2.5-fast", + }, + }, + }), + ]), + ); + yield* Queue.offer(input, responseBatch); + + assert.deepEqual(yield* Fiber.join(promptFiber), { + stopReason: "end_turn", + _meta: { + sessionId: "grok-session-1", + requestId: "prompt-1", + promptId: "prompt-1", + modelId: "grok-composer-2.5-fast", + }, + }); + yield* Scope.close(scope, Exit.void); + }), + ); }); diff --git a/packages/shared/package.json b/packages/shared/package.json index 5c56aaf6997..b68630a1484 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -87,6 +87,10 @@ "types": "./src/orchestrationTiming.ts", "import": "./src/orchestrationTiming.ts" }, + "./orchestrationMessages": { + "types": "./src/orchestrationMessages.ts", + "import": "./src/orchestrationMessages.ts" + }, "./remote": { "types": "./src/remote.ts", "import": "./src/remote.ts" diff --git a/packages/shared/src/orchestrationMessages.test.ts b/packages/shared/src/orchestrationMessages.test.ts new file mode 100644 index 00000000000..d8913fd3e81 --- /dev/null +++ b/packages/shared/src/orchestrationMessages.test.ts @@ -0,0 +1,690 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + applyAssistantSegmentMessageUpdate, + type AssistantSegmentThreadMessage, + assistantSegmentBelongsToActiveTurn, + assistantSegmentStreamingTextResets, + assistantSegmentTurnChanged, + archivedAssistantSegmentMessageId, + archivedAssistantSegmentTurnIds, + isLateAssistantSegmentFromPriorTurn, + isLateStreamingOnCompletedAssistant, + repointCheckpointsForArchivedAssistantSegment, + resolveAssistantSegmentText, +} from "./orchestrationMessages.ts"; + +describe("orchestrationMessages", () => { + it("detects assistant segment turn rebinding", () => { + expect( + assistantSegmentTurnChanged({ turnId: "turn-a", streaming: false }, { turnId: "turn-b" }), + ).toBe(true); + expect( + assistantSegmentTurnChanged({ turnId: "turn-a", streaming: false }, { turnId: "turn-a" }), + ).toBe(false); + }); + + it("treats in-flight null turnId bind as continuation", () => { + expect( + assistantSegmentTurnChanged({ turnId: null, streaming: true }, { turnId: "turn-a" }), + ).toBe(false); + }); + + it("only treats null-turn chunks as active when the existing row belongs to the active turn", () => { + expect( + assistantSegmentBelongsToActiveTurn({ + activeTurnId: "turn-b", + existingTurnId: "turn-a", + incomingTurnId: null, + }), + ).toBe(false); + expect( + assistantSegmentBelongsToActiveTurn({ + activeTurnId: "turn-b", + existingTurnId: "turn-b", + incomingTurnId: null, + }), + ).toBe(true); + expect( + assistantSegmentBelongsToActiveTurn({ + activeTurnId: "turn-b", + existingTurnId: undefined, + incomingTurnId: null, + }), + ).toBe(true); + }); + + it("treats completed replay rebind as a turn change", () => { + expect( + assistantSegmentTurnChanged({ turnId: null, streaming: false }, { turnId: "turn-a" }), + ).toBe(true); + }); + + it("accepts trailing streaming chunks for the same settled turn", () => { + expect( + isLateStreamingOnCompletedAssistant({ + existing: { + role: "assistant", + streaming: false, + turnId: "turn-a", + }, + incoming: { + role: "assistant", + streaming: true, + turnId: "turn-a", + }, + }), + ).toBe(false); + }); + + it("accepts resumed streaming on the active turn after segment-level completion", () => { + expect( + isLateStreamingOnCompletedAssistant({ + existing: { + role: "assistant", + streaming: false, + turnId: "turn-a", + }, + incoming: { + role: "assistant", + streaming: true, + turnId: "turn-a", + }, + turnStillActive: true, + }), + ).toBe(false); + }); + + it("still accepts streaming chunks for in-flight assistant messages", () => { + expect( + isLateStreamingOnCompletedAssistant({ + existing: { + role: "assistant", + streaming: true, + turnId: "turn-a", + }, + incoming: { + role: "assistant", + streaming: true, + turnId: "turn-a", + }, + }), + ).toBe(false); + }); + + it("still accepts the first rebound delta before turnId is known", () => { + expect( + isLateStreamingOnCompletedAssistant({ + existing: { + role: "assistant", + streaming: false, + turnId: "turn-a", + }, + incoming: { + role: "assistant", + streaming: true, + turnId: null, + }, + }), + ).toBe(false); + }); + + it("still accepts assistant chunks when the segment rebinds to a new turn", () => { + expect( + isLateStreamingOnCompletedAssistant({ + existing: { + role: "assistant", + streaming: false, + turnId: "turn-a", + }, + incoming: { + role: "assistant", + streaming: true, + turnId: "turn-b", + }, + }), + ).toBe(false); + }); + + it("resets streaming text on the first null-turn rebound chunk", () => { + expect( + assistantSegmentStreamingTextResets( + { role: "assistant", streaming: false, turnId: "turn-a" }, + { streaming: true, turnId: null }, + ), + ).toBe(true); + expect( + assistantSegmentStreamingTextResets( + { role: "assistant", streaming: true, turnId: null }, + { streaming: true, turnId: "turn-a" }, + ), + ).toBe(false); + }); + + it("keeps appending null-turn chunks while the turn is still active", () => { + expect( + assistantSegmentStreamingTextResets( + { role: "assistant", streaming: false, turnId: "turn-a" }, + { streaming: true, turnId: null }, + { activeTurnId: "turn-a", turnStillActive: true }, + ), + ).toBe(false); + }); + + it("resets null-turn rebound chunks when the completed row belongs to a prior turn", () => { + expect( + assistantSegmentStreamingTextResets( + { role: "assistant", streaming: false, turnId: "turn-a" }, + { streaming: true, turnId: null }, + { activeTurnId: "turn-b", turnStillActive: true }, + ), + ).toBe(true); + }); + + it("keeps appending when a null-turn segment resumes streaming", () => { + expect( + assistantSegmentStreamingTextResets( + { role: "assistant", streaming: false, turnId: null }, + { streaming: true, turnId: null }, + ), + ).toBe(false); + }); + + it("preserves completed text when the provider emits an empty completion", () => { + expect( + resolveAssistantSegmentText({ text: "Hello" }, { text: "", streaming: false }, false), + ).toBe("Hello"); + }); + + it("drops stale final assistant segments from an older turn", () => { + expect( + isLateAssistantSegmentFromPriorTurn({ + existing: { + role: "assistant", + streaming: false, + turnId: "turn-b", + }, + incoming: { + role: "assistant", + streaming: false, + turnId: "turn-a", + }, + providerMessageId: "assistant-segment-0", + archivedTurnIds: new Set(["turn-a"]), + }), + ).toBe(true); + }); + + it("accepts forward rebound completions when the prior turn is not archived yet", () => { + expect( + isLateAssistantSegmentFromPriorTurn({ + existing: { + role: "assistant", + streaming: false, + turnId: "turn-a", + }, + incoming: { + role: "assistant", + streaming: false, + turnId: "turn-b", + }, + providerMessageId: "assistant-segment-0", + archivedTurnIds: new Set(), + }), + ).toBe(false); + }); + + it("drops stale streaming assistant segments from an older turn", () => { + expect( + isLateAssistantSegmentFromPriorTurn({ + existing: { + role: "assistant", + streaming: false, + turnId: "turn-b", + }, + incoming: { + role: "assistant", + streaming: true, + turnId: "turn-a", + }, + providerMessageId: "assistant-segment-0", + archivedTurnIds: new Set(["turn-a"]), + }), + ).toBe(true); + }); + + it("accepts forward rebound streaming when the prior turn is not archived yet", () => { + expect( + isLateAssistantSegmentFromPriorTurn({ + existing: { + role: "assistant", + streaming: false, + turnId: "turn-a", + }, + incoming: { + role: "assistant", + streaming: true, + turnId: "turn-b", + }, + providerMessageId: "assistant-segment-0", + archivedTurnIds: new Set(), + }), + ).toBe(false); + }); + + it("drops null-turn stale chunks after an archived rebound settles", () => { + expect( + isLateAssistantSegmentFromPriorTurn({ + existing: { + role: "assistant", + streaming: false, + turnId: "turn-b", + }, + incoming: { + role: "assistant", + streaming: true, + turnId: null, + }, + providerMessageId: "assistant-segment-0", + archivedTurnIds: new Set(["turn-a"]), + }), + ).toBe(true); + }); + + it("tracks archived replay rows with null turn ids", () => { + const archivedTurnIds = archivedAssistantSegmentTurnIds( + [ + { + id: archivedAssistantSegmentMessageId("assistant-segment-0", null), + turnId: null, + }, + ], + "assistant-segment-0", + ); + + expect(archivedTurnIds.has(null)).toBe(true); + }); + + it("drops stale null-turn chunks after an archived replay row", () => { + expect( + isLateAssistantSegmentFromPriorTurn({ + existing: { + role: "assistant", + streaming: false, + turnId: null, + }, + incoming: { + role: "assistant", + streaming: true, + turnId: null, + }, + providerMessageId: "assistant-segment-0", + archivedTurnIds: new Set([null]), + }), + ).toBe(true); + }); + + it("accepts null-turn completed rows after an archived rebound settles", () => { + expect( + isLateAssistantSegmentFromPriorTurn({ + existing: { + role: "assistant", + streaming: true, + turnId: "turn-b", + }, + incoming: { + role: "assistant", + streaming: false, + turnId: null, + }, + providerMessageId: "assistant-segment-0", + archivedTurnIds: new Set(["turn-a"]), + }), + ).toBe(false); + }); + + it("accepts null-turn rebound chunks while a prompt is active", () => { + expect( + isLateAssistantSegmentFromPriorTurn({ + existing: { + role: "assistant", + streaming: false, + turnId: "turn-b", + }, + incoming: { + role: "assistant", + streaming: true, + turnId: null, + }, + providerMessageId: "assistant-segment-0", + archivedTurnIds: new Set(["turn-a"]), + turnStillActive: true, + }), + ).toBe(false); + }); + + it("archives completed replay rows on the first null-turn rebound chunk", () => { + const result = applyAssistantSegmentMessageUpdate( + [ + { + id: "assistant-segment-0", + role: "assistant", + text: "Replayed response.", + streaming: false, + turnId: "turn-a", + createdAt: "2026-06-24T00:29:27.101Z", + updatedAt: "2026-06-24T00:29:27.101Z", + }, + ], + { + id: "assistant-segment-0", + role: "assistant", + text: "New ", + streaming: true, + turnId: null, + createdAt: "2026-06-24T01:12:00.260Z", + updatedAt: "2026-06-24T01:12:00.260Z", + }, + { activeTurnId: "turn-b", turnStillActive: true }, + ); + + expect(result.messages).toEqual([ + { + id: archivedAssistantSegmentMessageId("assistant-segment-0", "turn-a"), + role: "assistant", + text: "Replayed response.", + streaming: false, + turnId: "turn-a", + createdAt: "2026-06-24T00:29:27.101Z", + updatedAt: "2026-06-24T00:29:27.101Z", + }, + { + id: "assistant-segment-0", + role: "assistant", + text: "New ", + streaming: true, + turnId: null, + createdAt: "2026-06-24T01:12:00.260Z", + updatedAt: "2026-06-24T01:12:00.260Z", + }, + ]); + }); + + it("keeps same-active null-turn chunks on the existing assistant row", () => { + const result = applyAssistantSegmentMessageUpdate( + [ + { + id: "assistant-segment-0", + role: "assistant", + text: "Still active.", + streaming: false, + turnId: "turn-a", + createdAt: "2026-06-24T01:12:00.260Z", + updatedAt: "2026-06-24T01:12:00.260Z", + }, + ], + { + id: "assistant-segment-0", + role: "assistant", + text: " More.", + streaming: true, + turnId: null, + createdAt: "2026-06-24T01:12:01.000Z", + updatedAt: "2026-06-24T01:12:01.000Z", + }, + { activeTurnId: "turn-a", turnStillActive: true }, + ); + + expect(result.messages).toEqual([ + { + id: "assistant-segment-0", + role: "assistant", + text: "Still active. More.", + streaming: true, + turnId: null, + createdAt: "2026-06-24T01:12:00.260Z", + updatedAt: "2026-06-24T01:12:01.000Z", + }, + ]); + }); + + it("drops stale null-turn chunks when another turn owns the active prompt", () => { + const messages: AssistantSegmentThreadMessage[] = [ + { + id: archivedAssistantSegmentMessageId("assistant-segment-0", "turn-a"), + role: "assistant", + text: "Archived response.", + streaming: false, + turnId: "turn-a", + createdAt: "2026-06-24T00:29:27.101Z", + updatedAt: "2026-06-24T00:29:27.101Z", + }, + { + id: "assistant-segment-0", + role: "assistant", + text: "Current response.", + streaming: false, + turnId: "turn-b", + createdAt: "2026-06-24T01:12:00.260Z", + updatedAt: "2026-06-24T01:12:00.260Z", + }, + ]; + + const result = applyAssistantSegmentMessageUpdate( + messages, + { + id: "assistant-segment-0", + role: "assistant", + text: " stale", + streaming: true, + turnId: null, + createdAt: "2026-06-24T01:12:01.000Z", + updatedAt: "2026-06-24T01:12:01.000Z", + }, + { activeTurnId: "turn-c", turnStillActive: false }, + ); + + expect(result.messages).toBe(messages); + }); + + it("archives prior-turn assistant rows when a reused segment rebinds via completion", () => { + const result = applyAssistantSegmentMessageUpdate( + [ + { + id: "assistant-segment-0", + role: "assistant", + text: "Replayed response.", + streaming: false, + turnId: "turn-a", + createdAt: "2026-06-24T00:29:27.101Z", + updatedAt: "2026-06-24T00:29:27.101Z", + }, + ], + { + id: "assistant-segment-0", + role: "assistant", + text: "New completed response.", + streaming: false, + turnId: "turn-b", + createdAt: "2026-06-24T01:12:00.260Z", + updatedAt: "2026-06-24T01:12:00.260Z", + }, + ); + + expect(result.messages).toEqual([ + { + id: archivedAssistantSegmentMessageId("assistant-segment-0", "turn-a"), + role: "assistant", + text: "Replayed response.", + streaming: false, + turnId: "turn-a", + createdAt: "2026-06-24T00:29:27.101Z", + updatedAt: "2026-06-24T00:29:27.101Z", + }, + { + id: "assistant-segment-0", + role: "assistant", + text: "New completed response.", + streaming: false, + turnId: "turn-b", + createdAt: "2026-06-24T01:12:00.260Z", + updatedAt: "2026-06-24T01:12:00.260Z", + }, + ]); + }); + + it("appends rebound live rows after newer messages", () => { + const result = applyAssistantSegmentMessageUpdate( + [ + { + id: "assistant-segment-0", + role: "assistant", + text: "Replayed response.", + streaming: false, + turnId: "turn-a", + createdAt: "2026-06-24T00:29:27.101Z", + updatedAt: "2026-06-24T00:29:27.101Z", + }, + { + id: "user-follow-up", + role: "user", + text: "Next prompt", + streaming: false, + turnId: "turn-b", + createdAt: "2026-06-24T01:11:59.000Z", + updatedAt: "2026-06-24T01:11:59.000Z", + }, + ], + { + id: "assistant-segment-0", + role: "assistant", + text: "New response.", + streaming: false, + turnId: "turn-b", + createdAt: "2026-06-24T01:12:00.260Z", + updatedAt: "2026-06-24T01:12:00.260Z", + }, + ); + + expect(result.messages.map((message) => message.id)).toEqual([ + archivedAssistantSegmentMessageId("assistant-segment-0", "turn-a"), + "user-follow-up", + "assistant-segment-0", + ]); + }); + + it("uses the replay occurrence when archiving null-turn completed rows", () => { + const result = applyAssistantSegmentMessageUpdate( + [ + { + id: "assistant-segment-0", + role: "assistant", + text: "Null replay response.", + streaming: false, + turnId: null, + createdAt: "2026-06-24T00:29:27.101Z", + updatedAt: "2026-06-24T00:29:27.101Z", + }, + ], + { + id: "assistant-segment-0", + role: "assistant", + text: "New ", + streaming: true, + turnId: "turn-a", + createdAt: "2026-06-24T01:12:00.260Z", + updatedAt: "2026-06-24T01:12:00.260Z", + }, + ); + + expect(result.messages[0]?.id).toBe( + archivedAssistantSegmentMessageId("assistant-segment-0", null, "2026-06-24T00:29:27.101Z"), + ); + expect(result.messages[1]?.id).toBe("assistant-segment-0"); + }); + + it("archives prior-turn assistant rows when a reused segment rebinds", () => { + const result = applyAssistantSegmentMessageUpdate( + [ + { + id: "assistant-segment-0", + role: "assistant", + text: "Replayed response.", + streaming: false, + turnId: "turn-a", + createdAt: "2026-06-24T00:29:27.101Z", + updatedAt: "2026-06-24T00:29:27.101Z", + }, + ], + { + id: "assistant-segment-0", + role: "assistant", + text: "New ", + streaming: true, + turnId: "turn-b", + createdAt: "2026-06-24T01:12:00.260Z", + updatedAt: "2026-06-24T01:12:00.260Z", + }, + ); + + expect(result.messages).toEqual([ + { + id: archivedAssistantSegmentMessageId("assistant-segment-0", "turn-a"), + role: "assistant", + text: "Replayed response.", + streaming: false, + turnId: "turn-a", + createdAt: "2026-06-24T00:29:27.101Z", + updatedAt: "2026-06-24T00:29:27.101Z", + }, + { + id: "assistant-segment-0", + role: "assistant", + text: "New ", + streaming: true, + turnId: "turn-b", + createdAt: "2026-06-24T01:12:00.260Z", + updatedAt: "2026-06-24T01:12:00.260Z", + }, + ]); + expect(result.checkpointsToRepoint).toEqual({ + providerMessageId: "assistant-segment-0", + archivedMessageId: archivedAssistantSegmentMessageId("assistant-segment-0", "turn-a"), + archivedTurnId: "turn-a", + }); + }); + + it("repoints checkpoint assistant message ids to the archived row", () => { + const checkpoints = repointCheckpointsForArchivedAssistantSegment( + [ + { + turnId: "turn-a", + assistantMessageId: "assistant-segment-0", + }, + ], + "assistant-segment-0", + archivedAssistantSegmentMessageId("assistant-segment-0", "turn-a"), + "turn-a", + ); + + expect(checkpoints[0]?.assistantMessageId).toBe( + archivedAssistantSegmentMessageId("assistant-segment-0", "turn-a"), + ); + }); + + it("clears checkpoint assistant message ids when the archived row is not retained", () => { + const checkpoints = repointCheckpointsForArchivedAssistantSegment( + [ + { + turnId: "turn-a", + assistantMessageId: "assistant-segment-0", + }, + ], + "assistant-segment-0", + null, + "turn-a", + ); + + expect(checkpoints[0]?.assistantMessageId).toBeNull(); + }); +}); diff --git a/packages/shared/src/orchestrationMessages.ts b/packages/shared/src/orchestrationMessages.ts new file mode 100644 index 00000000000..a83c6401c98 --- /dev/null +++ b/packages/shared/src/orchestrationMessages.ts @@ -0,0 +1,396 @@ +type AssistantSegmentMessage = { + readonly role: string; + readonly streaming: boolean; + readonly turnId: string | null; +}; + +export type AssistantSegmentThreadMessage = AssistantSegmentMessage & { + readonly id: string; + readonly text: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly attachments?: ReadonlyArray | undefined; +}; + +export function assistantSegmentTurnChanged( + existing: Pick | undefined, + incoming: Pick, +): boolean { + if (existing === undefined || incoming.turnId == null) { + return false; + } + if (existing.turnId != null) { + return existing.turnId !== incoming.turnId; + } + // In-flight rebound: keep appending until the segment settles. Completed replay + // rows without a turnId are rebounding to a new live turn and should reset. + return !existing.streaming; +} + +/** + * Grok can deliver trailing session/update chunks after a turn has already + * settled. Ignore cross-turn late streaming deltas so completed assistant rows + * keep a stable timeline anchor for ordering, but keep appending when the + * provider is still finishing the same turn (for example after prompt_complete + * races ahead of trailing session/update chunks). + */ +export function isLateStreamingOnCompletedAssistant(input: { + readonly existing: AssistantSegmentMessage | undefined; + readonly incoming: AssistantSegmentMessage; + readonly turnStillActive?: boolean; +}): boolean { + if (input.incoming.role !== "assistant" || !input.incoming.streaming) { + return false; + } + const existing = input.existing; + if (existing === undefined || existing.role !== "assistant" || existing.streaming) { + return false; + } + // Provider ingestion can emit the first rebound delta before turnId is known. + if (input.incoming.turnId === null) { + return false; + } + if (input.turnStillActive) { + return false; + } + if ( + input.incoming.turnId !== null && + existing.turnId !== null && + existing.turnId === input.incoming.turnId + ) { + return false; + } + return !assistantSegmentTurnChanged(existing, input.incoming); +} + +/** + * Grok can deliver a stale assistant segment for an older turn after the live + * provider message id has already advanced to a newer turn. + */ +export function isLateAssistantSegmentFromPriorTurn(input: { + readonly existing: AssistantSegmentMessage | undefined; + readonly incoming: AssistantSegmentMessage; + readonly providerMessageId?: string; + readonly archivedTurnIds?: ReadonlySet; + readonly turnStillActive?: boolean; +}): boolean { + if (input.incoming.role !== "assistant" || input.existing?.role !== "assistant") { + return false; + } + if (input.incoming.turnId === null) { + const archivedTurnIds = input.archivedTurnIds; + return ( + input.incoming.streaming && + archivedTurnIds !== undefined && + archivedTurnIds.size > 0 && + input.turnStillActive !== true + ); + } + if (input.existing.turnId === input.incoming.turnId) { + return false; + } + const providerMessageId = input.providerMessageId; + if (providerMessageId === undefined) { + return false; + } + const archivedTurnIds = input.archivedTurnIds; + if (archivedTurnIds !== undefined) { + return archivedTurnIds.has(input.incoming.turnId); + } + return false; +} + +export function assistantSegmentBelongsToActiveTurn(input: { + readonly activeTurnId: string | null | undefined; + readonly existingTurnId: string | null | undefined; + readonly incomingTurnId: string | null; +}): boolean { + const activeTurnId = input.activeTurnId ?? null; + if (activeTurnId === null) { + return false; + } + if (input.incomingTurnId !== null) { + return input.incomingTurnId === activeTurnId; + } + return ( + input.existingTurnId === undefined || + input.existingTurnId === null || + input.existingTurnId === activeTurnId + ); +} + +export function archivedAssistantSegmentTurnIds( + messages: ReadonlyArray<{ readonly id: string; readonly turnId: string | null }>, + providerMessageId: string, +): ReadonlySet { + const prefix = `${providerMessageId}@turn:`; + const archivedTurnIds = new Set(); + for (const message of messages) { + if (message.id === undefined || !message.id.startsWith(prefix)) { + continue; + } + archivedTurnIds.add(message.turnId); + } + return archivedTurnIds; +} + +export function assistantSegmentRebindArchives( + existing: AssistantSegmentMessage | undefined, + incoming: Pick, + options?: { + readonly activeTurnId?: string | null; + readonly turnStillActive?: boolean; + }, +): boolean { + const existingTurnStillActive = + options?.turnStillActive === true && + options.activeTurnId != null && + existing?.turnId === options.activeTurnId; + return ( + existing !== undefined && + existing.role === "assistant" && + !existing.streaming && + existing.turnId !== null && + incoming.streaming && + incoming.turnId === null && + !existingTurnStillActive + ); +} + +export function archivedAssistantSegmentMessageId( + messageId: string, + turnId: string | null, + occurrence?: string, +): string { + if (turnId !== null) { + return `${messageId}@turn:${turnId}`; + } + return `${messageId}@turn:replay${occurrence === undefined ? "" : `:${occurrence}`}`; +} + +export function assistantSegmentStreamingTextResets( + existing: AssistantSegmentMessage | undefined, + incoming: Pick, + options?: { + readonly activeTurnId?: string | null; + readonly turnStillActive?: boolean; + }, +): boolean { + if (!incoming.streaming || existing?.role !== "assistant") { + return false; + } + if (assistantSegmentTurnChanged(existing, incoming)) { + return true; + } + // Completed replay rows with a known turn keep stale text until the first + // rebound chunk arrives with turnId still unknown. Null-to-null continuation + // within the same live segment must keep appending. + const existingTurnStillActive = + options?.turnStillActive === true && + options.activeTurnId != null && + existing.turnId === options.activeTurnId; + return ( + existing !== undefined && + !existing.streaming && + existing.turnId !== null && + incoming.turnId === null && + !existingTurnStillActive + ); +} + +export function assistantSegmentTimelineAnchorResets( + existing: AssistantSegmentMessage | undefined, + incoming: Pick, + options?: { + readonly activeTurnId?: string | null; + readonly turnStillActive?: boolean; + }, +): boolean { + return ( + assistantSegmentTurnChanged(existing, incoming) || + assistantSegmentStreamingTextResets(existing, incoming, options) + ); +} + +export function resolveAssistantSegmentText( + existing: Pick | undefined, + incoming: Pick, + textResets: boolean, +): string { + if (incoming.streaming) { + return textResets ? incoming.text : `${existing?.text ?? ""}${incoming.text}`; + } + if (incoming.text.length === 0 && !textResets) { + return existing?.text ?? incoming.text; + } + return incoming.text; +} + +export function resolveAssistantSegmentAttachments( + existingAttachments: ReadonlyArray | undefined, + incoming: { readonly attachments?: ReadonlyArray | undefined }, + turnChanged: boolean, +): ReadonlyArray | undefined { + if (incoming.attachments !== undefined) { + return incoming.attachments; + } + if (turnChanged) { + return undefined; + } + return existingAttachments; +} + +export function repointCheckpointsForArchivedAssistantSegment< + T extends { readonly turnId: string; readonly assistantMessageId: string | null }, +>( + checkpoints: ReadonlyArray, + providerMessageId: string, + archivedMessageId: string | null, + archivedTurnId: string, +): readonly T[] { + return checkpoints.map((entry) => + entry.turnId === archivedTurnId && entry.assistantMessageId === providerMessageId + ? { ...entry, assistantMessageId: archivedMessageId as T["assistantMessageId"] } + : entry, + ); +} + +export function repointLatestTurnForArchivedAssistantSegment< + T extends { readonly turnId: string; readonly assistantMessageId: string | null }, +>( + latestTurn: T | null, + repoint: { + readonly providerMessageId: string; + readonly archivedMessageId: string | null; + readonly archivedTurnId: string; + }, +): T | null { + if ( + latestTurn === null || + latestTurn.turnId !== repoint.archivedTurnId || + latestTurn.assistantMessageId !== repoint.providerMessageId + ) { + return latestTurn; + } + return { + ...latestTurn, + assistantMessageId: repoint.archivedMessageId as T["assistantMessageId"], + }; +} + +export function applyAssistantSegmentMessageUpdate( + messages: ReadonlyArray, + incoming: T, + options?: { + readonly activeTurnId?: string | null; + readonly turnStillActive?: boolean; + }, +): { + readonly messages: readonly T[]; + readonly checkpointsToRepoint: + | { + readonly providerMessageId: string; + readonly archivedMessageId: string; + readonly archivedTurnId: string; + } + | undefined; +} { + const existingMessage = messages.find((entry) => entry.id === incoming.id); + const archivedTurnIds = archivedAssistantSegmentTurnIds(messages, incoming.id); + if ( + isLateAssistantSegmentFromPriorTurn({ + existing: existingMessage, + incoming, + providerMessageId: incoming.id, + archivedTurnIds, + turnStillActive: options?.turnStillActive === true, + }) || + isLateStreamingOnCompletedAssistant({ + existing: existingMessage, + incoming, + turnStillActive: options?.turnStillActive === true, + }) + ) { + return { messages, checkpointsToRepoint: undefined }; + } + + const turnChanged = assistantSegmentTurnChanged(existingMessage, incoming); + const textResets = assistantSegmentStreamingTextResets(existingMessage, incoming, { + activeTurnId: options?.activeTurnId ?? null, + turnStillActive: options?.turnStillActive === true, + }); + const rebindArchives = assistantSegmentRebindArchives(existingMessage, incoming, { + activeTurnId: options?.activeTurnId ?? null, + turnStillActive: options?.turnStillActive === true, + }); + const shouldArchive = turnChanged || rebindArchives; + const timelineAnchorResets = assistantSegmentTimelineAnchorResets(existingMessage, incoming, { + activeTurnId: options?.activeTurnId ?? null, + turnStillActive: options?.turnStillActive === true, + }); + const nextText = resolveAssistantSegmentText(existingMessage, incoming, textResets); + const resolvedAttachments = resolveAssistantSegmentAttachments( + existingMessage?.attachments, + incoming, + shouldArchive, + ); + const attachmentFields = + resolvedAttachments !== undefined + ? { attachments: resolvedAttachments } + : shouldArchive + ? { attachments: undefined } + : {}; + const liveMessage: T = { + ...(existingMessage ?? incoming), + ...incoming, + text: nextText, + createdAt: timelineAnchorResets + ? incoming.createdAt + : (existingMessage?.createdAt ?? incoming.createdAt), + ...attachmentFields, + }; + + if (existingMessage === undefined) { + return { messages: [...messages, liveMessage], checkpointsToRepoint: undefined }; + } + + const existingIndex = messages.findIndex((entry) => entry.id === incoming.id); + + if (shouldArchive) { + const archivedTurnId = existingMessage.turnId; + const archivedMessage: T = { + ...existingMessage, + id: archivedAssistantSegmentMessageId( + incoming.id, + archivedTurnId, + existingMessage.createdAt, + ) as T["id"], + streaming: false, + }; + return { + messages: [ + ...messages.slice(0, existingIndex), + archivedMessage, + ...messages.slice(existingIndex + 1), + liveMessage, + ], + checkpointsToRepoint: + archivedTurnId !== null + ? { + providerMessageId: incoming.id, + archivedMessageId: archivedMessage.id, + archivedTurnId, + } + : undefined, + }; + } + + return { + messages: [ + ...messages.slice(0, existingIndex), + liveMessage, + ...messages.slice(existingIndex + 1), + ], + checkpointsToRepoint: undefined, + }; +} From cebc61a231be5f394a7e8db292d7781e75bf77e0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 25 Jun 2026 20:25:50 -0700 Subject: [PATCH 2/7] fix(grok): guard replacement session settlement Prevent stale prompt completion from mutating a replacement Grok session and exercise replay-idle load readiness end to end. Co-authored-by: codex --- .../src/provider/Layers/GrokAdapter.test.ts | 26 ++++- .../server/src/provider/Layers/GrokAdapter.ts | 96 ++++++++++--------- .../provider/acp/AcpJsonRpcConnection.test.ts | 40 ++++++++ .../src/provider/acp/AcpSessionRuntime.ts | 3 +- 4 files changed, 116 insertions(+), 49 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index cd3b5901878..f30350b2ad9 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -26,7 +26,7 @@ import { } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; -import { makeGrokAdapter } from "./GrokAdapter.ts"; +import { grokPromptSettlementBelongsToContext, makeGrokAdapter } from "./GrokAdapter.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); @@ -82,6 +82,30 @@ const grokAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { const makeTestAdapter = (binaryPath: string, options?: Parameters[1]) => makeGrokAdapter(decodeGrokSettings({ binaryPath }), options).pipe(Effect.orDie); +it("classifies settlement from a replaced Grok session as detached", () => { + const staleTurnId = TurnId.make("stale-turn"); + const replacementTurnId = TurnId.make("replacement-turn"); + + assert.isFalse( + grokPromptSettlementBelongsToContext({ + liveAcpSessionId: "replacement-session", + expectedAcpSessionId: "stale-session", + liveActiveTurnId: replacementTurnId, + liveSessionActiveTurnId: replacementTurnId, + turnId: staleTurnId, + }), + ); + assert.isTrue( + grokPromptSettlementBelongsToContext({ + liveAcpSessionId: "replacement-session", + expectedAcpSessionId: "stale-session", + liveActiveTurnId: staleTurnId, + liveSessionActiveTurnId: staleTurnId, + turnId: staleTurnId, + }), + ); +}); + it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 35c2396e9a0..44ecd2d6dee 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -215,6 +215,20 @@ function completedStopReasonFromPromptResponse( return response.stopReason; } +export function grokPromptSettlementBelongsToContext(input: { + readonly liveAcpSessionId: string; + readonly expectedAcpSessionId: string; + readonly liveActiveTurnId: TurnId | undefined; + readonly liveSessionActiveTurnId: TurnId | undefined; + readonly turnId: TurnId; +}): boolean { + return ( + input.liveAcpSessionId === input.expectedAcpSessionId || + input.liveActiveTurnId === input.turnId || + input.liveSessionActiveTurnId === input.turnId + ); +} + export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapterLiveOptions) { return Effect.gen(function* () { const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("grok"); @@ -302,54 +316,44 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte if (!liveCtx) { return; } - if (liveCtx.acpSessionId !== expectedAcpSessionId) { - if (liveCtx.activeTurnId !== turnId && liveCtx.session.activeTurnId !== turnId) { - const sessionWasActive = - liveCtx.session.status === "running" || liveCtx.session.status === "connecting"; - const shouldEmitFailedTurn = options?.errorMessage !== undefined; - const shouldEmitCompletedTurn = options?.completedStopReason !== undefined; - if (options?.settleAllPrompts) { - liveCtx.promptsInFlight = 0; - if (sessionWasActive) { - const updatedAt = yield* nowIso; - const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; - liveCtx.activeTurnId = undefined; - liveCtx.session = { - ...readySession, - status: "ready", - updatedAt, - }; - } - } - if (options?.emitTurnCompletion !== false) { - if (shouldEmitFailedTurn) { - yield* offerRuntimeEvent({ - type: "turn.completed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId, - turnId, - payload: { - state: "failed", - errorMessage: options.errorMessage, - }, - }); - } else if (shouldEmitCompletedTurn) { - yield* offerRuntimeEvent({ - type: "turn.completed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId, - turnId, - payload: { - state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", - stopReason: options.completedStopReason ?? null, - }, - }); - } + const settlementBelongsToLiveContext = grokPromptSettlementBelongsToContext({ + liveAcpSessionId: liveCtx.acpSessionId, + expectedAcpSessionId, + liveActiveTurnId: liveCtx.activeTurnId, + liveSessionActiveTurnId: liveCtx.session.activeTurnId, + turnId, + }); + if (!settlementBelongsToLiveContext) { + if (options?.emitTurnCompletion !== false) { + if (options?.errorMessage !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (options?.completedStopReason !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); } - return; } + return; + } + if (liveCtx.acpSessionId !== expectedAcpSessionId) { liveCtx.promptsInFlight = options?.settleAllPrompts ? 0 : Math.max(0, liveCtx.promptsInFlight - 1); diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 6c443068a8d..cc7bcc57cee 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -8,6 +8,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; import * as TestClock from "effect/testing/TestClock"; import * as Stream from "effect/Stream"; import { describe, expect } from "vite-plus/test"; @@ -591,6 +592,45 @@ describe("AcpSessionRuntime", () => { ), ); + it.effect("completes session/load after replay becomes idle while its RPC stays pending", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + const started = yield* runtime.start().pipe(Effect.timeout("2 seconds")); + + expect(started.sessionId).toBe("mock-session-1"); + expect(started.sessionSetupResult._meta).toMatchObject({ + t3SessionLoadReady: "replay_idle", + }); + + const unexpectedReplayEvent = yield* Stream.runHead(runtime.getEvents()).pipe( + Effect.timeoutOption("100 millis"), + ); + expect(Option.isNone(unexpectedReplayEvent)).toBe(true); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "test", + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { + T3_ACP_HANG_LOAD_SESSION_AFTER_REPLAY: "1", + T3_ACP_LOAD_SESSION_DELAY_MS: "10000", + }, + }, + cwd: process.cwd(), + resumeSessionId: "mock-session-1", + sessionLoadReplayIdleGap: "50 millis", + sessionLoadTimeout: "1 second", + clientInfo: { name: "t3-test", version: "0.0.0" }, + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + TestClock.withLive, + ), + ); + it.effect("rejects invalid config option values before sending session/set_config_option", () => { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-")); const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index e5309071fbb..5134f23920a 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -29,7 +29,6 @@ import { parseSessionModeState, parseSessionUpdateEvent, sessionUpdateIsReplay, - syntheticLoadSessionResponseFromInitialize, waitForSessionLoadReplayIdle, type SessionLoadGate, type AcpParsedSessionEvent, @@ -409,7 +408,7 @@ export const make = ( pendingRef: pendingXAiPromptCompletionsRef, completedPromptIdsRef: completedXAiPromptIdsRef, notification, - }).pipe(Effect.catch(() => Effect.void)), + }), ); const initializeClientCapabilities = { From 82aa6c50cd704afd29af60e93c8683be5de11138 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 25 Jun 2026 20:55:21 -0700 Subject: [PATCH 3/7] fix(grok): isolate cancelled turn cleanup Prevent late cancelled prompt results from consuming replacement-turn state, and suppress ACP output that arrives after cancellation. Co-authored-by: codex --- apps/server/scripts/acp-mock-agent.ts | 18 +- .../src/provider/Layers/GrokAdapter.test.ts | 182 +++++++++++++++++- .../server/src/provider/Layers/GrokAdapter.ts | 75 +++++--- 3 files changed, 240 insertions(+), 35 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index f70a0f5799a..a5303eb1b09 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -22,6 +22,7 @@ const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1"; const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1"; +const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL === "1"; const omitXAiPromptCompleteStopReason = process.env.T3_ACP_OMIT_XAI_PROMPT_COMPLETE_STOP_REASON === "1"; const failLoadSession = process.env.T3_ACP_FAIL_LOAD_SESSION === "1"; @@ -432,8 +433,21 @@ const program = Effect.gen(function* () { ); yield* agent.handleCancel(({ sessionId }) => - Effect.sync(() => { - cancelledSessions.add(String(sessionId ?? "mock-session-1")); + Effect.gen(function* () { + const cancelledSessionId = String(sessionId ?? "mock-session-1"); + cancelledSessions.add(cancelledSessionId); + if (emitLateUpdateAfterCancel) { + yield* Effect.sleep("50 millis"); + yield* Effect.sync(() => { + writeJsonRpcNotification("session/update", { + sessionId: cancelledSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "late after cancel" }, + }, + }); + }); + } }), ); diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index f30350b2ad9..08f58f005b7 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -48,7 +48,11 @@ exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@" return wrapperPath; } -function waitForFileContent(filePath: string, attempts = 40): Effect.Effect { +function waitForFileContent( + filePath: string, + attempts = 40, + expectedContent?: string, +): Effect.Effect { const readAttempt = (remainingAttempts: number): Effect.Effect => Effect.gen(function* () { if (remainingAttempts <= 0) { @@ -57,7 +61,10 @@ function waitForFileContent(filePath: string, attempts = 40): Effect.Effect NodeFSP.readFile(filePath, "utf8")).pipe( Effect.orElseSucceed(() => ""), ); - if (raw.trim().length > 0) { + if ( + raw.trim().length > 0 && + (expectedContent === undefined || raw.includes(expectedContent)) + ) { return raw; } yield* Effect.sleep("25 millis"); @@ -82,14 +89,12 @@ const grokAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { const makeTestAdapter = (binaryPath: string, options?: Parameters[1]) => makeGrokAdapter(decodeGrokSettings({ binaryPath }), options).pipe(Effect.orDie); -it("classifies settlement from a replaced Grok session as detached", () => { +it("requires a settlement to match the live Grok turn", () => { const staleTurnId = TurnId.make("stale-turn"); const replacementTurnId = TurnId.make("replacement-turn"); assert.isFalse( grokPromptSettlementBelongsToContext({ - liveAcpSessionId: "replacement-session", - expectedAcpSessionId: "stale-session", liveActiveTurnId: replacementTurnId, liveSessionActiveTurnId: replacementTurnId, turnId: staleTurnId, @@ -97,8 +102,6 @@ it("classifies settlement from a replaced Grok session as detached", () => { ); assert.isTrue( grokPromptSettlementBelongsToContext({ - liveAcpSessionId: "replacement-session", - expectedAcpSessionId: "stale-session", liveActiveTurnId: staleTurnId, liveSessionActiveTurnId: staleTurnId, turnId: staleTurnId, @@ -545,6 +548,171 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }).pipe(TestClock.withLive), ); + it.effect("does not let a cancelled prompt settlement consume the follow-up prompt slot", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-cancelled-settlement-before-follow-up"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-cancel-race-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const firstTurnStarted = yield* Deferred.make(); + const twoTurnsCompleted = yield* Deferred.make(); + const completedCountRef = yield* Ref.make(0); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "turn.started" && event.turnId !== undefined) { + yield* Deferred.succeed(firstTurnStarted, event.turnId).pipe(Effect.ignore); + return; + } + if (event.type !== "turn.completed") { + return; + } + const completedCount = yield* Ref.updateAndGet(completedCountRef, (count) => count + 1); + if (completedCount === 2) { + yield* Deferred.succeed(twoTurnsCompleted, undefined); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const firstSendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "cancel this prompt", attachments: [] }) + .pipe(Effect.forkChild); + const firstTurnId = yield* Deferred.await(firstTurnStarted).pipe(Effect.timeout("2 seconds")); + yield* waitForFileContent(requestLogPath, 80, '"method":"session/prompt"'); + + yield* adapter.interruptTurn(threadId, firstTurnId).pipe(Effect.timeout("2 seconds")); + const followUp = yield* adapter + .sendTurn({ threadId, input: "complete the follow-up", attachments: [] }) + .pipe(Effect.timeout("2 seconds")); + yield* Fiber.join(firstSendTurnFiber).pipe(Effect.timeout("2 seconds")); + yield* Deferred.await(twoTurnsCompleted).pipe(Effect.timeout("2 seconds")); + + const turnCompletedEvents = runtimeEvents.filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + + assert.notEqual(String(followUp.turnId), String(firstTurnId)); + assert.deepEqual( + turnCompletedEvents.map((event) => [String(event.turnId), event.payload.state]), + [ + [String(firstTurnId), "cancelled"], + [String(followUp.turnId), "completed"], + ], + ); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect("drops late ACP notifications after a turn is cancelled", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-drop-late-cancelled-notifications"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_HANG_PROMPT_FOREVER: "1", + T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL: "1", + }), + ); + const lateNativeUpdate = yield* Deferred.make(); + const adapter = yield* makeTestAdapter(wrapperPath, { + nativeEventLogger: { + filePath: "memory://grok-cancelled-native-events", + write: (record: unknown) => + JSON.stringify(record).includes("late after cancel") + ? Deferred.succeed(lateNativeUpdate, undefined).pipe(Effect.asVoid) + : Effect.void, + close: () => Effect.void, + }, + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnStarted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.started" && + event.turnId !== undefined && + String(event.threadId) === String(threadId) + ? Deferred.succeed(turnStarted, event.turnId).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "cancel before the late update", attachments: [] }) + .pipe(Effect.forkChild); + const turnId = yield* Deferred.await(turnStarted).pipe(Effect.timeout("2 seconds")); + yield* adapter.interruptTurn(threadId, turnId).pipe(Effect.timeout("2 seconds")); + yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("2 seconds")); + yield* Deferred.await(lateNativeUpdate).pipe(Effect.timeout("2 seconds")); + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + const cancelledIndex = runtimeEvents.findIndex( + (event) => + event.type === "turn.completed" && + String(event.threadId) === String(threadId) && + String(event.turnId) === String(turnId) && + event.payload.state === "cancelled", + ); + const turnOutputTypes = new Set([ + "content.delta", + "item.started", + "item.updated", + "item.completed", + "turn.plan.updated", + ]); + const outputAfterCancellation = runtimeEvents + .slice(cancelledIndex + 1) + .filter( + (event) => String(event.threadId) === String(threadId) && turnOutputTypes.has(event.type), + ); + + assert.isAtLeast(cancelledIndex, 0); + assert.deepEqual(outputAfterCancellation, []); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + it.effect("lets Stop cancel during the xAI completion drain window", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-stop-during-completion-drain"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 44ecd2d6dee..239d44a0572 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -216,17 +216,11 @@ function completedStopReasonFromPromptResponse( } export function grokPromptSettlementBelongsToContext(input: { - readonly liveAcpSessionId: string; - readonly expectedAcpSessionId: string; readonly liveActiveTurnId: TurnId | undefined; readonly liveSessionActiveTurnId: TurnId | undefined; readonly turnId: TurnId; }): boolean { - return ( - input.liveAcpSessionId === input.expectedAcpSessionId || - input.liveActiveTurnId === input.turnId || - input.liveSessionActiveTurnId === input.turnId - ); + return input.liveActiveTurnId === input.turnId || input.liveSessionActiveTurnId === input.turnId; } export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapterLiveOptions) { @@ -317,13 +311,17 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte return; } const settlementBelongsToLiveContext = grokPromptSettlementBelongsToContext({ - liveAcpSessionId: liveCtx.acpSessionId, - expectedAcpSessionId, liveActiveTurnId: liveCtx.activeTurnId, liveSessionActiveTurnId: liveCtx.session.activeTurnId, turnId, }); if (!settlementBelongsToLiveContext) { + // interruptTurn already consumed every prompt slot for this turn. A + // late prompt result must neither emit a second terminal event nor + // consume a slot belonging to a newer turn on the same ACP session. + if (liveCtx.interruptedTurnIds.has(turnId)) { + return; + } if (options?.emitTurnCompletion !== false) { if (options?.errorMessage !== undefined) { yield* offerRuntimeEvent({ @@ -521,6 +519,8 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const emitPlanUpdate = ( ctx: GrokSessionContext, + turnId: TurnId | undefined, + stamp: { readonly eventId: EventId; readonly createdAt: string }, payload: { readonly explanation?: string | null; readonly plan: ReadonlyArray<{ @@ -532,17 +532,17 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte method: string, ) => Effect.gen(function* () { - const fingerprint = `${resolveNotificationTurnId(ctx) ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + const fingerprint = `${turnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; if (ctx.lastPlanFingerprint === fingerprint) { return; } ctx.lastPlanFingerprint = fingerprint; yield* offerRuntimeEvent( makeAcpPlanUpdatedEvent({ - stamp: yield* makeEventStamp(), + stamp, provider: PROVIDER, threadId: ctx.threadId, - turnId: resolveNotificationTurnId(ctx), + turnId, payload, source: "acp.jsonrpc", method, @@ -840,14 +840,35 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const nf = yield* Stream.runDrain( Stream.mapEffect(acp.getEvents(), (event) => Effect.gen(function* () { + if ( + event._tag === "PlanUpdated" || + event._tag === "ToolCallUpdated" || + event._tag === "ContentDelta" + ) { + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + } + + if (event._tag === "ModeChanged") { + return; + } + + const stamp = yield* makeEventStamp(); + const notificationTurnId = resolveNotificationTurnId(ctx); + if ( + notificationTurnId !== undefined && + ctx.interruptedTurnIds.has(notificationTurnId) + ) { + return; + } + switch (event._tag) { case "AssistantItemStarted": yield* offerRuntimeEvent( makeAcpAssistantItemEvent({ - stamp: yield* makeEventStamp(), + stamp, provider: PROVIDER, threadId: ctx.threadId, - turnId: resolveNotificationTurnId(ctx), + turnId: notificationTurnId, itemId: event.itemId, lifecycle: "item.started", }), @@ -856,40 +877,44 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte case "AssistantItemCompleted": yield* offerRuntimeEvent( makeAcpAssistantItemEvent({ - stamp: yield* makeEventStamp(), + stamp, provider: PROVIDER, threadId: ctx.threadId, - turnId: resolveNotificationTurnId(ctx), + turnId: notificationTurnId, itemId: event.itemId, lifecycle: "item.completed", }), ); return; case "PlanUpdated": - yield* logNative(ctx.threadId, "session/update", event.rawPayload); - yield* emitPlanUpdate(ctx, event.payload, event.rawPayload, "session/update"); + yield* emitPlanUpdate( + ctx, + notificationTurnId, + stamp, + event.payload, + event.rawPayload, + "session/update", + ); return; case "ToolCallUpdated": - yield* logNative(ctx.threadId, "session/update", event.rawPayload); yield* offerRuntimeEvent( makeAcpToolCallEvent({ - stamp: yield* makeEventStamp(), + stamp, provider: PROVIDER, threadId: ctx.threadId, - turnId: resolveNotificationTurnId(ctx), + turnId: notificationTurnId, toolCall: event.toolCall, rawPayload: event.rawPayload, }), ); return; case "ContentDelta": - yield* logNative(ctx.threadId, "session/update", event.rawPayload); yield* offerRuntimeEvent( makeAcpContentDeltaEvent({ - stamp: yield* makeEventStamp(), + stamp, provider: PROVIDER, threadId: ctx.threadId, - turnId: resolveNotificationTurnId(ctx), + turnId: notificationTurnId, ...(event.itemId ? { itemId: event.itemId } : {}), text: event.text, rawPayload: event.rawPayload, @@ -1146,7 +1171,6 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }); } if (ctx.interruptedTurnIds.has(prepared.turnId)) { - ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); yield* Ref.set(promptSettled, true); return { threadId: input.threadId, @@ -1259,7 +1283,6 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte return; } if (ctx.interruptedTurnIds.has(prepared.turnId)) { - ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); return; } if ( From b3f3a3daeaa871928d03544e8b83cd8ed3ae02a3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 25 Jun 2026 21:10:08 -0700 Subject: [PATCH 4/7] fix(web): keep active turns unfolded Co-authored-by: codex --- .../web/src/components/ChatView.logic.test.ts | 25 ++++++++ apps/web/src/components/ChatView.logic.ts | 11 ++++ apps/web/src/components/ChatView.tsx | 10 ++- .../chat/MessagesTimeline.logic.test.ts | 61 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 25 +++++--- .../components/chat/MessagesTimeline.test.tsx | 1 + .../src/components/chat/MessagesTimeline.tsx | 4 ++ 7 files changed, 127 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 43ed895c0db..0a0103df183 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -6,6 +6,7 @@ import { MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, + buildThreadTurnInterruptInput, createLocalDispatchSnapshot, deriveComposerSendState, getStartedThreadModelChangeBlockReason, @@ -69,6 +70,30 @@ const readySession = { updatedAt: "2026-03-29T00:00:10.000Z", }; +describe("buildThreadTurnInterruptInput", () => { + it("targets the session's active running turn", () => { + const activeTurnId = TurnId.make("turn-running"); + + expect( + buildThreadTurnInterruptInput( + makeThread({ + session: { + ...readySession, + status: "running", + activeTurnId, + }, + }), + ), + ).toEqual({ threadId, turnId: activeTurnId }); + }); + + it("omits a turn id when the session is not running", () => { + expect(buildThreadTurnInterruptInput(makeThread({ session: readySession }))).toEqual({ + threadId, + }); + }); +}); + describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { const state = deriveComposerSendState({ diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 36947caae6f..705793ec77e 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -74,6 +74,17 @@ export function shouldWriteThreadErrorToCurrentServerThread(input: { ); } +export function buildThreadTurnInterruptInput(thread: Pick): { + threadId: ThreadId; + turnId?: TurnId; +} { + const runningTurnId = thread.session?.status === "running" ? thread.session.activeTurnId : null; + return { + threadId: thread.id, + ...(runningTurnId !== null ? { turnId: runningTurnId } : {}), + }; +} + export function reconcileMountedTerminalThreadIds(input: { currentThreadIds: ReadonlyArray; openThreadIds: ReadonlyArray; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1e978b85d84..5adabd2d4a4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -215,6 +215,7 @@ import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, + buildThreadTurnInterruptInput, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveComposerSendState, @@ -3952,9 +3953,7 @@ function ChatViewContent(props: ChatViewProps) { if (!activeThread) return; const result = await interruptThreadTurn({ environmentId, - input: { - threadId: activeThread.id, - }, + input: buildThreadTurnInterruptInput(activeThread), }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); @@ -4785,6 +4784,11 @@ function ChatViewContent(props: ChatViewProps) { listRef={legendListRef} timelineEntries={timelineEntries} latestTurn={activeLatestTurn} + runningTurnId={ + activeThread.session?.status === "running" + ? activeThread.session.activeTurnId + : null + } turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} activeThreadEnvironmentId={activeThread.environmentId} routeThreadKey={routeThreadKey} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 50ee10b4169..1676d2d7c85 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -794,6 +794,67 @@ describe("deriveMessagesTimelineRows", () => { ]); }); + it("does not fold the session's running turn when latestTurn regresses", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "previous-work-entry", + kind: "work", + createdAt: "2026-01-01T00:00:05Z", + entry: { + id: "previous-work", + createdAt: "2026-01-01T00:00:05Z", + turnId: "turn-1" as never, + label: "Read files", + tone: "tool" as const, + }, + }, + { + id: "user-followup-entry", + kind: "message", + createdAt: "2026-01-01T00:01:00Z", + message: { + id: "user-followup" as never, + role: "user", + text: "continue", + turnId: null, + createdAt: "2026-01-01T00:01:00Z", + updatedAt: "2026-01-01T00:01:00Z", + streaming: false, + }, + }, + { + id: "running-work-entry", + kind: "work", + createdAt: "2026-01-01T00:01:05Z", + entry: { + id: "running-work", + createdAt: "2026-01-01T00:01:05Z", + turnId: "turn-2" as never, + label: "Searched files", + tone: "tool" as const, + }, + }, + ], + latestTurn: { + turnId: "turn-1" as never, + state: "completed", + startedAt: "2026-01-01T00:00:00Z", + completedAt: "2026-01-01T00:00:25Z", + }, + runningTurnId: "turn-2" as never, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:01:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ + "turn-1", + ]); + expect(rows.map((row) => row.id)).toContain("running-work-entry"); + }); + it("only shows assistant metadata on the terminal assistant message", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 1426f1deee2..86212576f0b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -149,13 +149,20 @@ interface TurnFold { } /** - * The latest turn counts as unsettled while it is still running (or has not - * recorded a completion). This is deliberately keyed on the turn's own - * lifecycle rather than transient working state: right after the user sends - * a message, the previous turn is still the "active" one until the server - * creates the new turn, and folding must not flicker through that window. + * The session's running turn is authoritative when latestTurn briefly lags or + * regresses behind it. Otherwise, the latest turn counts as unsettled while it + * is still running (or has not recorded a completion). This is deliberately + * keyed on turn lifecycle rather than transient working state: right after the + * user sends a message, the previous turn is still the "active" one until the + * server creates the new turn, and folding must not flicker through that window. */ -function deriveUnsettledTurnId(latestTurn: TimelineLatestTurn | null): TurnId | null { +function deriveUnsettledTurnId( + latestTurn: TimelineLatestTurn | null, + runningTurnId: TurnId | null, +): TurnId | null { + if (runningTurnId !== null) { + return runningTurnId; + } if (!latestTurn) { return null; } @@ -291,6 +298,7 @@ function deriveTurnFolds(input: { export function deriveMessagesTimelineRows(input: { timelineEntries: ReadonlyArray; latestTurn?: TimelineLatestTurn | null; + runningTurnId?: TurnId | null; expandedTurnIds?: ReadonlySet; isWorking: boolean; activeTurnStartedAt: string | null; @@ -302,7 +310,10 @@ export function deriveMessagesTimelineRows(input: { input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), ); const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries); - const unsettledTurnId = deriveUnsettledTurnId(input.latestTurn ?? null); + const unsettledTurnId = deriveUnsettledTurnId( + input.latestTurn ?? null, + input.runningTurnId ?? null, + ); const foldsByAnchorEntryId = deriveTurnFolds({ timelineEntries: input.timelineEntries, terminalAssistantMessageIds, diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 3008bd2ba9e..a69ce17c7d2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -109,6 +109,7 @@ function buildProps() { activeTurnStartedAt: null, listRef: createRef(), latestTurn: null, + runningTurnId: null, turnDiffSummaryByAssistantMessageId: new Map(), routeThreadKey: "environment-local:thread-1", onOpenTurnDiff: () => {}, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 55c982c64be..cf8224d51f6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -153,6 +153,7 @@ interface MessagesTimelineProps { listRef: React.RefObject; timelineEntries: ReturnType; latestTurn: TimelineLatestTurn | null; + runningTurnId: TurnId | null; turnDiffSummaryByAssistantMessageId: Map; routeThreadKey: string; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; @@ -182,6 +183,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ listRef, timelineEntries, latestTurn, + runningTurnId, turnDiffSummaryByAssistantMessageId, routeThreadKey, onOpenTurnDiff, @@ -272,6 +274,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ deriveMessagesTimelineRows({ timelineEntries, latestTurn, + runningTurnId, expandedTurnIds, isWorking, activeTurnStartedAt, @@ -281,6 +284,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [ timelineEntries, latestTurn, + runningTurnId, expandedTurnIds, isWorking, activeTurnStartedAt, From 84404a87cd2ac6ea8fd5c7b23ff097c8e4196d6d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 25 Jun 2026 22:40:26 -0700 Subject: [PATCH 5/7] fix(grok): enforce the root ACP turn boundary Drop foreign child-session updates instead of flattening them into the root turn, drain accepted root events before terminal settlement, and remove the projection/UI bridge workarounds that overlap the V2 architecture. Co-authored-by: codex --- apps/server/scripts/acp-mock-agent.ts | 68 + .../Layers/ProjectionPipeline.test.ts | 508 +---- .../Layers/ProjectionPipeline.ts | 291 +-- .../Layers/ProviderRuntimeIngestion.ts | 41 +- .../src/orchestration/projector.test.ts | 1636 ++++------------- apps/server/src/orchestration/projector.ts | 142 +- .../src/provider/Layers/CursorAdapter.ts | 3 + .../src/provider/Layers/GrokAdapter.test.ts | 33 + .../server/src/provider/Layers/GrokAdapter.ts | 32 +- .../provider/acp/AcpJsonRpcConnection.test.ts | 44 + .../src/provider/acp/AcpSessionRuntime.ts | 36 +- apps/web/src/components/ChatMarkdown.tsx | 14 +- apps/web/src/components/ChatView.tsx | 10 - apps/web/src/components/Sidebar.logic.test.ts | 16 - apps/web/src/components/Sidebar.logic.ts | 3 +- apps/web/src/components/Sidebar.tsx | 23 +- .../src/components/ThreadStatusIndicators.tsx | 7 +- .../src/hooks/useRafThrottledValue.test.ts | 173 -- apps/web/src/hooks/useRafThrottledValue.ts | 52 - apps/web/src/session-logic.test.ts | 58 - apps/web/src/session-logic.ts | 12 +- apps/web/src/uiStateStore.ts | 28 - .../no-manual-effect-runtime-in-tests.ts | 2 +- .../src/state/threadReducer.test.ts | 353 +--- .../client-runtime/src/state/threadReducer.ts | 160 +- packages/shared/package.json | 4 - .../shared/src/orchestrationMessages.test.ts | 690 ------- packages/shared/src/orchestrationMessages.ts | 396 ---- 28 files changed, 728 insertions(+), 4107 deletions(-) delete mode 100644 apps/web/src/hooks/useRafThrottledValue.test.ts delete mode 100644 apps/web/src/hooks/useRafThrottledValue.ts delete mode 100644 packages/shared/src/orchestrationMessages.test.ts delete mode 100644 packages/shared/src/orchestrationMessages.ts diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index a5303eb1b09..bc7828dd854 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -20,6 +20,7 @@ const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHO const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1"; +const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1"; const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL === "1"; @@ -530,6 +531,16 @@ const program = Effect.gen(function* () { }, }); + if (emitForeignSessionUpdates) { + writeJsonRpcNotification("session/update", { + sessionId: "mock-child-session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "child before completion" }, + }, + }); + } + writeJsonRpcNotification("_x.ai/session/prompt_complete", { sessionId: requestedSessionId, promptId: promptIdFromRequestMeta(request) ?? "mock-xai-prompt-1", @@ -537,6 +548,27 @@ const program = Effect.gen(function* () { agentResult: null, }); + if (emitForeignSessionUpdates) { + writeJsonRpcNotification("session/update", { + sessionId: "mock-child-session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "child-tool-call-1", + title: "Child-only tool", + kind: "other", + status: "pending", + rawInput: {}, + }, + }); + writeJsonRpcNotification("session/update", { + sessionId: "mock-child-session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "child after completion" }, + }, + }); + } + writeJsonRpcNotification("session/update", { sessionId: requestedSessionId, update: { @@ -778,6 +810,42 @@ const program = Effect.gen(function* () { return { stopReason: "end_turn" }; } + if (emitForeignSessionUpdates) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "root before child" }, + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: "mock-child-session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "child content" }, + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: "mock-child-session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "child-tool-call-1", + title: "Child-only tool", + kind: "other", + status: "pending", + rawInput: {}, + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: " root after child" }, + }, + }); + return { stopReason: "end_turn" }; + } + yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, update: { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 0aed9d6afd6..0999000ed4f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -33,7 +33,6 @@ import { import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; -import { archivedAssistantSegmentMessageId } from "@t3tools/shared/orchestrationMessages"; import { ServerConfig } from "../../config.ts"; const makeProjectionPipelinePrefixedTestLayer = (prefix: string) => @@ -1233,338 +1232,6 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); - it.effect("archives replayed assistant rows on null-turn rebound in projection", () => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const threadId = ThreadId.make("thread-null-turn-rebind"); - const priorTurnId = TurnId.make("turn-replay"); - const segmentId = MessageId.make("assistant-segment-0"); - const now = "2026-06-25T02:00:00.000Z"; - - const appendAndProject = (event: Parameters[0]) => - eventStore - .append(event) - .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); - - yield* appendAndProject({ - type: "thread.created", - eventId: EventId.make("evt-ntr-1"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.make("cmd-ntr-1"), - causationEventId: null, - correlationId: CorrelationId.make("cmd-ntr-1"), - metadata: {}, - payload: { - threadId, - projectId: ProjectId.make("project-null-turn-rebind"), - title: "Null turn rebind", - modelSelection: { - instanceId: ProviderInstanceId.make("grok"), - model: "grok-composer-2.5-fast", - }, - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt: now, - updatedAt: now, - }, - }); - - yield* appendAndProject({ - type: "thread.message-sent", - eventId: EventId.make("evt-ntr-2"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.make("cmd-ntr-2"), - causationEventId: null, - correlationId: CorrelationId.make("cmd-ntr-2"), - metadata: {}, - payload: { - threadId, - messageId: segmentId, - role: "assistant", - text: "Replayed response.", - turnId: priorTurnId, - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); - - yield* appendAndProject({ - type: "thread.message-sent", - eventId: EventId.make("evt-ntr-3"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: "2026-06-25T02:00:01.000Z", - commandId: CommandId.make("cmd-ntr-3"), - causationEventId: null, - correlationId: CorrelationId.make("cmd-ntr-3"), - metadata: {}, - payload: { - threadId, - messageId: segmentId, - role: "assistant", - text: "New ", - turnId: null, - streaming: true, - createdAt: "2026-06-25T02:00:01.000Z", - updatedAt: "2026-06-25T02:00:01.000Z", - }, - }); - - const archivedMessageId = MessageId.make( - archivedAssistantSegmentMessageId(segmentId, priorTurnId), - ); - const messageRows = yield* sql<{ readonly messageId: string; readonly text: string }>` - SELECT message_id AS "messageId", text - FROM projection_thread_messages - WHERE thread_id = ${threadId} - ORDER BY created_at ASC, message_id ASC - `; - assert.deepEqual(messageRows, [ - { messageId: archivedMessageId, text: "Replayed response." }, - { messageId: segmentId, text: "New " }, - ]); - }), - ); - - it.effect( - "accumulates rebound assistant segment streaming text across turn changes in projection", - () => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const threadId = ThreadId.make("thread-segment-rebind"); - const priorTurnId = TurnId.make("turn-prior"); - const nextTurnId = TurnId.make("turn-next"); - const segmentId = MessageId.make( - "assistant:assistant:019efa67-b48b-7022-b4c0-0cba45dfa83d:segment:0", - ); - const now = "2026-06-25T01:26:00.000Z"; - - const appendAndProject = (event: Parameters[0]) => - eventStore - .append(event) - .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); - - yield* appendAndProject({ - type: "thread.created", - eventId: EventId.make("evt-sr-1"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.make("cmd-sr-1"), - causationEventId: null, - correlationId: CorrelationId.make("cmd-sr-1"), - metadata: {}, - payload: { - threadId, - projectId: ProjectId.make("project-segment-rebind"), - title: "Segment rebind", - modelSelection: { - instanceId: ProviderInstanceId.make("grok"), - model: "grok-composer-2.5-fast", - }, - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt: now, - updatedAt: now, - }, - }); - - yield* appendAndProject({ - type: "thread.message-sent", - eventId: EventId.make("evt-sr-2"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.make("cmd-sr-2"), - causationEventId: null, - correlationId: CorrelationId.make("cmd-sr-2"), - metadata: {}, - payload: { - threadId, - messageId: segmentId, - role: "assistant", - text: "Old replayed response.", - turnId: priorTurnId, - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); - - yield* appendAndProject({ - type: "thread.session-set", - eventId: EventId.make("evt-sr-session"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: "2026-06-25T01:26:49.000Z", - commandId: CommandId.make("cmd-sr-session"), - causationEventId: null, - correlationId: CorrelationId.make("cmd-sr-session"), - metadata: {}, - payload: { - threadId, - session: { - threadId, - status: "running", - providerName: "grok", - runtimeMode: "full-access", - activeTurnId: nextTurnId, - lastError: null, - updatedAt: "2026-06-25T01:26:49.000Z", - }, - }, - }); - - yield* appendAndProject({ - type: "thread.message-sent", - eventId: EventId.make("evt-sr-3-0"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: "2026-06-25T01:26:49.524Z", - commandId: CommandId.make("cmd-sr-3-0"), - causationEventId: null, - correlationId: CorrelationId.make("cmd-sr-3-0"), - metadata: {}, - payload: { - threadId, - messageId: segmentId, - role: "assistant", - text: "Done", - turnId: nextTurnId, - streaming: true, - createdAt: "2026-06-25T01:26:49.524Z", - updatedAt: "2026-06-25T01:26:49.524Z", - }, - }); - - yield* appendAndProject({ - type: "thread.message-sent", - eventId: EventId.make("evt-sr-3-complete-early"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: "2026-06-25T01:26:49.530Z", - commandId: CommandId.make("cmd-sr-3-complete-early"), - causationEventId: null, - correlationId: CorrelationId.make("cmd-sr-3-complete-early"), - metadata: {}, - payload: { - threadId, - messageId: segmentId, - role: "assistant", - text: "", - turnId: nextTurnId, - streaming: false, - createdAt: "2026-06-25T01:26:49.530Z", - updatedAt: "2026-06-25T01:26:49.530Z", - }, - }); - - const deltas = [" —", " same", " listing", "."] as const; - for (const [index, delta] of deltas.entries()) { - yield* appendAndProject({ - type: "thread.message-sent", - eventId: EventId.make(`evt-sr-3-${index + 1}`), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: `2026-06-25T01:26:49.${540 + index}Z`, - commandId: CommandId.make(`cmd-sr-3-${index + 1}`), - causationEventId: null, - correlationId: CorrelationId.make(`cmd-sr-3-${index + 1}`), - metadata: {}, - payload: { - threadId, - messageId: segmentId, - role: "assistant", - text: delta, - turnId: nextTurnId, - streaming: true, - createdAt: `2026-06-25T01:26:49.${540 + index}Z`, - updatedAt: `2026-06-25T01:26:49.${540 + index}Z`, - }, - }); - } - - yield* appendAndProject({ - type: "thread.message-sent", - eventId: EventId.make("evt-sr-4"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: "2026-06-25T01:26:49.949Z", - commandId: CommandId.make("cmd-sr-4"), - causationEventId: null, - correlationId: CorrelationId.make("cmd-sr-4"), - metadata: {}, - payload: { - threadId, - messageId: segmentId, - role: "assistant", - text: "", - turnId: nextTurnId, - streaming: false, - createdAt: "2026-06-25T01:26:49.949Z", - updatedAt: "2026-06-25T01:26:49.949Z", - }, - }); - - const messageRows = yield* sql<{ readonly text: string }>` - SELECT text FROM projection_thread_messages WHERE message_id = ${segmentId} - `; - assert.deepEqual(messageRows, [{ text: "Done — same listing." }]); - - const archivedAssistantMessageId = MessageId.make( - archivedAssistantSegmentMessageId(segmentId, priorTurnId), - ); - const priorTurnRows = yield* sql<{ readonly assistantMessageId: string | null }>` - SELECT assistant_message_id AS "assistantMessageId" - FROM projection_turns - WHERE thread_id = ${threadId} AND turn_id = ${priorTurnId} - `; - assert.deepEqual(priorTurnRows, [{ assistantMessageId: archivedAssistantMessageId }]); - - yield* appendAndProject({ - type: "thread.message-sent", - eventId: EventId.make("evt-sr-stale-prior"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: "2026-06-25T01:26:50.000Z", - commandId: CommandId.make("cmd-sr-stale-prior"), - causationEventId: null, - correlationId: CorrelationId.make("cmd-sr-stale-prior"), - metadata: {}, - payload: { - threadId, - messageId: segmentId, - role: "assistant", - text: " stale", - turnId: priorTurnId, - streaming: true, - createdAt: "2026-06-25T01:26:50.000Z", - updatedAt: "2026-06-25T01:26:50.000Z", - }, - }); - - const priorTurnRowsAfterStale = yield* sql<{ readonly assistantMessageId: string | null }>` - SELECT assistant_message_id AS "assistantMessageId" - FROM projection_turns - WHERE thread_id = ${threadId} AND turn_id = ${priorTurnId} - `; - assert.deepEqual(priorTurnRowsAfterStale, [ - { assistantMessageId: archivedAssistantMessageId }, - ]); - }), - ); - it.effect("keeps the turn running across interim assistant messages until the session ends", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; @@ -1653,60 +1320,23 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { const runningRows = yield* sql<{ readonly state: string; readonly completedAt: string | null; - readonly assistantMessageId: string | null; }>` - SELECT - state, - completed_at AS "completedAt", - assistant_message_id AS "assistantMessageId" - FROM projection_turns - WHERE thread_id = ${threadId} AND turn_id = ${turnId} - `; - assert.deepEqual(runningRows, [ - { state: "running", completedAt: null, assistantMessageId: "message-tl-interim" }, - ]); - - yield* eventStore.append({ - type: "thread.message-sent", - eventId: EventId.make("evt-tl4"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: "2026-01-01T00:00:10.000Z", - commandId: CommandId.make("cmd-tl4"), - causationEventId: null, - correlationId: CorrelationId.make("cmd-tl4"), - metadata: {}, - payload: { - threadId, - messageId: MessageId.make("message-tl-latest"), - role: "assistant", - text: "later commentary", - turnId, - streaming: false, - createdAt: "2026-01-01T00:00:10.000Z", - updatedAt: "2026-01-01T00:00:10.000Z", - }, - }); - - yield* projectionPipeline.bootstrap; - - const latestMessageRows = yield* sql<{ readonly assistantMessageId: string | null }>` - SELECT assistant_message_id AS "assistantMessageId" + SELECT state, completed_at AS "completedAt" FROM projection_turns WHERE thread_id = ${threadId} AND turn_id = ${turnId} `; - assert.deepEqual(latestMessageRows, [{ assistantMessageId: "message-tl-latest" }]); + assert.deepEqual(runningRows, [{ state: "running", completedAt: null }]); // The session leaving "running" is the turn-end signal. yield* eventStore.append({ type: "thread.session-set", - eventId: EventId.make("evt-tl5"), + eventId: EventId.make("evt-tl4"), aggregateKind: "thread", aggregateId: threadId, occurredAt: "2026-01-01T00:01:00.000Z", - commandId: CommandId.make("cmd-tl5"), + commandId: CommandId.make("cmd-tl4"), causationEventId: null, - correlationId: CorrelationId.make("cmd-tl5"), + correlationId: CorrelationId.make("cmd-tl4"), metadata: {}, payload: { threadId, @@ -2899,134 +2529,6 @@ it.effect("restores pending turn-start metadata across projection pipeline resta ), ); -it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-bootstrap-order-")))( - "OrchestrationProjectionPipeline", - (it) => { - it.effect("replays bootstrap events in order across projectors", () => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const threadId = ThreadId.make("thread-bootstrap-order"); - const turnId = TurnId.make("turn-bootstrap-order"); - const messageId = MessageId.make("message-bootstrap-order"); - const createdAt = "2026-06-25T03:00:00.000Z"; - const runningAt = "2026-06-25T03:00:01.000Z"; - const messageAt = "2026-06-25T03:00:02.000Z"; - const completedAt = "2026-06-25T03:00:10.000Z"; - - yield* eventStore.append({ - type: "thread.created", - eventId: EventId.make("evt-bootstrap-order-1"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: createdAt, - commandId: CommandId.make("cmd-bootstrap-order-1"), - causationEventId: null, - correlationId: CorrelationId.make("cmd-bootstrap-order-1"), - metadata: {}, - payload: { - threadId, - projectId: ProjectId.make("project-bootstrap-order"), - title: "Bootstrap order", - modelSelection: { - instanceId: ProviderInstanceId.make("grok"), - model: "grok-build", - }, - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt, - updatedAt: createdAt, - }, - }); - - yield* eventStore.append({ - type: "thread.session-set", - eventId: EventId.make("evt-bootstrap-order-2"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: runningAt, - commandId: CommandId.make("cmd-bootstrap-order-2"), - causationEventId: null, - correlationId: CorrelationId.make("cmd-bootstrap-order-2"), - metadata: {}, - payload: { - threadId, - session: { - threadId, - status: "running", - providerName: "grok", - runtimeMode: "full-access", - activeTurnId: turnId, - lastError: null, - updatedAt: runningAt, - }, - }, - }); - - yield* eventStore.append({ - type: "thread.message-sent", - eventId: EventId.make("evt-bootstrap-order-3"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: messageAt, - commandId: CommandId.make("cmd-bootstrap-order-3"), - causationEventId: null, - correlationId: CorrelationId.make("cmd-bootstrap-order-3"), - metadata: {}, - payload: { - threadId, - messageId, - role: "assistant", - text: "complete", - turnId, - streaming: false, - createdAt: messageAt, - updatedAt: messageAt, - }, - }); - - yield* eventStore.append({ - type: "thread.session-set", - eventId: EventId.make("evt-bootstrap-order-4"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: completedAt, - commandId: CommandId.make("cmd-bootstrap-order-4"), - causationEventId: null, - correlationId: CorrelationId.make("cmd-bootstrap-order-4"), - metadata: {}, - payload: { - threadId, - session: { - threadId, - status: "ready", - providerName: "grok", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: completedAt, - }, - }, - }); - - yield* projectionPipeline.bootstrap; - - const rows = yield* sql<{ - readonly state: string; - readonly completedAt: string | null; - }>` - SELECT state, completed_at AS "completedAt" - FROM projection_turns - WHERE thread_id = ${threadId} AND turn_id = ${turnId} - `; - assert.deepEqual(rows, [{ state: "completed", completedAt }]); - }), - ); - }, -); - const engineLayer = it.layer( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index d41dc61fff7..f12df850941 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1,7 +1,6 @@ import { ApprovalRequestId, type ChatAttachment, - MessageId, type OrchestrationEvent, type OrchestrationSessionStatus, ThreadId, @@ -13,18 +12,6 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { - archivedAssistantSegmentMessageId, - archivedAssistantSegmentTurnIds, - assistantSegmentBelongsToActiveTurn, - assistantSegmentRebindArchives, - assistantSegmentStreamingTextResets, - assistantSegmentTimelineAnchorResets, - assistantSegmentTurnChanged, - isLateAssistantSegmentFromPriorTurn, - isLateStreamingOnCompletedAssistant, - resolveAssistantSegmentText, -} from "@t3tools/shared/orchestrationMessages"; import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; @@ -829,171 +816,33 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti messageId: event.payload.messageId, }); const previousMessage = Option.getOrUndefined(existingMessage); - const previousAssistantSegment = previousMessage - ? { - role: previousMessage.role, - streaming: previousMessage.isStreaming, - turnId: previousMessage.turnId, + const nextText = Option.match(existingMessage, { + onNone: () => event.payload.text, + onSome: (message) => { + if (event.payload.streaming) { + return `${message.text}${event.payload.text}`; } - : undefined; - const session = yield* projectionThreadSessionRepository.getByThreadId({ - threadId: event.payload.threadId, - }); - const activeTurnId = Option.isSome(session) ? session.value.activeTurnId : null; - const turnStillActive = - Option.isSome(session) && - session.value.status === "running" && - assistantSegmentBelongsToActiveTurn({ - activeTurnId, - existingTurnId: previousAssistantSegment?.turnId, - incomingTurnId: event.payload.turnId, - }); - const incomingAssistantSegment = { - role: event.payload.role, - streaming: event.payload.streaming, - turnId: event.payload.turnId, - }; - const threadMessages = yield* projectionThreadMessageRepository.listByThreadId({ - threadId: event.payload.threadId, - }); - const archivedTurnIds = archivedAssistantSegmentTurnIds( - threadMessages.map((message) => ({ - id: message.messageId, - turnId: message.turnId, - })), - event.payload.messageId, - ); - if ( - isLateAssistantSegmentFromPriorTurn({ - existing: previousAssistantSegment, - incoming: incomingAssistantSegment, - providerMessageId: event.payload.messageId, - archivedTurnIds, - turnStillActive: turnStillActive === true, - }) || - isLateStreamingOnCompletedAssistant({ - existing: previousAssistantSegment, - incoming: incomingAssistantSegment, - turnStillActive: turnStillActive === true, - }) - ) { - return; - } - const turnChanged = assistantSegmentTurnChanged(previousAssistantSegment, { - turnId: event.payload.turnId, - }); - const rebindArchives = assistantSegmentRebindArchives( - previousAssistantSegment, - { - streaming: event.payload.streaming, - turnId: event.payload.turnId, - }, - { - activeTurnId, - turnStillActive: turnStillActive === true, - }, - ); - const shouldArchive = turnChanged || rebindArchives; - const textResets = assistantSegmentStreamingTextResets( - previousAssistantSegment, - { - streaming: event.payload.streaming, - turnId: event.payload.turnId, - }, - { - activeTurnId, - turnStillActive: turnStillActive === true, - }, - ); - const timelineAnchorResets = assistantSegmentTimelineAnchorResets( - previousAssistantSegment, - { - streaming: event.payload.streaming, - turnId: event.payload.turnId, - }, - { - activeTurnId, - turnStillActive: turnStillActive === true, - }, - ); - const nextText = resolveAssistantSegmentText( - previousMessage, - { - text: event.payload.text, - streaming: event.payload.streaming, + if (event.payload.text.length === 0) { + return message.text; + } + return event.payload.text; }, - textResets, - ); - const materializedAttachments = + }); + const nextAttachments = event.payload.attachments !== undefined ? yield* materializeAttachmentsForProjection({ attachments: event.payload.attachments, }) - : undefined; - const attachmentFields = - materializedAttachments !== undefined - ? { attachments: [...materializedAttachments] } - : shouldArchive - ? { attachments: [] as ProjectionThreadMessage["attachments"] } - : previousMessage?.attachments !== undefined - ? { attachments: [...previousMessage.attachments] } - : {}; - - if (shouldArchive && previousMessage !== undefined) { - const archivedTurnId = previousMessage.turnId; - yield* projectionThreadMessageRepository.upsert({ - messageId: MessageId.make( - archivedAssistantSegmentMessageId( - event.payload.messageId, - archivedTurnId, - previousMessage.createdAt, - ), - ), - threadId: event.payload.threadId, - turnId: archivedTurnId, - role: previousMessage.role, - text: previousMessage.text, - ...(previousMessage.attachments !== undefined - ? { attachments: [...previousMessage.attachments] } - : {}), - isStreaming: false, - createdAt: previousMessage.createdAt, - updatedAt: previousMessage.updatedAt, - }); - if (archivedTurnId !== null) { - const archivedTurn = yield* projectionTurnRepository.getByTurnId({ - threadId: event.payload.threadId, - turnId: archivedTurnId, - }); - if ( - Option.isSome(archivedTurn) && - archivedTurn.value.assistantMessageId === event.payload.messageId - ) { - yield* projectionTurnRepository.upsertByTurnId({ - ...archivedTurn.value, - assistantMessageId: MessageId.make( - archivedAssistantSegmentMessageId( - event.payload.messageId, - archivedTurnId, - previousMessage.createdAt, - ), - ), - }); - } - } - } - + : previousMessage?.attachments; yield* projectionThreadMessageRepository.upsert({ messageId: event.payload.messageId, threadId: event.payload.threadId, turnId: event.payload.turnId, role: event.payload.role, text: nextText, - ...attachmentFields, + ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), isStreaming: event.payload.streaming, - createdAt: timelineAnchorResets - ? event.payload.createdAt - : (previousMessage?.createdAt ?? event.payload.createdAt), + createdAt: previousMessage?.createdAt ?? event.payload.createdAt, updatedAt: event.payload.updatedAt, }); return; @@ -1305,22 +1154,11 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (event.payload.turnId === null || event.payload.role !== "assistant") { return; } - const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({ - messageId: event.payload.messageId, - }); - const previousMessage = Option.getOrUndefined(existingMessage); - const previousAssistantSegment = previousMessage - ? { - role: previousMessage.role, - streaming: previousMessage.isStreaming, - turnId: previousMessage.turnId, - } - : undefined; - const incomingAssistantSegment = { - role: event.payload.role, - streaming: event.payload.streaming, - turnId: event.payload.turnId, - }; + // A completed assistant message only settles the turn once the + // session is no longer running it — providers may emit several + // assistant messages per turn (commentary between tool calls), and + // the turn must stay unsettled until the provider reports turn end + // (projected as thread.session-set leaving the "running" status). const session = yield* projectionThreadSessionRepository.getByThreadId({ threadId: event.payload.threadId, }); @@ -1328,37 +1166,6 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti Option.isSome(session) && session.value.status === "running" && session.value.activeTurnId === event.payload.turnId; - const threadMessages = yield* projectionThreadMessageRepository.listByThreadId({ - threadId: event.payload.threadId, - }); - const archivedTurnIds = archivedAssistantSegmentTurnIds( - threadMessages.map((message) => ({ - id: message.messageId, - turnId: message.turnId, - })), - event.payload.messageId, - ); - if ( - isLateAssistantSegmentFromPriorTurn({ - existing: previousAssistantSegment, - incoming: incomingAssistantSegment, - providerMessageId: event.payload.messageId, - archivedTurnIds, - turnStillActive: turnStillRunning, - }) || - isLateStreamingOnCompletedAssistant({ - existing: previousAssistantSegment, - incoming: incomingAssistantSegment, - turnStillActive: turnStillRunning, - }) - ) { - return; - } - // A completed assistant message only settles the turn once the - // session is no longer running it — providers may emit several - // assistant messages per turn (commentary between tool calls), and - // the turn must stay unsettled until the provider reports turn end - // (projected as thread.session-set leaving the "running" status). const settlesTurn = !event.payload.streaming && !turnStillRunning; const existingTurn = yield* projectionTurnRepository.getByTurnId({ threadId: event.payload.threadId, @@ -1657,10 +1464,6 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti name: ORCHESTRATION_PROJECTOR_NAMES.projects, apply: applyProjectsProjection, }, - { - name: ORCHESTRATION_PROJECTOR_NAMES.threadSessions, - apply: applyThreadSessionsProjection, - }, { name: ORCHESTRATION_PROJECTOR_NAMES.threadMessages, apply: applyThreadMessagesProjection, @@ -1673,6 +1476,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti name: ORCHESTRATION_PROJECTOR_NAMES.threadActivities, apply: applyThreadActivitiesProjection, }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.threadSessions, + apply: applyThreadSessionsProjection, + }, { name: ORCHESTRATION_PROJECTOR_NAMES.threadTurns, apply: applyThreadTurnsProjection, @@ -1724,6 +1531,22 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ); }); + const bootstrapProjector = (projector: ProjectorDefinition) => + projectionStateRepository + .getByProjector({ + projector: projector.name, + }) + .pipe( + Effect.flatMap((stateRow) => + Stream.runForEach( + eventStore.readFromSequence( + Option.isSome(stateRow) ? stateRow.value.lastAppliedSequence : 0, + ), + (event) => runProjectorForEvent(projector, event), + ), + ), + ); + const projectEvent: OrchestrationProjectionPipelineShape["projectEvent"] = (event) => Effect.forEach(projectors, (projector) => runProjectorForEvent(projector, event), { concurrency: 1, @@ -1737,37 +1560,11 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ), ); - const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.gen(function* () { - const stateRows = yield* projectionStateRepository.listAll(); - const lastAppliedByProjector = new Map( - stateRows.map((row) => [row.projector, row.lastAppliedSequence] as const), - ); - const minLastAppliedSequence = projectors.reduce( - (minimum, projector) => Math.min(minimum, lastAppliedByProjector.get(projector.name) ?? 0), - Number.POSITIVE_INFINITY, - ); - const readFromSequence = Number.isFinite(minLastAppliedSequence) ? minLastAppliedSequence : 0; - - yield* Stream.runForEach(eventStore.readFromSequence(readFromSequence), (event) => - Effect.forEach( - projectors, - (projector) => { - const lastAppliedSequence = lastAppliedByProjector.get(projector.name) ?? 0; - if (lastAppliedSequence >= event.sequence) { - return Effect.void; - } - return runProjectorForEvent(projector, event).pipe( - Effect.tap(() => - Effect.sync(() => { - lastAppliedByProjector.set(projector.name, event.sequence); - }), - ), - ); - }, - { concurrency: 1 }, - ), - ); - }).pipe( + const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.forEach( + projectors, + bootstrapProjector, + { concurrency: 1 }, + ).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), Effect.provideService(ServerConfig, serverConfig), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index d16d918409c..3e5978f4846 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1545,29 +1545,24 @@ const make = Effect.gen(function* () { const proposedPlans = detailedThread?.proposedPlans ?? []; const turnId = toTurnId(event.turnId); if (turnId) { - // Grok can emit turn.completed while assistant segments are still - // streaming. Defer assistant finalization to item.completed so - // trailing chunks keep appending to the active segment. - if (event.provider !== "grok") { - const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId); - yield* Effect.forEach( - assistantMessageIds, - (assistantMessageId) => - finalizeAssistantMessage({ - event, - threadId: thread.id, - messageId: assistantMessageId, - turnId, - createdAt: now, - commandTag: "assistant-complete-finalize", - finalDeltaCommandTag: "assistant-delta-finalize-fallback", - hasProjectedMessage: findMessageById(messages, assistantMessageId) !== undefined, - }), - { concurrency: 1 }, - ).pipe(Effect.asVoid); - yield* clearAssistantMessageIdsForTurn(thread.id, turnId); - yield* clearAssistantSegmentStateForTurn(thread.id, turnId); - } + const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId); + yield* Effect.forEach( + assistantMessageIds, + (assistantMessageId) => + finalizeAssistantMessage({ + event, + threadId: thread.id, + messageId: assistantMessageId, + turnId, + createdAt: now, + commandTag: "assistant-complete-finalize", + finalDeltaCommandTag: "assistant-delta-finalize-fallback", + hasProjectedMessage: findMessageById(messages, assistantMessageId) !== undefined, + }), + { concurrency: 1 }, + ).pipe(Effect.asVoid); + yield* clearAssistantMessageIdsForTurn(thread.id, turnId); + yield* clearAssistantSegmentStateForTurn(thread.id, turnId); yield* finalizeBufferedProposedPlan({ event, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 0fa81c14f41..fadd5078026 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -1,14 +1,11 @@ import { CommandId, EventId, - MessageId, ProjectId, ProviderDriverKind, ThreadId, - TurnId, type OrchestrationEvent, } from "@t3tools/contracts"; -import { it as effectIt } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { describe, expect, it } from "vite-plus/test"; @@ -487,11 +484,8 @@ describe("orchestration projector", () => { expect(message?.updatedAt).toBe(completeAt); }); - it("appends trailing assistant deltas for the same turn after session settles", async () => { - const createdAt = "2026-06-25T18:26:53.000Z"; - const deltaAt = "2026-06-25T18:27:58.000Z"; - const completeAt = "2026-06-25T18:28:10.714Z"; - const trailingAt = "2026-06-25T18:28:10.747Z"; + it("prunes reverted turn messages from in-memory thread snapshot", async () => { + const createdAt = "2026-02-23T10:00:00.000Z"; const model = createEmptyReadModel(createdAt); const afterCreate = await Effect.runPromise( @@ -509,8 +503,8 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", modelSelection: { - provider: ProviderDriverKind.make("grok"), - model: "grok-3", + provider: ProviderDriverKind.make("codex"), + model: "gpt-5.3-codex", }, runtimeMode: "full-access", branch: null, @@ -522,153 +516,205 @@ describe("orchestration projector", () => { ), ); - const afterSessionRunning = await Effect.runPromise( - projectEvent( - afterCreate, - makeEvent({ - sequence: 2, - type: "thread.session-set", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: deltaAt, - commandId: "cmd-session-running", - payload: { - threadId: "thread-1", - session: { - threadId: "thread-1", - status: "running", - providerName: ProviderDriverKind.make("grok"), - runtimeMode: "full-access", - activeTurnId: "turn-1", - lastError: null, - updatedAt: deltaAt, - }, - }, - }), - ), - ); - - const afterDelta = await Effect.runPromise( - projectEvent( - afterSessionRunning, - makeEvent({ - sequence: 3, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: deltaAt, - commandId: "cmd-delta", - payload: { - threadId: "thread-1", - messageId: "assistant:msg-1", - role: "assistant", - text: "section 7 starts here", + const events: ReadonlyArray = [ + makeEvent({ + sequence: 2, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-02-23T10:00:01.000Z", + commandId: "cmd-user-1", + payload: { + threadId: "thread-1", + messageId: "user-msg-1", + role: "user", + text: "First edit", + turnId: null, + streaming: false, + createdAt: "2026-02-23T10:00:01.000Z", + updatedAt: "2026-02-23T10:00:01.000Z", + }, + }), + makeEvent({ + sequence: 3, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-02-23T10:00:02.000Z", + commandId: "cmd-assistant-1", + payload: { + threadId: "thread-1", + messageId: "assistant-msg-1", + role: "assistant", + text: "Updated README to v2.\n", + turnId: "turn-1", + streaming: false, + createdAt: "2026-02-23T10:00:02.000Z", + updatedAt: "2026-02-23T10:00:02.000Z", + }, + }), + makeEvent({ + sequence: 4, + type: "thread.turn-diff-completed", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-02-23T10:00:02.500Z", + commandId: "cmd-turn-1-complete", + payload: { + threadId: "thread-1", + turnId: "turn-1", + checkpointTurnCount: 1, + checkpointRef: "refs/t3/checkpoints/thread-1/turn/1", + status: "ready", + files: [], + assistantMessageId: "assistant-msg-1", + completedAt: "2026-02-23T10:00:02.500Z", + }, + }), + makeEvent({ + sequence: 5, + type: "thread.activity-appended", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-02-23T10:00:02.750Z", + commandId: "cmd-activity-1", + payload: { + threadId: "thread-1", + activity: { + id: "activity-1", + tone: "tool", + kind: "tool.started", + summary: "Edit file started", + payload: { toolKind: "command" }, turnId: "turn-1", - streaming: true, - createdAt: deltaAt, - updatedAt: deltaAt, + createdAt: "2026-02-23T10:00:02.750Z", }, - }), - ), - ); - - const afterComplete = await Effect.runPromise( - projectEvent( - afterDelta, - makeEvent({ - sequence: 4, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: completeAt, - commandId: "cmd-complete", - payload: { - threadId: "thread-1", - messageId: "assistant:msg-1", - role: "assistant", - text: "", - turnId: "turn-1", - streaming: false, - createdAt: completeAt, - updatedAt: completeAt, + }, + }), + makeEvent({ + sequence: 6, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-02-23T10:00:03.000Z", + commandId: "cmd-user-2", + payload: { + threadId: "thread-1", + messageId: "user-msg-2", + role: "user", + text: "Second edit", + turnId: null, + streaming: false, + createdAt: "2026-02-23T10:00:03.000Z", + updatedAt: "2026-02-23T10:00:03.000Z", + }, + }), + makeEvent({ + sequence: 7, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-02-23T10:00:04.000Z", + commandId: "cmd-assistant-2", + payload: { + threadId: "thread-1", + messageId: "assistant-msg-2", + role: "assistant", + text: "Updated README to v3.\n", + turnId: "turn-2", + streaming: false, + createdAt: "2026-02-23T10:00:04.000Z", + updatedAt: "2026-02-23T10:00:04.000Z", + }, + }), + makeEvent({ + sequence: 8, + type: "thread.turn-diff-completed", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-02-23T10:00:04.500Z", + commandId: "cmd-turn-2-complete", + payload: { + threadId: "thread-1", + turnId: "turn-2", + checkpointTurnCount: 2, + checkpointRef: "refs/t3/checkpoints/thread-1/turn/2", + status: "ready", + files: [], + assistantMessageId: "assistant-msg-2", + completedAt: "2026-02-23T10:00:04.500Z", + }, + }), + makeEvent({ + sequence: 9, + type: "thread.activity-appended", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-02-23T10:00:04.750Z", + commandId: "cmd-activity-2", + payload: { + threadId: "thread-1", + activity: { + id: "activity-2", + tone: "tool", + kind: "tool.completed", + summary: "Edit file complete", + payload: { toolKind: "command" }, + turnId: "turn-2", + createdAt: "2026-02-23T10:00:04.750Z", }, - }), - ), - ); + }, + }), + makeEvent({ + sequence: 10, + type: "thread.reverted", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-02-23T10:00:05.000Z", + commandId: "cmd-revert", + payload: { + threadId: "thread-1", + turnCount: 1, + }, + }), + ]; - const afterSessionReady = await Effect.runPromise( - projectEvent( - afterComplete, - makeEvent({ - sequence: 5, - type: "thread.session-set", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: completeAt, - commandId: "cmd-session-ready", - payload: { - threadId: "thread-1", - session: { - threadId: "thread-1", - status: "ready", - providerName: ProviderDriverKind.make("grok"), - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: completeAt, - }, - }, - }), - ), + const afterRevert = await events.reduce>>( + (statePromise, event) => + statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), + Promise.resolve(afterCreate), ); - const afterTrailingDelta = await Effect.runPromise( - projectEvent( - afterSessionReady, - makeEvent({ - sequence: 6, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: trailingAt, - commandId: "cmd-trailing-delta", - payload: { - threadId: "thread-1", - messageId: "assistant:msg-1", - role: "assistant", - text: " and the rest of section 7", - turnId: "turn-1", - streaming: true, - createdAt: trailingAt, - updatedAt: trailingAt, - }, - }), - ), + const thread = afterRevert.threads[0]; + expect(thread?.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual( + [ + { role: "user", text: "First edit" }, + { role: "assistant", text: "Updated README to v2.\n" }, + ], ); - - const message = afterTrailingDelta.threads[0]?.messages[0]; - expect(message?.text).toBe("section 7 starts here and the rest of section 7"); - expect(message?.streaming).toBe(true); + expect( + thread?.activities.map((activity) => ({ id: activity.id, turnId: activity.turnId })), + ).toEqual([{ id: "activity-1", turnId: "turn-1" }]); + expect(thread?.checkpoints.map((checkpoint) => checkpoint.checkpointTurnCount)).toEqual([1]); + expect(thread?.latestTurn?.turnId).toBe("turn-1"); }); - effectIt.effect("preserves latestTurn completion time across later assistant messages", () => - Effect.gen(function* () { - const createdAt = "2026-02-23T09:10:00.000Z"; - const completeAt = "2026-02-23T09:10:03.500Z"; - const laterAt = "2026-02-23T09:10:05.000Z"; - const model = createEmptyReadModel(createdAt); + it("does not fallback-retain messages tied to removed turn IDs", async () => { + const createdAt = "2026-02-26T12:00:00.000Z"; + const model = createEmptyReadModel(createdAt); - const afterCreate = yield* projectEvent( + const afterCreate = await Effect.runPromise( + projectEvent( model, makeEvent({ sequence: 1, type: "thread.created", aggregateKind: "thread", - aggregateId: "thread-1", + aggregateId: "thread-revert", occurredAt: createdAt, - commandId: "cmd-create", + commandId: "cmd-create-revert", payload: { - threadId: "thread-1", + threadId: "thread-revert", projectId: "project-1", title: "demo", modelSelection: { @@ -682,106 +728,151 @@ describe("orchestration projector", () => { updatedAt: createdAt, }, }), - ); - - const afterFirstMessage = yield* projectEvent( - afterCreate, - makeEvent({ - sequence: 2, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: completeAt, - commandId: "cmd-complete", - payload: { - threadId: "thread-1", - messageId: "assistant:msg-1", - role: "assistant", - text: "first", - turnId: "turn-1", - streaming: false, - createdAt: completeAt, - updatedAt: completeAt, - }, - }), - ); + ), + ); - const afterCheckpoint = yield* projectEvent( - afterFirstMessage, - makeEvent({ - sequence: 3, - type: "thread.turn-diff-completed", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: completeAt, - commandId: "cmd-checkpoint", - payload: { - threadId: "thread-1", - turnId: "turn-1", - checkpointTurnCount: 1, - checkpointRef: "refs/t3/checkpoints/thread-1/turn/1", - status: "ready", - files: [], - assistantMessageId: "assistant:msg-1", - completedAt: completeAt, - }, - }), - ); + const events: ReadonlyArray = [ + makeEvent({ + sequence: 2, + type: "thread.turn-diff-completed", + aggregateKind: "thread", + aggregateId: "thread-revert", + occurredAt: "2026-02-26T12:00:01.000Z", + commandId: "cmd-turn-1", + payload: { + threadId: "thread-revert", + turnId: "turn-1", + checkpointTurnCount: 1, + checkpointRef: "refs/t3/checkpoints/thread-revert/turn/1", + status: "ready", + files: [], + assistantMessageId: "assistant-keep", + completedAt: "2026-02-26T12:00:01.000Z", + }, + }), + makeEvent({ + sequence: 3, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-revert", + occurredAt: "2026-02-26T12:00:01.100Z", + commandId: "cmd-assistant-keep", + payload: { + threadId: "thread-revert", + messageId: "assistant-keep", + role: "assistant", + text: "kept", + turnId: "turn-1", + streaming: false, + createdAt: "2026-02-26T12:00:01.100Z", + updatedAt: "2026-02-26T12:00:01.100Z", + }, + }), + makeEvent({ + sequence: 4, + type: "thread.turn-diff-completed", + aggregateKind: "thread", + aggregateId: "thread-revert", + occurredAt: "2026-02-26T12:00:02.000Z", + commandId: "cmd-turn-2", + payload: { + threadId: "thread-revert", + turnId: "turn-2", + checkpointTurnCount: 2, + checkpointRef: "refs/t3/checkpoints/thread-revert/turn/2", + status: "ready", + files: [], + assistantMessageId: "assistant-remove", + completedAt: "2026-02-26T12:00:02.000Z", + }, + }), + makeEvent({ + sequence: 5, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-revert", + occurredAt: "2026-02-26T12:00:02.050Z", + commandId: "cmd-user-remove", + payload: { + threadId: "thread-revert", + messageId: "user-remove", + role: "user", + text: "removed", + turnId: "turn-2", + streaming: false, + createdAt: "2026-02-26T12:00:02.050Z", + updatedAt: "2026-02-26T12:00:02.050Z", + }, + }), + makeEvent({ + sequence: 6, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-revert", + occurredAt: "2026-02-26T12:00:02.100Z", + commandId: "cmd-assistant-remove", + payload: { + threadId: "thread-revert", + messageId: "assistant-remove", + role: "assistant", + text: "removed", + turnId: "turn-2", + streaming: false, + createdAt: "2026-02-26T12:00:02.100Z", + updatedAt: "2026-02-26T12:00:02.100Z", + }, + }), + makeEvent({ + sequence: 7, + type: "thread.reverted", + aggregateKind: "thread", + aggregateId: "thread-revert", + occurredAt: "2026-02-26T12:00:03.000Z", + commandId: "cmd-revert", + payload: { + threadId: "thread-revert", + turnCount: 1, + }, + }), + ]; - const afterLaterMessage = yield* projectEvent( - afterCheckpoint, - makeEvent({ - sequence: 4, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: laterAt, - commandId: "cmd-later", - payload: { - threadId: "thread-1", - messageId: "assistant:msg-2", - role: "assistant", - text: "second", - turnId: "turn-1", - streaming: false, - createdAt: laterAt, - updatedAt: laterAt, - }, - }), - ); + const afterRevert = await events.reduce>>( + (statePromise, event) => + statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), + Promise.resolve(afterCreate), + ); - const latestTurn = afterLaterMessage.threads[0]?.latestTurn; - expect(latestTurn?.assistantMessageId).toBe("assistant:msg-2"); - expect(latestTurn?.completedAt).toBe(completeAt); - expect(afterLaterMessage.threads[0]?.checkpoints[0]?.assistantMessageId).toBe( - "assistant:msg-2", - ); - }), - ); + const thread = afterRevert.threads[0]; + expect( + thread?.messages.map((message) => ({ + id: message.id, + role: message.role, + turnId: message.turnId, + })), + ).toEqual([{ id: "assistant-keep", role: "assistant", turnId: "turn-1" }]); + }); - effectIt.effect("does not regress interrupted latestTurn on late streaming chunks", () => - Effect.gen(function* () { - const createdAt = "2026-02-23T09:10:00.000Z"; - const interruptedAt = "2026-02-23T09:10:03.500Z"; - const laterAt = "2026-02-23T09:10:05.000Z"; - const model = createEmptyReadModel(createdAt); + it("caps message and checkpoint retention for long-lived threads", async () => { + const createdAt = "2026-03-01T10:00:00.000Z"; + const model = createEmptyReadModel(createdAt); - const afterCreate = yield* projectEvent( + const afterCreate = await Effect.runPromise( + projectEvent( model, makeEvent({ sequence: 1, type: "thread.created", aggregateKind: "thread", - aggregateId: "thread-1", + aggregateId: "thread-capped", occurredAt: createdAt, - commandId: "cmd-create", + commandId: "cmd-create-capped", payload: { - threadId: "thread-1", + threadId: "thread-capped", projectId: "project-1", - title: "demo", + title: "capped", modelSelection: { provider: ProviderDriverKind.make("codex"), - model: "gpt-5.3-codex", + model: "gpt-5-codex", }, runtimeMode: "full-access", branch: null, @@ -790,1060 +881,75 @@ describe("orchestration projector", () => { updatedAt: createdAt, }, }), - ); + ), + ); - const afterMessage = yield* projectEvent( - afterCreate, + const messageEvents: ReadonlyArray = Array.from( + { length: 2_100 }, + (_, index) => makeEvent({ - sequence: 2, + sequence: index + 2, type: "thread.message-sent", aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: createdAt, - commandId: "cmd-message", + aggregateId: "thread-capped", + occurredAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, + commandId: `cmd-message-${index}`, payload: { - threadId: "thread-1", - messageId: "assistant:msg-1", + threadId: "thread-capped", + messageId: `msg-${index}`, role: "assistant", - text: "partial", - turnId: "turn-1", - streaming: true, - createdAt, - updatedAt: createdAt, + text: `message-${index}`, + turnId: `turn-${index}`, + streaming: false, + createdAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, + updatedAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, }, }), - ); - - const afterInterrupt = { - ...afterMessage, - threads: afterMessage.threads.map((thread) => - thread.id === "thread-1" - ? { - ...thread, - latestTurn: { - turnId: TurnId.make("turn-1"), - state: "interrupted" as const, - requestedAt: createdAt, - startedAt: createdAt, - completedAt: interruptedAt, - assistantMessageId: MessageId.make("assistant:msg-1"), - }, - } - : thread, - ), - }; + ); + const afterMessages = await messageEvents.reduce< + Promise> + >( + (statePromise, event) => + statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), + Promise.resolve(afterCreate), + ); - const afterLateChunk = yield* projectEvent( - afterInterrupt, + const checkpointEvents: ReadonlyArray = Array.from( + { length: 600 }, + (_, index) => makeEvent({ - sequence: 4, - type: "thread.message-sent", + sequence: index + 2_102, + type: "thread.turn-diff-completed", aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: laterAt, - commandId: "cmd-late", + aggregateId: "thread-capped", + occurredAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, + commandId: `cmd-checkpoint-${index}`, payload: { - threadId: "thread-1", - messageId: "assistant:msg-1", - role: "assistant", - text: " trailing", - turnId: "turn-1", - streaming: true, - createdAt: laterAt, - updatedAt: laterAt, + threadId: "thread-capped", + turnId: `turn-${index}`, + checkpointTurnCount: index + 1, + checkpointRef: `refs/t3/checkpoints/thread-capped/turn/${index + 1}`, + status: "ready", + files: [], + assistantMessageId: `msg-${index}`, + completedAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, }, }), - ); - - const latestTurn = afterLateChunk.threads[0]?.latestTurn; - expect(latestTurn?.state).toBe("interrupted"); - expect(latestTurn?.completedAt).toBe(interruptedAt); - }), - ); - - effectIt.effect( - "advances latestTurn when a fresh assistant message belongs to the active turn", - () => - Effect.gen(function* () { - const createdAt = "2026-02-23T09:10:00.000Z"; - const secondTurnAt = "2026-02-23T09:20:00.000Z"; - const model = createEmptyReadModel(createdAt); - - const afterCreate = yield* projectEvent( - model, - makeEvent({ - sequence: 1, - type: "thread.created", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: createdAt, - commandId: "cmd-create", - payload: { - threadId: "thread-1", - projectId: "project-1", - title: "demo", - modelSelection: { - provider: ProviderDriverKind.make("codex"), - model: "gpt-5.3-codex", - }, - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt, - updatedAt: createdAt, - }, - }), - ); - - const afterFirstMessage = yield* projectEvent( - afterCreate, - makeEvent({ - sequence: 2, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: createdAt, - commandId: "cmd-message-1", - payload: { - threadId: "thread-1", - messageId: "assistant:msg-1", - role: "assistant", - text: "done", - turnId: "turn-1", - streaming: false, - createdAt, - updatedAt: createdAt, - }, - }), - ); + ); + const finalState = await checkpointEvents.reduce< + Promise> + >( + (statePromise, event) => + statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), + Promise.resolve(afterMessages), + ); - const afterSecondSession = yield* projectEvent( - afterFirstMessage, - makeEvent({ - sequence: 3, - type: "thread.session-set", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: secondTurnAt, - commandId: "cmd-session-running", - payload: { - threadId: "thread-1", - session: { - threadId: "thread-1", - status: "running", - providerName: "grok", - runtimeMode: "full-access", - activeTurnId: "turn-2", - lastError: null, - updatedAt: secondTurnAt, - }, - }, - }), - ); - - const afterSecondMessage = yield* projectEvent( - afterSecondSession, - makeEvent({ - sequence: 4, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: secondTurnAt, - commandId: "cmd-message-2", - payload: { - threadId: "thread-1", - messageId: "assistant:msg-2", - role: "assistant", - text: "working", - turnId: "turn-2", - streaming: true, - createdAt: secondTurnAt, - updatedAt: secondTurnAt, - }, - }), - ); - - const latestTurn = afterSecondMessage.threads[0]?.latestTurn; - expect(latestTurn?.turnId).toBe("turn-2"); - expect(latestTurn?.state).toBe("running"); - expect(latestTurn?.assistantMessageId).toBe("assistant:msg-2"); - }), - ); - - it("archives replayed assistant rows and advances latestTurn when a reused segment rebinds", async () => { - const createdAt = "2026-06-24T00:29:27.101Z"; - const reboundAt = "2026-06-24T01:12:00.260Z"; - const model = createEmptyReadModel(createdAt); - - const afterRebound = await Effect.runPromise( - Effect.gen(function* () { - let state = model; - state = yield* projectEvent( - state, - makeEvent({ - sequence: 1, - type: "thread.created", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: createdAt, - commandId: "cmd-create", - payload: { - threadId: "thread-1", - projectId: "project-1", - title: "demo", - modelSelection: { - provider: ProviderDriverKind.make("codex"), - model: "gpt-5.3-codex", - }, - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt, - updatedAt: createdAt, - }, - }), - ); - state = yield* projectEvent( - state, - makeEvent({ - sequence: 2, - type: "thread.session-set", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: createdAt, - commandId: "cmd-session-running", - payload: { - threadId: "thread-1", - session: { - threadId: "thread-1", - status: "running", - providerName: "grok", - runtimeMode: "full-access", - activeTurnId: "turn-replay", - lastError: null, - updatedAt: createdAt, - }, - }, - }), - ); - state = yield* projectEvent( - state, - makeEvent({ - sequence: 3, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: createdAt, - commandId: "cmd-replay", - payload: { - threadId: "thread-1", - messageId: "assistant-segment-0", - role: "assistant", - text: "Health-check response from replay.", - turnId: "turn-replay", - streaming: false, - createdAt, - updatedAt: createdAt, - }, - }), - ); - return yield* projectEvent( - state, - makeEvent({ - sequence: 3, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: reboundAt, - commandId: "cmd-rebound", - payload: { - threadId: "thread-1", - messageId: "assistant-segment-0", - role: "assistant", - text: "New ", - turnId: "turn-follow-up", - streaming: true, - createdAt: reboundAt, - updatedAt: reboundAt, - }, - }), - ); - }), - ); - - const thread = afterRebound.threads[0]; - expect(thread?.messages).toHaveLength(2); - expect(thread?.messages[0]?.text).toBe("Health-check response from replay."); - expect(thread?.messages[1]?.text).toBe("New "); - expect(thread?.messages[1]?.turnId).toBe("turn-follow-up"); - expect(thread?.latestTurn?.turnId).toBe("turn-follow-up"); - expect(thread?.latestTurn?.state).toBe("running"); - expect(thread?.latestTurn?.assistantMessageId).toBe("assistant-segment-0"); + const thread = finalState.threads[0]; + expect(thread?.messages).toHaveLength(2_000); + expect(thread?.messages[0]?.id).toBe("msg-100"); + expect(thread?.messages.at(-1)?.id).toBe("msg-2099"); + expect(thread?.checkpoints).toHaveLength(500); + expect(thread?.checkpoints[0]?.turnId).toBe("turn-100"); + expect(thread?.checkpoints.at(-1)?.turnId).toBe("turn-599"); }); - - effectIt.effect( - "does not regress latestTurn when a late rebound targets an older archived turn", - () => - Effect.gen(function* () { - const createdAt = "2026-06-24T00:29:27.101Z"; - const reboundAt = "2026-06-24T01:12:00.260Z"; - const completedAt = "2026-06-24T01:12:05.000Z"; - const staleAt = "2026-06-24T01:12:10.000Z"; - let state = createEmptyReadModel(createdAt); - state = yield* projectEvent( - state, - makeEvent({ - sequence: 1, - type: "thread.created", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: createdAt, - commandId: "cmd-create", - payload: { - threadId: "thread-1", - projectId: "project-1", - title: "demo", - modelSelection: { - provider: ProviderDriverKind.make("codex"), - model: "gpt-5.3-codex", - }, - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt, - updatedAt: createdAt, - }, - }), - ); - state = yield* projectEvent( - state, - makeEvent({ - sequence: 2, - type: "thread.session-set", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: createdAt, - commandId: "cmd-session-running", - payload: { - threadId: "thread-1", - session: { - threadId: "thread-1", - status: "running", - providerName: "grok", - runtimeMode: "full-access", - activeTurnId: "turn-replay", - lastError: null, - updatedAt: createdAt, - }, - }, - }), - ); - state = yield* projectEvent( - state, - makeEvent({ - sequence: 3, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: createdAt, - commandId: "cmd-replay", - payload: { - threadId: "thread-1", - messageId: "assistant-segment-0", - role: "assistant", - text: "Health-check response from replay.", - turnId: "turn-replay", - streaming: false, - createdAt, - updatedAt: createdAt, - }, - }), - ); - state = yield* projectEvent( - state, - makeEvent({ - sequence: 4, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: reboundAt, - commandId: "cmd-rebound", - payload: { - threadId: "thread-1", - messageId: "assistant-segment-0", - role: "assistant", - text: "Follow-up response.", - turnId: "turn-follow-up", - streaming: false, - createdAt: reboundAt, - updatedAt: reboundAt, - }, - }), - ); - state = yield* projectEvent( - state, - makeEvent({ - sequence: 5, - type: "thread.session-set", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: completedAt, - commandId: "cmd-session-ready", - payload: { - threadId: "thread-1", - session: { - threadId: "thread-1", - status: "ready", - providerName: "grok", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: completedAt, - }, - }, - }), - ); - state = yield* projectEvent( - state, - makeEvent({ - sequence: 6, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: staleAt, - commandId: "cmd-stale-replay", - payload: { - threadId: "thread-1", - messageId: "assistant-segment-0", - role: "assistant", - text: " stale", - turnId: "turn-replay", - streaming: true, - createdAt: staleAt, - updatedAt: staleAt, - }, - }), - ); - - const thread = state.threads[0]; - expect(thread?.latestTurn?.turnId).toBe("turn-follow-up"); - expect(thread?.latestTurn?.assistantMessageId).toBe("assistant-segment-0"); - }), - ); - - effectIt.effect( - "skips checkpoint repoint when the archived assistant row is evicted by message cap", - () => - Effect.gen(function* () { - const createdAt = "2026-06-24T00:29:27.101Z"; - const reboundAt = "2026-06-24T01:12:00.260Z"; - let state = createEmptyReadModel(createdAt); - state = yield* projectEvent( - state, - makeEvent({ - sequence: 1, - type: "thread.created", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: createdAt, - commandId: "cmd-create", - payload: { - threadId: "thread-1", - projectId: "project-1", - title: "demo", - modelSelection: { - provider: ProviderDriverKind.make("codex"), - model: "gpt-5.3-codex", - }, - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt, - updatedAt: createdAt, - }, - }), - ); - state = yield* projectEvent( - state, - makeEvent({ - sequence: 2, - type: "thread.session-set", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: createdAt, - commandId: "cmd-session-running", - payload: { - threadId: "thread-1", - session: { - threadId: "thread-1", - status: "running", - providerName: "grok", - runtimeMode: "full-access", - activeTurnId: "turn-replay", - lastError: null, - updatedAt: createdAt, - }, - }, - }), - ); - state = yield* projectEvent( - state, - makeEvent({ - sequence: 3, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: createdAt, - commandId: "cmd-replay", - payload: { - threadId: "thread-1", - messageId: "assistant-segment-0", - role: "assistant", - text: "Health-check response from replay.", - turnId: "turn-replay", - streaming: false, - createdAt, - updatedAt: createdAt, - }, - }), - ); - state = yield* projectEvent( - state, - makeEvent({ - sequence: 4, - type: "thread.turn-diff-completed", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: createdAt, - commandId: "cmd-checkpoint", - payload: { - threadId: "thread-1", - turnId: "turn-replay", - checkpointTurnCount: 1, - checkpointRef: "refs/t3/checkpoints/thread-1/turn/1", - status: "ready", - files: [], - assistantMessageId: "assistant-segment-0", - completedAt: createdAt, - }, - }), - ); - - const fillerEvents: ReadonlyArray = Array.from( - { length: 1_999 }, - (_, index) => - makeEvent({ - sequence: index + 5, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: `2026-06-24T00:30:${String(index % 60).padStart(2, "0")}.000Z`, - commandId: `cmd-filler-${index}`, - payload: { - threadId: "thread-1", - messageId: `msg-filler-${index}`, - role: "user", - text: `filler-${index}`, - turnId: null, - streaming: false, - createdAt: `2026-06-24T00:30:${String(index % 60).padStart(2, "0")}.000Z`, - updatedAt: `2026-06-24T00:30:${String(index % 60).padStart(2, "0")}.000Z`, - }, - }), - ); - for (const event of fillerEvents) { - state = yield* projectEvent(state, event); - } - - state = yield* projectEvent( - state, - makeEvent({ - sequence: 2_004, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: reboundAt, - commandId: "cmd-rebound", - payload: { - threadId: "thread-1", - messageId: "assistant-segment-0", - role: "assistant", - text: "New ", - turnId: "turn-follow-up", - streaming: true, - createdAt: reboundAt, - updatedAt: reboundAt, - }, - }), - ); - - const thread = state.threads[0]; - expect(thread?.messages).toHaveLength(2_000); - expect( - thread?.messages.some((message) => message.id === "assistant-segment-0@turn:turn-replay"), - ).toBe(false); - expect(thread?.checkpoints[0]?.assistantMessageId).toBe("assistant-segment-0"); - }), - ); - - effectIt.effect("prunes reverted turn messages from in-memory thread snapshot", () => - Effect.gen(function* () { - const createdAt = "2026-02-23T10:00:00.000Z"; - const model = createEmptyReadModel(createdAt); - - const afterCreate = yield* projectEvent( - model, - makeEvent({ - sequence: 1, - type: "thread.created", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: createdAt, - commandId: "cmd-create", - payload: { - threadId: "thread-1", - projectId: "project-1", - title: "demo", - modelSelection: { - provider: ProviderDriverKind.make("codex"), - model: "gpt-5.3-codex", - }, - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt, - updatedAt: createdAt, - }, - }), - ); - - const events: ReadonlyArray = [ - makeEvent({ - sequence: 2, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: "2026-02-23T10:00:01.000Z", - commandId: "cmd-user-1", - payload: { - threadId: "thread-1", - messageId: "user-msg-1", - role: "user", - text: "First edit", - turnId: null, - streaming: false, - createdAt: "2026-02-23T10:00:01.000Z", - updatedAt: "2026-02-23T10:00:01.000Z", - }, - }), - makeEvent({ - sequence: 3, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: "2026-02-23T10:00:02.000Z", - commandId: "cmd-assistant-1", - payload: { - threadId: "thread-1", - messageId: "assistant-msg-1", - role: "assistant", - text: "Updated README to v2.\n", - turnId: "turn-1", - streaming: false, - createdAt: "2026-02-23T10:00:02.000Z", - updatedAt: "2026-02-23T10:00:02.000Z", - }, - }), - makeEvent({ - sequence: 4, - type: "thread.turn-diff-completed", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: "2026-02-23T10:00:02.500Z", - commandId: "cmd-turn-1-complete", - payload: { - threadId: "thread-1", - turnId: "turn-1", - checkpointTurnCount: 1, - checkpointRef: "refs/t3/checkpoints/thread-1/turn/1", - status: "ready", - files: [], - assistantMessageId: "assistant-msg-1", - completedAt: "2026-02-23T10:00:02.500Z", - }, - }), - makeEvent({ - sequence: 5, - type: "thread.activity-appended", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: "2026-02-23T10:00:02.750Z", - commandId: "cmd-activity-1", - payload: { - threadId: "thread-1", - activity: { - id: "activity-1", - tone: "tool", - kind: "tool.started", - summary: "Edit file started", - payload: { toolKind: "command" }, - turnId: "turn-1", - createdAt: "2026-02-23T10:00:02.750Z", - }, - }, - }), - makeEvent({ - sequence: 6, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: "2026-02-23T10:00:03.000Z", - commandId: "cmd-user-2", - payload: { - threadId: "thread-1", - messageId: "user-msg-2", - role: "user", - text: "Second edit", - turnId: null, - streaming: false, - createdAt: "2026-02-23T10:00:03.000Z", - updatedAt: "2026-02-23T10:00:03.000Z", - }, - }), - makeEvent({ - sequence: 7, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: "2026-02-23T10:00:04.000Z", - commandId: "cmd-assistant-2", - payload: { - threadId: "thread-1", - messageId: "assistant-msg-2", - role: "assistant", - text: "Updated README to v3.\n", - turnId: "turn-2", - streaming: false, - createdAt: "2026-02-23T10:00:04.000Z", - updatedAt: "2026-02-23T10:00:04.000Z", - }, - }), - makeEvent({ - sequence: 8, - type: "thread.turn-diff-completed", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: "2026-02-23T10:00:04.500Z", - commandId: "cmd-turn-2-complete", - payload: { - threadId: "thread-1", - turnId: "turn-2", - checkpointTurnCount: 2, - checkpointRef: "refs/t3/checkpoints/thread-1/turn/2", - status: "ready", - files: [], - assistantMessageId: "assistant-msg-2", - completedAt: "2026-02-23T10:00:04.500Z", - }, - }), - makeEvent({ - sequence: 9, - type: "thread.activity-appended", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: "2026-02-23T10:00:04.750Z", - commandId: "cmd-activity-2", - payload: { - threadId: "thread-1", - activity: { - id: "activity-2", - tone: "tool", - kind: "tool.completed", - summary: "Edit file complete", - payload: { toolKind: "command" }, - turnId: "turn-2", - createdAt: "2026-02-23T10:00:04.750Z", - }, - }, - }), - makeEvent({ - sequence: 10, - type: "thread.reverted", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: "2026-02-23T10:00:05.000Z", - commandId: "cmd-revert", - payload: { - threadId: "thread-1", - turnCount: 1, - }, - }), - ]; - - let afterRevert = afterCreate; - for (const event of events) { - afterRevert = yield* projectEvent(afterRevert, event); - } - - const thread = afterRevert.threads[0]; - expect( - thread?.messages.map((message) => ({ role: message.role, text: message.text })), - ).toEqual([ - { role: "user", text: "First edit" }, - { role: "assistant", text: "Updated README to v2.\n" }, - ]); - expect( - thread?.activities.map((activity) => ({ id: activity.id, turnId: activity.turnId })), - ).toEqual([{ id: "activity-1", turnId: "turn-1" }]); - expect(thread?.checkpoints.map((checkpoint) => checkpoint.checkpointTurnCount)).toEqual([1]); - expect(thread?.latestTurn?.turnId).toBe("turn-1"); - }), - ); - - effectIt.effect("does not fallback-retain messages tied to removed turn IDs", () => - Effect.gen(function* () { - const createdAt = "2026-02-26T12:00:00.000Z"; - const model = createEmptyReadModel(createdAt); - - const afterCreate = yield* projectEvent( - model, - makeEvent({ - sequence: 1, - type: "thread.created", - aggregateKind: "thread", - aggregateId: "thread-revert", - occurredAt: createdAt, - commandId: "cmd-create-revert", - payload: { - threadId: "thread-revert", - projectId: "project-1", - title: "demo", - modelSelection: { - provider: ProviderDriverKind.make("codex"), - model: "gpt-5.3-codex", - }, - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt, - updatedAt: createdAt, - }, - }), - ); - - const events: ReadonlyArray = [ - makeEvent({ - sequence: 2, - type: "thread.turn-diff-completed", - aggregateKind: "thread", - aggregateId: "thread-revert", - occurredAt: "2026-02-26T12:00:01.000Z", - commandId: "cmd-turn-1", - payload: { - threadId: "thread-revert", - turnId: "turn-1", - checkpointTurnCount: 1, - checkpointRef: "refs/t3/checkpoints/thread-revert/turn/1", - status: "ready", - files: [], - assistantMessageId: "assistant-keep", - completedAt: "2026-02-26T12:00:01.000Z", - }, - }), - makeEvent({ - sequence: 3, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-revert", - occurredAt: "2026-02-26T12:00:01.100Z", - commandId: "cmd-assistant-keep", - payload: { - threadId: "thread-revert", - messageId: "assistant-keep", - role: "assistant", - text: "kept", - turnId: "turn-1", - streaming: false, - createdAt: "2026-02-26T12:00:01.100Z", - updatedAt: "2026-02-26T12:00:01.100Z", - }, - }), - makeEvent({ - sequence: 4, - type: "thread.turn-diff-completed", - aggregateKind: "thread", - aggregateId: "thread-revert", - occurredAt: "2026-02-26T12:00:02.000Z", - commandId: "cmd-turn-2", - payload: { - threadId: "thread-revert", - turnId: "turn-2", - checkpointTurnCount: 2, - checkpointRef: "refs/t3/checkpoints/thread-revert/turn/2", - status: "ready", - files: [], - assistantMessageId: "assistant-remove", - completedAt: "2026-02-26T12:00:02.000Z", - }, - }), - makeEvent({ - sequence: 5, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-revert", - occurredAt: "2026-02-26T12:00:02.050Z", - commandId: "cmd-user-remove", - payload: { - threadId: "thread-revert", - messageId: "user-remove", - role: "user", - text: "removed", - turnId: "turn-2", - streaming: false, - createdAt: "2026-02-26T12:00:02.050Z", - updatedAt: "2026-02-26T12:00:02.050Z", - }, - }), - makeEvent({ - sequence: 6, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-revert", - occurredAt: "2026-02-26T12:00:02.100Z", - commandId: "cmd-assistant-remove", - payload: { - threadId: "thread-revert", - messageId: "assistant-remove", - role: "assistant", - text: "removed", - turnId: "turn-2", - streaming: false, - createdAt: "2026-02-26T12:00:02.100Z", - updatedAt: "2026-02-26T12:00:02.100Z", - }, - }), - makeEvent({ - sequence: 7, - type: "thread.reverted", - aggregateKind: "thread", - aggregateId: "thread-revert", - occurredAt: "2026-02-26T12:00:03.000Z", - commandId: "cmd-revert", - payload: { - threadId: "thread-revert", - turnCount: 1, - }, - }), - ]; - - let afterRevert = afterCreate; - for (const event of events) { - afterRevert = yield* projectEvent(afterRevert, event); - } - - const thread = afterRevert.threads[0]; - expect( - thread?.messages.map((message) => ({ - id: message.id, - role: message.role, - turnId: message.turnId, - })), - ).toEqual([{ id: "assistant-keep", role: "assistant", turnId: "turn-1" }]); - }), - ); - - effectIt.effect("caps message and checkpoint retention for long-lived threads", () => - Effect.gen(function* () { - const createdAt = "2026-03-01T10:00:00.000Z"; - const model = createEmptyReadModel(createdAt); - - const afterCreate = yield* projectEvent( - model, - makeEvent({ - sequence: 1, - type: "thread.created", - aggregateKind: "thread", - aggregateId: "thread-capped", - occurredAt: createdAt, - commandId: "cmd-create-capped", - payload: { - threadId: "thread-capped", - projectId: "project-1", - title: "capped", - modelSelection: { - provider: ProviderDriverKind.make("codex"), - model: "gpt-5-codex", - }, - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt, - updatedAt: createdAt, - }, - }), - ); - - const messageEvents: ReadonlyArray = Array.from( - { length: 2_100 }, - (_, index) => - makeEvent({ - sequence: index + 2, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-capped", - occurredAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, - commandId: `cmd-message-${index}`, - payload: { - threadId: "thread-capped", - messageId: `msg-${index}`, - role: "assistant", - text: `message-${index}`, - turnId: `turn-${index}`, - streaming: false, - createdAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, - updatedAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, - }, - }), - ); - let afterMessages = afterCreate; - for (const event of messageEvents) { - afterMessages = yield* projectEvent(afterMessages, event); - } - - const checkpointEvents: ReadonlyArray = Array.from( - { length: 600 }, - (_, index) => - makeEvent({ - sequence: index + 2_102, - type: "thread.turn-diff-completed", - aggregateKind: "thread", - aggregateId: "thread-capped", - occurredAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, - commandId: `cmd-checkpoint-${index}`, - payload: { - threadId: "thread-capped", - turnId: `turn-${index}`, - checkpointTurnCount: index + 1, - checkpointRef: `refs/t3/checkpoints/thread-capped/turn/${index + 1}`, - status: "ready", - files: [], - assistantMessageId: `msg-${index}`, - completedAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, - }, - }), - ); - let finalState = afterMessages; - for (const event of checkpointEvents) { - finalState = yield* projectEvent(finalState, event); - } - - const thread = finalState.threads[0]; - expect(thread?.messages).toHaveLength(2_000); - expect(thread?.messages[0]?.id).toBe("msg-100"); - expect(thread?.messages.at(-1)?.id).toBe("msg-2099"); - expect(thread?.checkpoints).toHaveLength(500); - expect(thread?.checkpoints[0]?.turnId).toBe("turn-100"); - expect(thread?.checkpoints.at(-1)?.turnId).toBe("turn-599"); - }), - ); }); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 6bcca88b698..fc6ab8f6fcf 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,25 +1,12 @@ -import type { - MessageId, - OrchestrationEvent, - OrchestrationReadModel, - ThreadId, - TurnId, -} from "@t3tools/contracts"; +import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; import { OrchestrationCheckpointSummary, OrchestrationMessage, OrchestrationSession, OrchestrationThread, } from "@t3tools/contracts"; -import * as Arr from "effect/Array"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { - assistantSegmentBelongsToActiveTurn, - applyAssistantSegmentMessageUpdate, - repointCheckpointsForArchivedAssistantSegment, - repointLatestTurnForArchivedAssistantSegment, -} from "@t3tools/shared/orchestrationMessages"; import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts"; import { @@ -45,16 +32,6 @@ type ThreadPatch = Partial>; const MAX_THREAD_MESSAGES = 2_000; const MAX_THREAD_CHECKPOINTS = 500; -function rebindCheckpointAssistantMessage( - checkpoints: ReadonlyArray, - turnId: TurnId, - messageId: MessageId, -): OrchestrationCheckpointSummary[] { - return Arr.map(checkpoints, (entry) => - entry.turnId === turnId ? { ...entry, assistantMessageId: messageId } : entry, - ); -} - function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { if (status === "error") return "error" as const; if (status === "missing") return "interrupted" as const; @@ -432,108 +409,33 @@ export function projectEvent( "message", ); - const existingMessage = thread.messages.find((entry) => entry.id === payload.messageId); - const turnStillActive = - thread.session?.status === "running" && - assistantSegmentBelongsToActiveTurn({ - activeTurnId: thread.session.activeTurnId, - existingTurnId: existingMessage?.turnId, - incomingTurnId: payload.turnId, - }); - const applied = applyAssistantSegmentMessageUpdate(thread.messages, message, { - activeTurnId: thread.session?.activeTurnId ?? null, - turnStillActive: turnStillActive === true, - }); - if (applied.messages === thread.messages) { - return nextBase; - } - const cappedMessages = applied.messages.slice(-MAX_THREAD_MESSAGES); - const settlesTurn = !payload.streaming && !turnStillActive; - let latestTurn = thread.latestTurn; - const checkpointRepointAdvancesLatestTurn = - applied.checkpointsToRepoint !== undefined && - (latestTurn === null || - latestTurn.turnId === applied.checkpointsToRepoint.archivedTurnId); - const shouldAdvanceLatestTurn = - payload.role === "assistant" && - payload.turnId !== null && - (latestTurn === null || - latestTurn.turnId === payload.turnId || - turnStillActive || - checkpointRepointAdvancesLatestTurn); - const preservedLatestTurn = - latestTurn?.turnId === payload.turnId && - !turnStillActive && - (latestTurn.state === "completed" || - latestTurn.state === "interrupted" || - latestTurn.state === "error") - ? latestTurn - : null; - const nextLatestTurnState = preservedLatestTurn - ? preservedLatestTurn.state - : settlesTurn - ? latestTurn?.turnId === payload.turnId && latestTurn.state === "interrupted" - ? "interrupted" - : latestTurn?.turnId === payload.turnId && latestTurn.state === "error" - ? "error" - : "completed" - : "running"; - const nextLatestTurnCompletedAt = preservedLatestTurn - ? preservedLatestTurn.completedAt - : settlesTurn - ? latestTurn?.turnId === payload.turnId - ? (latestTurn.completedAt ?? payload.updatedAt) - : payload.updatedAt - : latestTurn?.turnId === payload.turnId - ? (latestTurn.completedAt ?? null) - : null; - latestTurn = shouldAdvanceLatestTurn - ? { - turnId: payload.turnId, - state: nextLatestTurnState, - requestedAt: - latestTurn?.turnId === payload.turnId ? latestTurn.requestedAt : payload.createdAt, - startedAt: - latestTurn?.turnId === payload.turnId - ? (latestTurn.startedAt ?? payload.createdAt) - : payload.createdAt, - completedAt: nextLatestTurnCompletedAt, - assistantMessageId: payload.messageId, - } - : latestTurn; - const retainedRepoint = - applied.checkpointsToRepoint !== undefined && - cappedMessages.some( - (message) => message.id === applied.checkpointsToRepoint?.archivedMessageId, - ) - ? applied.checkpointsToRepoint - : undefined; - const checkpointRepoint = retainedRepoint; - if (checkpointRepoint) { - latestTurn = repointLatestTurnForArchivedAssistantSegment(latestTurn, checkpointRepoint); - } - let checkpoints = checkpointRepoint - ? repointCheckpointsForArchivedAssistantSegment( - thread.checkpoints, - checkpointRepoint.providerMessageId, - checkpointRepoint.archivedMessageId, - checkpointRepoint.archivedTurnId, + const existingMessage = thread.messages.find((entry) => entry.id === message.id); + const messages = existingMessage + ? thread.messages.map((entry) => + entry.id === message.id + ? { + ...entry, + text: message.streaming + ? `${entry.text}${message.text}` + : message.text.length > 0 + ? message.text + : entry.text, + streaming: message.streaming, + updatedAt: message.updatedAt, + turnId: message.turnId, + ...(message.attachments !== undefined + ? { attachments: message.attachments } + : {}), + } + : entry, ) - : thread.checkpoints; - if (payload.role === "assistant" && payload.turnId !== null) { - checkpoints = rebindCheckpointAssistantMessage( - checkpoints, - payload.turnId, - payload.messageId, - ); - } + : [...thread.messages, message]; + const cappedMessages = messages.slice(-MAX_THREAD_MESSAGES); return { ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { messages: cappedMessages, - checkpoints, - latestTurn, updatedAt: event.occurredAt, }), }; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 9760b2f81fb..59788a2d225 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -785,6 +785,9 @@ export function makeCursorAdapter( Stream.mapEffect(acp.getEvents(), (event) => Effect.gen(function* () { switch (event._tag) { + case "EventStreamBarrier": + yield* Deferred.succeed(event.acknowledge, undefined); + return; case "ModeChanged": return; case "AssistantItemStarted": diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 08f58f005b7..6441d33218b 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -320,6 +320,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper({ T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1", + T3_ACP_EMIT_FOREIGN_SESSION_UPDATES: "1", }), ); const adapter = yield* makeTestAdapter(wrapperPath); @@ -353,6 +354,9 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }); yield* Deferred.await(turnCompleted); + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } const readySessions = yield* adapter.listSessions(); const readySession = readySessions.find((session) => session.threadId === threadId); const turnCompletedEvent = runtimeEvents.find( @@ -360,9 +364,38 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { event.type === "turn.completed", ); const eventTypes = runtimeEvents.map((event) => event.type); + const content = runtimeEvents + .filter( + (event): event is Extract => + event.type === "content.delta" && String(event.threadId) === String(threadId), + ) + .map((event) => event.payload.delta) + .join(""); + const terminalIndex = runtimeEvents.findIndex( + (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + const turnOutputTypes = new Set([ + "content.delta", + "item.started", + "item.updated", + "item.completed", + "turn.plan.updated", + ]); + const outputAfterTerminal = runtimeEvents + .slice(terminalIndex + 1) + .filter( + (event) => String(event.threadId) === String(threadId) && turnOutputTypes.has(event.type), + ); + const toolTitles = runtimeEvents.flatMap((event) => + event.type === "item.updated" && event.payload.title ? [event.payload.title] : [], + ); assert.equal(sendTurnResult.threadId, threadId); assert.include(eventTypes, "turn.completed"); + assert.equal(content, "hello from mock"); + assert.isAtLeast(terminalIndex, 0); + assert.deepEqual(outputAfterTerminal, []); + assert.notInclude(toolTitles, "Child-only tool"); assert.equal(turnCompletedEvent?.payload.stopReason, "end_turn"); assert.equal(readySession?.status, "ready"); assert.isUndefined(readySession?.activeTurnId); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 239d44a0572..12469c91ee3 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -110,9 +110,6 @@ interface GrokSessionContext { turns: Array<{ id: TurnId; items: Array }>; lastPlanFingerprint: string | undefined; activeTurnId: TurnId | undefined; - /** Retains the most recently settled turn so trailing session/update chunks - * emitted after prompt completion still attach to the completed turn. */ - streamingTurnId: TurnId | undefined; /** Turns already interrupted; late prompt RPCs must not resurrect them. */ interruptedTurnIds: Set; /** Number of sendTurn prompts currently in flight or being prepared. @@ -163,8 +160,7 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -const resolveNotificationTurnId = (ctx: GrokSessionContext): TurnId | undefined => - ctx.streamingTurnId ?? ctx.activeTurnId; +const resolveNotificationTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId; const resolveCallbackTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId; @@ -364,13 +360,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const shouldEmitFailedTurn = options?.errorMessage !== undefined && canEmitTurnCompletion; const shouldEmitCompletedTurn = options?.completedStopReason !== undefined && canEmitTurnCompletion; - const shouldRetainStreamingTurnId = shouldEmitFailedTurn || shouldEmitCompletedTurn; const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; - if (shouldRetainStreamingTurnId) { - liveCtx.streamingTurnId = turnId; - } else if (liveCtx.streamingTurnId === turnId) { - liveCtx.streamingTurnId = undefined; - } liveCtx.activeTurnId = undefined; liveCtx.session = { ...readySession, @@ -445,13 +435,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const shouldEmitFailedTurn = options?.errorMessage !== undefined && canEmitTurnCompletion; const shouldEmitCompletedTurn = options?.completedStopReason !== undefined && canEmitTurnCompletion; - const shouldRetainStreamingTurnId = shouldEmitFailedTurn || shouldEmitCompletedTurn; const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; - if (shouldRetainStreamingTurnId) { - liveCtx.streamingTurnId = settleTurnId; - } else if (liveCtx.streamingTurnId === settleTurnId) { - liveCtx.streamingTurnId = undefined; - } liveCtx.activeTurnId = undefined; liveCtx.session = { ...readySession, @@ -830,7 +814,6 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte turns: [], lastPlanFingerprint: undefined, activeTurnId: undefined, - streamingTurnId: undefined, interruptedTurnIds: new Set(), promptsInFlight: 0, currentModelId: boundModelId, @@ -840,6 +823,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const nf = yield* Stream.runDrain( Stream.mapEffect(acp.getEvents(), (event) => Effect.gen(function* () { + if (event._tag === "EventStreamBarrier") { + yield* Deferred.succeed(event.acknowledge, undefined); + return; + } if ( event._tag === "PlanUpdated" || event._tag === "ToolCallUpdated" || @@ -852,14 +839,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte return; } - const stamp = yield* makeEventStamp(); const notificationTurnId = resolveNotificationTurnId(ctx); if ( - notificationTurnId !== undefined && + notificationTurnId === undefined || ctx.interruptedTurnIds.has(notificationTurnId) ) { return; } + const stamp = yield* makeEventStamp(); switch (event._tag) { case "AssistantItemStarted": @@ -1070,7 +1057,6 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } if (steeringTurnId === undefined) { ctx.lastPlanFingerprint = undefined; - ctx.streamingTurnId = undefined; } ctx.session = { ...ctx.session, @@ -1138,7 +1124,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte Ref.set( promptFailureMessageRef, mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message, - ), + ).pipe(Effect.andThen(prepared.acp.drainEvents)), ), Effect.mapError((error) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), @@ -1148,6 +1134,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { yield* Effect.yieldNow; } + yield* prepared.acp.drainEvents; return yield* withThreadLock( input.threadId, @@ -1221,7 +1208,6 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } const completedAt = yield* nowIso; const { activeTurnId: _completedTurnId, ...readySession } = ctx.session; - ctx.streamingTurnId = prepared.turnId; ctx.activeTurnId = undefined; ctx.session = { ...readySession, diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index cc7bcc57cee..294290c176e 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -116,6 +116,50 @@ describe("AcpSessionRuntime", () => { ), ); + it.effect("drops session updates emitted for a child ACP session", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + + const promptResult = yield* runtime.prompt({ + prompt: [{ type: "text", text: "hi" }], + }); + expect(promptResult).toMatchObject({ stopReason: "end_turn" }); + + const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4))); + expect(notes.map((note) => note._tag)).toEqual([ + "AssistantItemStarted", + "ContentDelta", + "ContentDelta", + "AssistantItemCompleted", + ]); + expect( + notes + .filter((note) => note._tag === "ContentDelta") + .map((note) => note.text) + .join(""), + ).toBe("root before child root after child"); + expect(notes.some((note) => note._tag === "ToolCallUpdated")).toBe(false); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { + T3_ACP_EMIT_FOREIGN_SESSION_UPDATES: "1", + }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + it.effect("resolves a prompt from xAI prompt completion when the prompt RPC hangs", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 5134f23920a..8060e17b68f 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -55,6 +55,13 @@ interface PendingXAiPromptCompletion { readonly deferred: Deferred.Deferred; } +export interface AcpSessionEventStreamBarrier { + readonly _tag: "EventStreamBarrier"; + readonly acknowledge: Deferred.Deferred; +} + +export type AcpSessionRuntimeEvent = AcpParsedSessionEvent | AcpSessionEventStreamBarrier; + const completedXAiPromptIdLimit = 128; const defaultSessionLoadTimeout = Duration.seconds(90); const defaultSessionLoadReplayIdleGap = Duration.seconds(2); @@ -190,7 +197,9 @@ export class AcpSessionRuntime extends Context.Service< */ readonly start: () => Effect.Effect; /** Stream of parsed ACP session events emitted after startup. */ - readonly getEvents: () => Stream.Stream; + readonly getEvents: () => Stream.Stream; + /** Waits until the current event consumer has processed every queued event. */ + readonly drainEvents: Effect.Effect; /** Latest mode state observed from session setup and `session/update` notifications. */ readonly getModeState: Effect.Effect; /** Latest configuration options observed from session setup and configuration writes. */ @@ -284,7 +293,7 @@ export const make = ( Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runtimeScope = yield* Scope.Scope; - const eventQueue = yield* Queue.unbounded(); + const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); @@ -391,6 +400,15 @@ export const make = ( if (sessionUpdateIsReplay(notification)) { return; } + const startState = yield* Ref.get(startStateRef); + // One runtime projects one root ACP session. Child-session updates need + // explicit lineage routing and must never be flattened into this stream. + if ( + startState._tag !== "Started" || + notification.sessionId !== startState.result.sessionId + ) { + return; + } yield* handleSessionUpdate({ queue: eventQueue, modeStateRef, @@ -714,6 +732,14 @@ export const make = ( handleExtNotification: acp.handleExtNotification, start: () => start, getEvents: () => Stream.fromQueue(eventQueue), + drainEvents: Effect.gen(function* () { + const acknowledge = yield* Deferred.make(); + yield* Queue.offer(eventQueue, { + _tag: "EventStreamBarrier", + acknowledge, + }); + yield* Deferred.await(acknowledge); + }), getModeState: Ref.get(modeStateRef), getConfigOptions: Ref.get(configOptionsRef), prompt: (payload) => @@ -877,7 +903,7 @@ const handleSessionUpdate = ({ assistantSegmentRef, params, }: { - readonly queue: Queue.Queue; + readonly queue: Queue.Queue; readonly modeStateRef: Ref.Ref; readonly toolCallsRef: Ref.Ref>; readonly assistantSegmentRef: Ref.Ref; @@ -973,7 +999,7 @@ const ensureActiveAssistantSegment = ({ assistantSegmentRef, sessionId, }: { - readonly queue: Queue.Queue; + readonly queue: Queue.Queue; readonly assistantSegmentRef: Ref.Ref; readonly sessionId: string; }) => @@ -1010,7 +1036,7 @@ const closeActiveAssistantSegment = ({ queue, assistantSegmentRef, }: { - readonly queue: Queue.Queue; + readonly queue: Queue.Queue; readonly assistantSegmentRef: Ref.Ref; }) => Ref.modify(assistantSegmentRef, (current) => { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index bf94a799e94..b04e98a8a60 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -39,7 +39,6 @@ import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; -import { useRafThrottledValue } from "../hooks/useRafThrottledValue"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; @@ -1239,7 +1238,6 @@ function ChatMarkdown({ className, lineBreaks = false, }: ChatMarkdownProps) { - const displayText = useRafThrottledValue(text, isStreaming); const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, @@ -1260,7 +1258,7 @@ function ChatMarkdown({ string, NonNullable> >(); - for (const href of extractMarkdownLinkHrefs(displayText)) { + for (const href of extractMarkdownLinkHrefs(text)) { const normalizedHref = normalizeMarkdownLinkHrefKey(href); if (metaByHref.has(normalizedHref)) continue; const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd); @@ -1269,7 +1267,7 @@ function ChatMarkdown({ } } return metaByHref; - }, [cwd, displayText]); + }, [cwd, text]); const fileLinkParentSuffixByPath = useMemo(() => { const filePaths = [...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath); return buildFileLinkParentSuffixByPath(filePaths); @@ -1336,9 +1334,7 @@ function ChatMarkdown({ li({ node, children, ...props }) { const listItemStart = node?.position?.start.offset; const markerOffset = - typeof listItemStart === "number" - ? findTaskListMarkerOffset(displayText, listItemStart) - : null; + typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null; return (
  • {renderSkillInlineMarkdownChildren(children, skills)} @@ -1534,7 +1530,7 @@ function ChatMarkdown({ openMarkdownFileInPreview, resolvedTheme, skills, - displayText, + text, threadRef, ], ); @@ -1557,7 +1553,7 @@ function ChatMarkdown({ components={markdownComponents} urlTransform={markdownUrlTransform} > - {displayText} + {text} ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5adabd2d4a4..81bd44c08cf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1036,7 +1036,6 @@ function ChatViewContent(props: ChatViewProps) { routeKind === "server" ? routeThreadRef : props.draftId; const serverThread = useThread(routeKind === "server" ? routeThreadRef : null); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const setThreadDispatchPending = useUiStateStore((store) => store.setThreadDispatchPending); const activeThreadLastVisitedAt = useUiStateStore((store) => routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, ); @@ -1812,15 +1811,6 @@ function ChatViewContent(props: ChatViewProps) { threadError, }); const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; - useEffect(() => { - if (routeKind !== "server") { - return; - } - setThreadDispatchPending(routeThreadKey, isSendBusy); - return () => { - setThreadDispatchPending(routeThreadKey, false); - }; - }, [isSendBusy, routeKind, routeThreadKey, setThreadDispatchPending]); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, activeThread?.session ?? null, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 128663c0e5d..574e33d4dab 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -615,22 +615,6 @@ describe("resolveThreadStatusPill", () => { ).toMatchObject({ label: "Working", pulse: true }); }); - it("shows working while the composer is waiting for server acknowledgement", () => { - expect( - resolveThreadStatusPill({ - thread: { - ...baseThread, - hasPendingLocalDispatch: true, - session: { - ...baseThread.session, - status: "ready", - activeTurnId: null, - }, - }, - }), - ).toMatchObject({ label: "Working", pulse: true }); - }); - it("shows plan ready when a settled plan turn has a proposed plan ready for follow-up", () => { expect( resolveThreadStatusPill({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 26a070629dc..4e7614ed551 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -58,7 +58,6 @@ type ThreadStatusInput = Pick< | "session" > & { lastVisitedAt?: string | undefined; - hasPendingLocalDispatch?: boolean | undefined; }; export interface ThreadJumpHintVisibilityController { @@ -381,7 +380,7 @@ export function resolveThreadStatusPill(input: { }; } - if (thread.session?.status === "running" || thread.hasPendingLocalDispatch) { + if (thread.session?.status === "running") { return { label: "Working", colorClass: "text-sky-600 dark:text-sky-300/80", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5afeb54c6e9..ce925618caa 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -380,9 +380,6 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); - const hasPendingLocalDispatch = useUiStateStore( - (state) => state.threadDispatchPendingById[threadKey] === true, - ); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: thread.environmentId, @@ -448,13 +445,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr [discoveredPorts, navigateToThread, openPreview, threadRef], ); const isThreadRunning = - (thread.session?.status === "running" && thread.session.activeTurnId != null) || - hasPendingLocalDispatch; + thread.session?.status === "running" && thread.session.activeTurnId != null; const threadStatus = resolveThreadStatusPill({ thread: { ...thread, lastVisitedAt, - hasPendingLocalDispatch, }, }); const pr = resolveThreadPr(thread.branch, gitStatus.data); @@ -1196,7 +1191,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ), ), ); - const threadDispatchPendingById = useUiStateStore((state) => state.threadDispatchPendingById); const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState(null); @@ -1246,13 +1240,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ]), ); const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => { - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const lastVisitedAt = lastVisitedAtByThreadKey.get(threadKey); + const lastVisitedAt = lastVisitedAtByThreadKey.get( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); return resolveThreadStatusPill({ thread: { ...thread, ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}), - hasPendingLocalDispatch: threadDispatchPendingById[threadKey] === true, }, }); }; @@ -1270,7 +1264,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec projectStatus, visibleProjectThreads, }; - }, [projectThreads, threadDispatchPendingById, threadLastVisitedAts, threadSortOrder]); + }, [projectThreads, threadLastVisitedAts, threadSortOrder]); const pinnedCollapsedThread = useMemo(() => { const activeThreadKey = activeRouteThreadKey ?? undefined; if (!activeThreadKey || projectExpanded) { @@ -1298,13 +1292,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ]), ); const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => { - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const lastVisitedAt = lastVisitedAtByThreadKey.get(threadKey); + const lastVisitedAt = lastVisitedAtByThreadKey.get( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); return resolveThreadStatusPill({ thread: { ...thread, ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}), - hasPendingLocalDispatch: threadDispatchPendingById[threadKey] === true, }, }); }; @@ -1342,7 +1336,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec projectExpanded, projectThreads, sidebarThreadPreviewCount, - threadDispatchPendingById, threadLastVisitedAts, visibleProjectThreads, ]); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index a0e23139623..3e85920d190 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -187,10 +187,8 @@ export function ThreadStatusLabel({ */ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) { const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const threadKey = scopedThreadKey(threadRef); - const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); - const hasPendingLocalDispatch = useUiStateStore( - (state) => state.threadDispatchPendingById[threadKey] === true, + const lastVisitedAt = useUiStateStore( + (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], ); const threadProject = useProject( useMemo( @@ -214,7 +212,6 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar thread: { ...thread, lastVisitedAt, - hasPendingLocalDispatch, }, }); diff --git a/apps/web/src/hooks/useRafThrottledValue.test.ts b/apps/web/src/hooks/useRafThrottledValue.test.ts deleted file mode 100644 index 40139c39eaa..00000000000 --- a/apps/web/src/hooks/useRafThrottledValue.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { useRafThrottledValue } from "./useRafThrottledValue"; - -type HookHarness = { - readonly value: string; - readonly enabled: boolean; - readonly displayed: string; - readonly scheduleFrame: () => void; -}; - -function createHookHarness(initialValue: string, initialEnabled: boolean): HookHarness { - let value = initialValue; - let enabled = initialEnabled; - let prevEnabled = initialEnabled; - let displayed = initialValue; - let frameId: number | null = null; - - const scheduleFrame = () => { - if (frameId !== null) { - return; - } - frameId = requestAnimationFrame(() => { - frameId = null; - if (enabled) { - displayed = value; - } - }); - }; - - const runEffect = () => { - if (!enabled) { - if (frameId !== null) { - cancelAnimationFrame(frameId); - frameId = null; - } - prevEnabled = false; - displayed = value; - return; - } - - if (!prevEnabled) { - displayed = value; - } - prevEnabled = true; - scheduleFrame(); - }; - - const setValue = (nextValue: string) => { - value = nextValue; - runEffect(); - }; - - const setEnabled = (nextEnabled: boolean) => { - enabled = nextEnabled; - runEffect(); - }; - - return { - get value() { - return value; - }, - get enabled() { - return enabled; - }, - get displayed() { - return enabled ? displayed : value; - }, - scheduleFrame, - setValue, - setEnabled, - } as HookHarness & { - setValue: (nextValue: string) => void; - setEnabled: (nextEnabled: boolean) => void; - }; -} - -describe("useRafThrottledValue", () => { - let rafCallbacks: Array; - - beforeEach(() => { - rafCallbacks = []; - const requestAnimationFrame = (callback: FrameRequestCallback) => { - rafCallbacks.push(callback); - return rafCallbacks.length; - }; - const cancelAnimationFrame = () => { - rafCallbacks = []; - }; - vi.stubGlobal("window", { requestAnimationFrame, cancelAnimationFrame }); - vi.stubGlobal("requestAnimationFrame", requestAnimationFrame); - vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - const flushRaf = () => { - const callbacks = [...rafCallbacks]; - rafCallbacks = []; - for (const callback of callbacks) { - callback(0); - } - }; - - it("exports a hook function", () => { - expect(typeof useRafThrottledValue).toBe("function"); - }); - - it("mirrors disabled streaming semantics: latest value is shown immediately", () => { - const harness = createHookHarness("first", false) as ReturnType & { - setValue: (nextValue: string) => void; - }; - - harness.setValue("second"); - expect(harness.displayed).toBe("second"); - expect(rafCallbacks).toHaveLength(0); - }); - - it("mirrors enabled streaming semantics: updates land on the next animation frame", () => { - const harness = createHookHarness("hello", true) as ReturnType & { - setValue: (nextValue: string) => void; - }; - - harness.setValue("hello world"); - expect(harness.displayed).toBe("hello"); - expect(rafCallbacks).toHaveLength(1); - - flushRaf(); - expect(harness.displayed).toBe("hello world"); - }); - - it("coalesces rapid value changes into one animation frame", () => { - const harness = createHookHarness("hello", true) as ReturnType & { - setValue: (nextValue: string) => void; - }; - - harness.setValue("hello "); - harness.setValue("hello w"); - harness.setValue("hello world"); - expect(harness.displayed).toBe("hello"); - expect(rafCallbacks).toHaveLength(1); - - flushRaf(); - expect(harness.displayed).toBe("hello world"); - }); - - it("mirrors stream-end semantics: disabling flushes the latest value", () => { - const harness = createHookHarness("partial", true) as ReturnType & { - setValue: (nextValue: string) => void; - setEnabled: (nextEnabled: boolean) => void; - }; - - harness.setValue("partial response"); - harness.setEnabled(false); - expect(harness.displayed).toBe("partial response"); - }); - - it("mirrors stream-start semantics: enabling shows the current value immediately", () => { - const harness = createHookHarness("partial", true) as ReturnType & { - setValue: (nextValue: string) => void; - setEnabled: (nextEnabled: boolean) => void; - }; - - harness.setValue("partial response"); - flushRaf(); - harness.setEnabled(false); - harness.setValue("fresh stream"); - harness.setEnabled(true); - expect(harness.displayed).toBe("fresh stream"); - }); -}); diff --git a/apps/web/src/hooks/useRafThrottledValue.ts b/apps/web/src/hooks/useRafThrottledValue.ts deleted file mode 100644 index 1851a0857f7..00000000000 --- a/apps/web/src/hooks/useRafThrottledValue.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useEffect, useRef, useState } from "react"; - -/** - * Caps React updates to one per animation frame while `enabled`. - * Use for high-frequency streaming text so markdown parsing stays smooth. - * When disabled, the latest value is returned synchronously with no lag. - */ -export function useRafThrottledValue(value: T, enabled: boolean): T { - const [displayed, setDisplayed] = useState(value); - const latestRef = useRef(value); - const frameRef = useRef(null); - const prevEnabledRef = useRef(enabled); - - latestRef.current = value; - - useEffect(() => { - if (!enabled) { - if (frameRef.current !== null) { - cancelAnimationFrame(frameRef.current); - frameRef.current = null; - } - prevEnabledRef.current = false; - setDisplayed(value); - return; - } - - if (!prevEnabledRef.current) { - setDisplayed(value); - } - prevEnabledRef.current = true; - - if (frameRef.current !== null) { - return; - } - - frameRef.current = requestAnimationFrame(() => { - frameRef.current = null; - setDisplayed(latestRef.current); - }); - }, [enabled, value]); - - useEffect(() => { - return () => { - if (frameRef.current !== null) { - cancelAnimationFrame(frameRef.current); - frameRef.current = null; - } - }; - }, []); - - return enabled ? displayed : value; -} diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 189227ff602..0f12e672f66 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1493,64 +1493,6 @@ describe("deriveWorkLogEntries", () => { }); describe("deriveTimelineEntries", () => { - it("orders reused Grok assistant segments by createdAt so they stay below the active user message", () => { - const entries = deriveTimelineEntries( - [ - { - id: MessageId.make("assistant-segment-0"), - role: "assistant", - text: "Health-check response.", - createdAt: "2026-06-24T01:12:00.260Z", - turnId: TurnId.make("turn-health"), - updatedAt: "2026-06-24T01:12:00.260Z", - streaming: false, - }, - { - id: MessageId.make("user-health"), - role: "user", - text: "does all seem well?", - createdAt: "2026-06-24T01:10:45.533Z", - turnId: null, - updatedAt: "2026-06-24T01:10:45.533Z", - streaming: false, - }, - ], - [], - [], - ); - - expect(entries.map((entry) => entry.id)).toEqual(["user-health", "assistant-segment-0"]); - }); - - it("keeps a completed assistant response above the next user message when updatedAt bumps without createdAt changes", () => { - const entries = deriveTimelineEntries( - [ - { - id: MessageId.make("assistant-yep"), - role: "assistant", - text: "Yep — that background task dying with exit 143...", - createdAt: "2026-06-24T01:00:00.000Z", - turnId: TurnId.make("turn-n"), - updatedAt: "2026-06-24T01:02:00.000Z", - streaming: false, - }, - { - id: MessageId.make("user-followup"), - role: "user", - text: "i see it now. what's the source of truth for those categories?", - createdAt: "2026-06-24T01:03:00.000Z", - turnId: null, - updatedAt: "2026-06-24T01:03:00.000Z", - streaming: false, - }, - ], - [], - [], - ); - - expect(entries.map((entry) => entry.id)).toEqual(["assistant-yep", "user-followup"]); - }); - it("includes proposed plans alongside messages and work entries in chronological order", () => { const entries = deriveTimelineEntries( [ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index d926a14a4d4..5d5051f748e 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1337,16 +1337,6 @@ function compareActivityLifecycleRank(kind: string): number { return 1; } -function timelineEntrySortAt(entry: TimelineEntry): string { - if (entry.kind === "message" && entry.message.role === "assistant") { - // Grok session/load reuses assistant segment ids across turns. Projection - // resets createdAt only when a rebound starts or turn binding changes, so - // sort by createdAt instead of mutable updatedAt streaming bumps. - return entry.message.createdAt; - } - return entry.createdAt; -} - export function deriveTimelineEntries( messages: ReadonlyArray, proposedPlans: ReadonlyArray, @@ -1371,7 +1361,7 @@ export function deriveTimelineEntries( entry, })); return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) => - timelineEntrySortAt(a).localeCompare(timelineEntrySortAt(b)), + a.createdAt.localeCompare(b.createdAt), ); } diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 76d9c9fc0de..4a97f0542b4 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -412,9 +412,6 @@ export function reorderProjects( } interface UiStateStore extends UiState { - /** Ephemeral: composer waiting for server acknowledgement after send. */ - threadDispatchPendingById: Record; - setThreadDispatchPending: (threadKey: string, pending: boolean) => void; markThreadVisited: (threadId: string, visitedAt: string) => void; markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; @@ -429,31 +426,6 @@ interface UiStateStore extends UiState { export const useUiStateStore = create((set) => ({ ...readPersistedState(), - threadDispatchPendingById: {}, - setThreadDispatchPending: (threadKey, pending) => - set((state) => { - if (pending) { - if (state.threadDispatchPendingById[threadKey] === true) { - return state; - } - return { - ...state, - threadDispatchPendingById: { - ...state.threadDispatchPendingById, - [threadKey]: true, - }, - }; - } - if (!(threadKey in state.threadDispatchPendingById)) { - return state; - } - const nextPending = { ...state.threadDispatchPendingById }; - delete nextPending[threadKey]; - return { - ...state, - threadDispatchPendingById: nextPending, - }; - }), markThreadVisited: (threadId, visitedAt) => set((state) => markThreadVisited(state, threadId, visitedAt)), markThreadUnread: (threadId, latestTurnCompletedAt) => diff --git a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts index 237135f7a74..e6eff1e5c21 100644 --- a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts +++ b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts @@ -32,7 +32,7 @@ const LEGACY_BASELINE = new Map([ ["apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts", 70], ["apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts", 31], ["apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts", 2], - ["apps/server/src/orchestration/projector.test.ts", 21], + ["apps/server/src/orchestration/projector.test.ts", 20], ["apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts", 4], ["apps/server/src/provider/acp/CursorAcpSupport.test.ts", 1], ["apps/server/src/provider/Layers/ClaudeAdapter.test.ts", 2], diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 56bce039018..94eb1c65370 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -9,7 +9,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import type { OrchestrationMessage, OrchestrationThread } from "@t3tools/contracts"; +import type { OrchestrationThread } from "@t3tools/contracts"; import { applyThreadDetailEvent } from "./threadReducer.ts"; @@ -219,308 +219,6 @@ describe("applyThreadDetailEvent", () => { } }); - it("resumes streaming on the active turn after segment-level completion", () => { - const threadWithRunningSession: OrchestrationThread = { - ...baseThread, - session: { - threadId: ThreadId.make("thread-1"), - status: "running", - providerName: "grok", - runtimeMode: "full-access", - activeTurnId: TurnId.make("turn-n"), - lastError: null, - updatedAt: "2026-06-24T01:02:00.000Z", - }, - messages: [ - { - id: MessageId.make("assistant-segment-0"), - role: "assistant", - text: "Done", - streaming: false, - turnId: TurnId.make("turn-n"), - createdAt: "2026-06-24T01:02:00.000Z", - updatedAt: "2026-06-24T01:02:00.500Z", - } as OrchestrationMessage, - ], - }; - - const result = applyThreadDetailEvent(threadWithRunningSession, { - ...baseEventFields, - sequence: 8, - occurredAt: "2026-06-24T01:02:01.000Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.message-sent", - payload: { - threadId: ThreadId.make("thread-1"), - messageId: MessageId.make("assistant-segment-0"), - role: "assistant", - text: " — same listing.", - turnId: TurnId.make("turn-n"), - streaming: true, - createdAt: "2026-06-24T01:02:01.000Z", - updatedAt: "2026-06-24T01:02:01.000Z", - }, - }); - - expect(result.kind).toBe("updated"); - if (result.kind === "updated") { - expect(result.thread.messages).toHaveLength(1); - expect(result.thread.messages[0]?.text).toBe("Done — same listing."); - expect(result.thread.messages[0]?.streaming).toBe(true); - } - }); - - it("accepts trailing streaming chunks for the same settled turn", () => { - const threadWithCompletedAssistant: OrchestrationThread = { - ...baseThread, - messages: [ - { - id: MessageId.make("assistant-yep"), - role: "assistant", - text: "Yep — that background task dying with exit 143...", - streaming: false, - turnId: TurnId.make("turn-n"), - createdAt: "2026-06-24T01:00:00.000Z", - updatedAt: "2026-06-24T01:02:00.000Z", - } as OrchestrationMessage, - ], - }; - - const result = applyThreadDetailEvent(threadWithCompletedAssistant, { - ...baseEventFields, - sequence: 8, - occurredAt: "2026-06-24T01:05:00.000Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.message-sent", - payload: { - threadId: ThreadId.make("thread-1"), - messageId: MessageId.make("assistant-yep"), - role: "assistant", - text: " trailing", - turnId: TurnId.make("turn-n"), - streaming: true, - createdAt: "2026-06-24T01:05:00.000Z", - updatedAt: "2026-06-24T01:05:00.000Z", - }, - }); - - expect(result.kind).toBe("updated"); - if (result.kind === "updated") { - expect(result.thread.messages[0]?.text).toBe( - "Yep — that background task dying with exit 143... trailing", - ); - expect(result.thread.messages[0]?.streaming).toBe(true); - } - }); - - it("does not regress interrupted latestTurn on late streaming chunks", () => { - const threadWithInterruptedTurn: OrchestrationThread = { - ...baseThread, - latestTurn: { - turnId: TurnId.make("turn-n"), - state: "interrupted", - requestedAt: "2026-06-24T01:00:00.000Z", - startedAt: "2026-06-24T01:00:00.000Z", - completedAt: "2026-06-24T01:02:00.000Z", - assistantMessageId: MessageId.make("assistant-yep"), - }, - messages: [ - { - id: MessageId.make("assistant-yep"), - role: "assistant", - text: "Partial", - streaming: true, - turnId: TurnId.make("turn-n"), - createdAt: "2026-06-24T01:00:00.000Z", - updatedAt: "2026-06-24T01:02:00.000Z", - } as OrchestrationMessage, - ], - }; - - const result = applyThreadDetailEvent(threadWithInterruptedTurn, { - ...baseEventFields, - sequence: 8, - occurredAt: "2026-06-24T01:05:00.000Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.message-sent", - payload: { - threadId: ThreadId.make("thread-1"), - messageId: MessageId.make("assistant-yep"), - role: "assistant", - text: " trailing", - turnId: TurnId.make("turn-n"), - streaming: true, - createdAt: "2026-06-24T01:05:00.000Z", - updatedAt: "2026-06-24T01:05:00.000Z", - }, - }); - - expect(result.kind).toBe("updated"); - if (result.kind === "updated") { - expect(result.thread.latestTurn?.state).toBe("interrupted"); - expect(result.thread.latestTurn?.completedAt).toBe("2026-06-24T01:02:00.000Z"); - } - }); - - it("archives replayed assistant rows when streaming binds a reused segment to a new turn", () => { - const threadWithReplayedSegment: OrchestrationThread = { - ...baseThread, - latestTurn: { - turnId: TurnId.make("turn-replay"), - state: "completed", - requestedAt: "2026-06-24T00:29:27.101Z", - startedAt: "2026-06-24T00:29:27.101Z", - completedAt: "2026-06-24T00:29:27.101Z", - assistantMessageId: MessageId.make("assistant-segment-0"), - }, - messages: [ - { - id: MessageId.make("assistant-segment-0"), - role: "assistant", - text: "Health-check response from replay.", - streaming: false, - turnId: TurnId.make("turn-replay"), - createdAt: "2026-06-24T00:29:27.101Z", - updatedAt: "2026-06-24T00:29:27.101Z", - } as OrchestrationMessage, - ], - }; - - const result = applyThreadDetailEvent(threadWithReplayedSegment, { - ...baseEventFields, - sequence: 7, - occurredAt: "2026-06-24T01:12:00.260Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.message-sent", - payload: { - threadId: ThreadId.make("thread-1"), - messageId: MessageId.make("assistant-segment-0"), - role: "assistant", - text: "New ", - turnId: TurnId.make("turn-follow-up"), - streaming: true, - createdAt: "2026-06-24T01:12:00.260Z", - updatedAt: "2026-06-24T01:12:00.260Z", - }, - }); - - expect(result.kind).toBe("updated"); - if (result.kind === "updated") { - expect(result.thread.messages).toHaveLength(2); - expect(result.thread.messages[0]?.text).toBe("Health-check response from replay."); - expect(result.thread.messages[1]?.text).toBe("New "); - expect(result.thread.messages[1]?.turnId).toBe("turn-follow-up"); - expect(result.thread.latestTurn?.turnId).toBe("turn-follow-up"); - expect(result.thread.latestTurn?.state).toBe("running"); - expect(result.thread.latestTurn?.assistantMessageId).toBe("assistant-segment-0"); - } - }); - - it("does not inherit a prior rebound turn's terminal latestTurn state", () => { - const threadWithFailedReplay: OrchestrationThread = { - ...baseThread, - latestTurn: { - turnId: TurnId.make("turn-replay"), - state: "error", - requestedAt: "2026-06-24T00:29:27.101Z", - startedAt: "2026-06-24T00:29:27.101Z", - completedAt: "2026-06-24T00:29:27.101Z", - assistantMessageId: MessageId.make("assistant-segment-0"), - }, - messages: [ - { - id: MessageId.make("assistant-segment-0"), - role: "assistant", - text: "Failed replay response.", - streaming: false, - turnId: TurnId.make("turn-replay"), - createdAt: "2026-06-24T00:29:27.101Z", - updatedAt: "2026-06-24T00:29:27.101Z", - } as OrchestrationMessage, - ], - }; - - const result = applyThreadDetailEvent(threadWithFailedReplay, { - ...baseEventFields, - sequence: 7, - occurredAt: "2026-06-24T01:12:00.260Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.message-sent", - payload: { - threadId: ThreadId.make("thread-1"), - messageId: MessageId.make("assistant-segment-0"), - role: "assistant", - text: "Recovered response.", - turnId: TurnId.make("turn-follow-up"), - streaming: false, - createdAt: "2026-06-24T01:12:00.260Z", - updatedAt: "2026-06-24T01:12:00.260Z", - }, - }); - - expect(result.kind).toBe("updated"); - if (result.kind === "updated") { - expect(result.thread.latestTurn?.turnId).toBe("turn-follow-up"); - expect(result.thread.latestTurn?.state).toBe("completed"); - } - }); - - it("rewrites checkpoint assistant ids after a later assistant message", () => { - const threadWithCheckpoint: OrchestrationThread = { - ...baseThread, - latestTurn: { - turnId: TurnId.make("turn-1"), - state: "completed", - requestedAt: "2026-04-01T06:59:00.000Z", - startedAt: "2026-04-01T06:59:00.000Z", - completedAt: "2026-04-01T07:00:00.000Z", - assistantMessageId: MessageId.make("msg-first"), - }, - checkpoints: [ - { - turnId: TurnId.make("turn-1"), - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.make("ref-1"), - status: "ready", - files: [], - assistantMessageId: MessageId.make("msg-first"), - completedAt: "2026-04-01T07:00:00.000Z", - }, - ], - }; - - const result = applyThreadDetailEvent(threadWithCheckpoint, { - ...baseEventFields, - sequence: 8, - occurredAt: "2026-04-01T07:01:00.000Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.message-sent", - payload: { - threadId: ThreadId.make("thread-1"), - messageId: MessageId.make("msg-later"), - role: "assistant", - text: "Later commentary.", - turnId: TurnId.make("turn-1"), - streaming: false, - createdAt: "2026-04-01T07:01:00.000Z", - updatedAt: "2026-04-01T07:01:00.000Z", - }, - }); - - expect(result.kind).toBe("updated"); - if (result.kind === "updated") { - expect(result.thread.checkpoints[0]?.assistantMessageId).toBe("msg-later"); - expect(result.thread.latestTurn?.assistantMessageId).toBe("msg-later"); - expect(result.thread.latestTurn?.completedAt).toBe("2026-04-01T07:00:00.000Z"); - } - }); - it("appends text for streaming messages", () => { const threadWithMessage: OrchestrationThread = { ...baseThread, @@ -591,55 +289,6 @@ describe("applyThreadDetailEvent", () => { } }); - it("advances latestTurn when a fresh assistant message belongs to the active turn", () => { - const threadWithCompletedTurn: OrchestrationThread = { - ...baseThread, - session: { - threadId: ThreadId.make("thread-1"), - status: "running", - providerName: "grok", - runtimeMode: "full-access", - activeTurnId: TurnId.make("turn-2"), - lastError: null, - updatedAt: "2026-04-01T08:00:00.000Z", - }, - latestTurn: { - turnId: TurnId.make("turn-1"), - state: "completed", - requestedAt: "2026-04-01T07:00:00.000Z", - startedAt: "2026-04-01T07:00:00.000Z", - completedAt: "2026-04-01T07:05:00.000Z", - assistantMessageId: MessageId.make("msg-1"), - }, - }; - - const result = applyThreadDetailEvent(threadWithCompletedTurn, { - ...baseEventFields, - sequence: 9, - occurredAt: "2026-04-01T08:01:00.000Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.message-sent", - payload: { - threadId: ThreadId.make("thread-1"), - messageId: MessageId.make("msg-2"), - role: "assistant", - text: "Working", - turnId: TurnId.make("turn-2"), - streaming: true, - createdAt: "2026-04-01T08:01:00.000Z", - updatedAt: "2026-04-01T08:01:00.000Z", - }, - }); - - expect(result.kind).toBe("updated"); - if (result.kind === "updated") { - expect(result.thread.latestTurn?.turnId).toBe("turn-2"); - expect(result.thread.latestTurn?.state).toBe("running"); - expect(result.thread.latestTurn?.assistantMessageId).toBe("msg-2"); - } - }); - it("keeps latestTurn running for interim assistant messages while the session runs the turn", () => { const threadWithRunningSession: OrchestrationThread = { ...baseThread, diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 095f027c8c9..670540fee70 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -12,13 +12,6 @@ import type { OrchestrationThreadActivity, TurnId, } from "@t3tools/contracts"; -import { - assistantSegmentBelongsToActiveTurn, - applyAssistantSegmentMessageUpdate, - type AssistantSegmentThreadMessage, - repointCheckpointsForArchivedAssistantSegment, - repointLatestTurnForArchivedAssistantSegment, -} from "@t3tools/shared/orchestrationMessages"; export type ThreadDetailReducerResult = | { readonly kind: "updated"; readonly thread: OrchestrationThread } @@ -198,107 +191,76 @@ export function applyThreadDetailEvent( updatedAt: event.payload.updatedAt, }; - const existingMessage = thread.messages.find((entry) => entry.id === event.payload.messageId); - const turnStillRunning = - thread.session?.status === "running" && - assistantSegmentBelongsToActiveTurn({ - activeTurnId: thread.session.activeTurnId, - existingTurnId: existingMessage?.turnId, - incomingTurnId: event.payload.turnId, - }); - const applied = applyAssistantSegmentMessageUpdate( - thread.messages as ReadonlyArray, - message, - { - activeTurnId: thread.session?.activeTurnId ?? null, - turnStillActive: turnStillRunning === true, - }, - ); - if (applied.messages === thread.messages) { - return { kind: "unchanged" }; - } - const messages = applied.messages as OrchestrationMessage[]; + const existingMessage = thread.messages.find((entry) => entry.id === message.id); + const messages = existingMessage + ? Arr.map(thread.messages, (entry) => + entry.id !== message.id + ? entry + : { + ...entry, + text: message.streaming + ? `${entry.text}${message.text}` + : message.text.length > 0 + ? message.text + : entry.text, + streaming: message.streaming, + ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), + ...(message.streaming ? {} : { updatedAt: message.updatedAt }), + ...(message.attachments !== undefined + ? { attachments: message.attachments } + : {}), + }, + ) + : Arr.append(thread.messages, message); // Update latestTurn for assistant messages bound to a turn. A completed // assistant message only settles the turn once the session is no longer // running it — providers may emit several assistant messages per turn // (commentary between tool calls), and the turn must stay unsettled // until the provider reports turn end. + const turnStillRunning = + event.payload.turnId !== null && + thread.session?.status === "running" && + thread.session.activeTurnId === event.payload.turnId; const settlesTurn = !event.payload.streaming && !turnStillRunning; - let latestTurn: OrchestrationThread["latestTurn"] = thread.latestTurn; - const checkpointRepointAdvancesLatestTurn = - applied.checkpointsToRepoint !== undefined && - (latestTurn === null || latestTurn.turnId === applied.checkpointsToRepoint.archivedTurnId); - const shouldAdvanceLatestTurn = + const latestTurn: OrchestrationThread["latestTurn"] = event.payload.role === "assistant" && event.payload.turnId !== null && - (latestTurn === null || - latestTurn.turnId === event.payload.turnId || - turnStillRunning || - checkpointRepointAdvancesLatestTurn); - const preservedLatestTurn = - latestTurn?.turnId === event.payload.turnId && - !turnStillRunning && - (latestTurn.state === "completed" || - latestTurn.state === "interrupted" || - latestTurn.state === "error") - ? latestTurn - : null; - const nextLatestTurnState = preservedLatestTurn - ? preservedLatestTurn.state - : settlesTurn - ? latestTurn?.turnId === event.payload.turnId && latestTurn.state === "interrupted" - ? "interrupted" - : latestTurn?.turnId === event.payload.turnId && latestTurn.state === "error" - ? "error" - : "completed" - : "running"; - const nextLatestTurnCompletedAt = preservedLatestTurn - ? preservedLatestTurn.completedAt - : settlesTurn - ? latestTurn?.turnId === event.payload.turnId - ? (latestTurn.completedAt ?? event.payload.updatedAt) - : event.payload.updatedAt - : latestTurn?.turnId === event.payload.turnId - ? (latestTurn.completedAt ?? null) - : null; - latestTurn = shouldAdvanceLatestTurn - ? { - turnId: event.payload.turnId, - state: nextLatestTurnState, - requestedAt: - latestTurn?.turnId === event.payload.turnId - ? latestTurn.requestedAt - : event.payload.createdAt, - startedAt: - latestTurn?.turnId === event.payload.turnId - ? (latestTurn.startedAt ?? event.payload.createdAt) - : event.payload.createdAt, - completedAt: nextLatestTurnCompletedAt, - assistantMessageId: event.payload.messageId, - } - : latestTurn; - if (applied.checkpointsToRepoint) { - latestTurn = repointLatestTurnForArchivedAssistantSegment( - latestTurn, - applied.checkpointsToRepoint, - ); - } + (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) + ? { + turnId: event.payload.turnId, + state: settlesTurn + ? thread.latestTurn?.state === "interrupted" + ? "interrupted" + : thread.latestTurn?.state === "error" + ? "error" + : "completed" + : "running", + requestedAt: + thread.latestTurn?.turnId === event.payload.turnId + ? thread.latestTurn.requestedAt + : event.payload.createdAt, + startedAt: + thread.latestTurn?.turnId === event.payload.turnId + ? (thread.latestTurn.startedAt ?? event.payload.createdAt) + : event.payload.createdAt, + completedAt: settlesTurn + ? event.payload.updatedAt + : thread.latestTurn?.turnId === event.payload.turnId + ? (thread.latestTurn.completedAt ?? null) + : null, + assistantMessageId: event.payload.messageId, + } + : thread.latestTurn; - let checkpoints = applied.checkpointsToRepoint - ? repointCheckpointsForArchivedAssistantSegment( - thread.checkpoints, - applied.checkpointsToRepoint.providerMessageId, - applied.checkpointsToRepoint.archivedMessageId, - applied.checkpointsToRepoint.archivedTurnId, - ) - : thread.checkpoints; - if (event.payload.role === "assistant" && event.payload.turnId !== null) { - checkpoints = rebindCheckpointAssistantMessage( - checkpoints, - event.payload.turnId, - event.payload.messageId, - ); - } + // Rebind checkpoint assistant message IDs for assistant messages. + const checkpoints = + event.payload.role === "assistant" && event.payload.turnId !== null + ? rebindCheckpointAssistantMessage( + thread.checkpoints, + event.payload.turnId, + event.payload.messageId, + ) + : thread.checkpoints; return { kind: "updated", diff --git a/packages/shared/package.json b/packages/shared/package.json index b68630a1484..5c56aaf6997 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -87,10 +87,6 @@ "types": "./src/orchestrationTiming.ts", "import": "./src/orchestrationTiming.ts" }, - "./orchestrationMessages": { - "types": "./src/orchestrationMessages.ts", - "import": "./src/orchestrationMessages.ts" - }, "./remote": { "types": "./src/remote.ts", "import": "./src/remote.ts" diff --git a/packages/shared/src/orchestrationMessages.test.ts b/packages/shared/src/orchestrationMessages.test.ts deleted file mode 100644 index d8913fd3e81..00000000000 --- a/packages/shared/src/orchestrationMessages.test.ts +++ /dev/null @@ -1,690 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - applyAssistantSegmentMessageUpdate, - type AssistantSegmentThreadMessage, - assistantSegmentBelongsToActiveTurn, - assistantSegmentStreamingTextResets, - assistantSegmentTurnChanged, - archivedAssistantSegmentMessageId, - archivedAssistantSegmentTurnIds, - isLateAssistantSegmentFromPriorTurn, - isLateStreamingOnCompletedAssistant, - repointCheckpointsForArchivedAssistantSegment, - resolveAssistantSegmentText, -} from "./orchestrationMessages.ts"; - -describe("orchestrationMessages", () => { - it("detects assistant segment turn rebinding", () => { - expect( - assistantSegmentTurnChanged({ turnId: "turn-a", streaming: false }, { turnId: "turn-b" }), - ).toBe(true); - expect( - assistantSegmentTurnChanged({ turnId: "turn-a", streaming: false }, { turnId: "turn-a" }), - ).toBe(false); - }); - - it("treats in-flight null turnId bind as continuation", () => { - expect( - assistantSegmentTurnChanged({ turnId: null, streaming: true }, { turnId: "turn-a" }), - ).toBe(false); - }); - - it("only treats null-turn chunks as active when the existing row belongs to the active turn", () => { - expect( - assistantSegmentBelongsToActiveTurn({ - activeTurnId: "turn-b", - existingTurnId: "turn-a", - incomingTurnId: null, - }), - ).toBe(false); - expect( - assistantSegmentBelongsToActiveTurn({ - activeTurnId: "turn-b", - existingTurnId: "turn-b", - incomingTurnId: null, - }), - ).toBe(true); - expect( - assistantSegmentBelongsToActiveTurn({ - activeTurnId: "turn-b", - existingTurnId: undefined, - incomingTurnId: null, - }), - ).toBe(true); - }); - - it("treats completed replay rebind as a turn change", () => { - expect( - assistantSegmentTurnChanged({ turnId: null, streaming: false }, { turnId: "turn-a" }), - ).toBe(true); - }); - - it("accepts trailing streaming chunks for the same settled turn", () => { - expect( - isLateStreamingOnCompletedAssistant({ - existing: { - role: "assistant", - streaming: false, - turnId: "turn-a", - }, - incoming: { - role: "assistant", - streaming: true, - turnId: "turn-a", - }, - }), - ).toBe(false); - }); - - it("accepts resumed streaming on the active turn after segment-level completion", () => { - expect( - isLateStreamingOnCompletedAssistant({ - existing: { - role: "assistant", - streaming: false, - turnId: "turn-a", - }, - incoming: { - role: "assistant", - streaming: true, - turnId: "turn-a", - }, - turnStillActive: true, - }), - ).toBe(false); - }); - - it("still accepts streaming chunks for in-flight assistant messages", () => { - expect( - isLateStreamingOnCompletedAssistant({ - existing: { - role: "assistant", - streaming: true, - turnId: "turn-a", - }, - incoming: { - role: "assistant", - streaming: true, - turnId: "turn-a", - }, - }), - ).toBe(false); - }); - - it("still accepts the first rebound delta before turnId is known", () => { - expect( - isLateStreamingOnCompletedAssistant({ - existing: { - role: "assistant", - streaming: false, - turnId: "turn-a", - }, - incoming: { - role: "assistant", - streaming: true, - turnId: null, - }, - }), - ).toBe(false); - }); - - it("still accepts assistant chunks when the segment rebinds to a new turn", () => { - expect( - isLateStreamingOnCompletedAssistant({ - existing: { - role: "assistant", - streaming: false, - turnId: "turn-a", - }, - incoming: { - role: "assistant", - streaming: true, - turnId: "turn-b", - }, - }), - ).toBe(false); - }); - - it("resets streaming text on the first null-turn rebound chunk", () => { - expect( - assistantSegmentStreamingTextResets( - { role: "assistant", streaming: false, turnId: "turn-a" }, - { streaming: true, turnId: null }, - ), - ).toBe(true); - expect( - assistantSegmentStreamingTextResets( - { role: "assistant", streaming: true, turnId: null }, - { streaming: true, turnId: "turn-a" }, - ), - ).toBe(false); - }); - - it("keeps appending null-turn chunks while the turn is still active", () => { - expect( - assistantSegmentStreamingTextResets( - { role: "assistant", streaming: false, turnId: "turn-a" }, - { streaming: true, turnId: null }, - { activeTurnId: "turn-a", turnStillActive: true }, - ), - ).toBe(false); - }); - - it("resets null-turn rebound chunks when the completed row belongs to a prior turn", () => { - expect( - assistantSegmentStreamingTextResets( - { role: "assistant", streaming: false, turnId: "turn-a" }, - { streaming: true, turnId: null }, - { activeTurnId: "turn-b", turnStillActive: true }, - ), - ).toBe(true); - }); - - it("keeps appending when a null-turn segment resumes streaming", () => { - expect( - assistantSegmentStreamingTextResets( - { role: "assistant", streaming: false, turnId: null }, - { streaming: true, turnId: null }, - ), - ).toBe(false); - }); - - it("preserves completed text when the provider emits an empty completion", () => { - expect( - resolveAssistantSegmentText({ text: "Hello" }, { text: "", streaming: false }, false), - ).toBe("Hello"); - }); - - it("drops stale final assistant segments from an older turn", () => { - expect( - isLateAssistantSegmentFromPriorTurn({ - existing: { - role: "assistant", - streaming: false, - turnId: "turn-b", - }, - incoming: { - role: "assistant", - streaming: false, - turnId: "turn-a", - }, - providerMessageId: "assistant-segment-0", - archivedTurnIds: new Set(["turn-a"]), - }), - ).toBe(true); - }); - - it("accepts forward rebound completions when the prior turn is not archived yet", () => { - expect( - isLateAssistantSegmentFromPriorTurn({ - existing: { - role: "assistant", - streaming: false, - turnId: "turn-a", - }, - incoming: { - role: "assistant", - streaming: false, - turnId: "turn-b", - }, - providerMessageId: "assistant-segment-0", - archivedTurnIds: new Set(), - }), - ).toBe(false); - }); - - it("drops stale streaming assistant segments from an older turn", () => { - expect( - isLateAssistantSegmentFromPriorTurn({ - existing: { - role: "assistant", - streaming: false, - turnId: "turn-b", - }, - incoming: { - role: "assistant", - streaming: true, - turnId: "turn-a", - }, - providerMessageId: "assistant-segment-0", - archivedTurnIds: new Set(["turn-a"]), - }), - ).toBe(true); - }); - - it("accepts forward rebound streaming when the prior turn is not archived yet", () => { - expect( - isLateAssistantSegmentFromPriorTurn({ - existing: { - role: "assistant", - streaming: false, - turnId: "turn-a", - }, - incoming: { - role: "assistant", - streaming: true, - turnId: "turn-b", - }, - providerMessageId: "assistant-segment-0", - archivedTurnIds: new Set(), - }), - ).toBe(false); - }); - - it("drops null-turn stale chunks after an archived rebound settles", () => { - expect( - isLateAssistantSegmentFromPriorTurn({ - existing: { - role: "assistant", - streaming: false, - turnId: "turn-b", - }, - incoming: { - role: "assistant", - streaming: true, - turnId: null, - }, - providerMessageId: "assistant-segment-0", - archivedTurnIds: new Set(["turn-a"]), - }), - ).toBe(true); - }); - - it("tracks archived replay rows with null turn ids", () => { - const archivedTurnIds = archivedAssistantSegmentTurnIds( - [ - { - id: archivedAssistantSegmentMessageId("assistant-segment-0", null), - turnId: null, - }, - ], - "assistant-segment-0", - ); - - expect(archivedTurnIds.has(null)).toBe(true); - }); - - it("drops stale null-turn chunks after an archived replay row", () => { - expect( - isLateAssistantSegmentFromPriorTurn({ - existing: { - role: "assistant", - streaming: false, - turnId: null, - }, - incoming: { - role: "assistant", - streaming: true, - turnId: null, - }, - providerMessageId: "assistant-segment-0", - archivedTurnIds: new Set([null]), - }), - ).toBe(true); - }); - - it("accepts null-turn completed rows after an archived rebound settles", () => { - expect( - isLateAssistantSegmentFromPriorTurn({ - existing: { - role: "assistant", - streaming: true, - turnId: "turn-b", - }, - incoming: { - role: "assistant", - streaming: false, - turnId: null, - }, - providerMessageId: "assistant-segment-0", - archivedTurnIds: new Set(["turn-a"]), - }), - ).toBe(false); - }); - - it("accepts null-turn rebound chunks while a prompt is active", () => { - expect( - isLateAssistantSegmentFromPriorTurn({ - existing: { - role: "assistant", - streaming: false, - turnId: "turn-b", - }, - incoming: { - role: "assistant", - streaming: true, - turnId: null, - }, - providerMessageId: "assistant-segment-0", - archivedTurnIds: new Set(["turn-a"]), - turnStillActive: true, - }), - ).toBe(false); - }); - - it("archives completed replay rows on the first null-turn rebound chunk", () => { - const result = applyAssistantSegmentMessageUpdate( - [ - { - id: "assistant-segment-0", - role: "assistant", - text: "Replayed response.", - streaming: false, - turnId: "turn-a", - createdAt: "2026-06-24T00:29:27.101Z", - updatedAt: "2026-06-24T00:29:27.101Z", - }, - ], - { - id: "assistant-segment-0", - role: "assistant", - text: "New ", - streaming: true, - turnId: null, - createdAt: "2026-06-24T01:12:00.260Z", - updatedAt: "2026-06-24T01:12:00.260Z", - }, - { activeTurnId: "turn-b", turnStillActive: true }, - ); - - expect(result.messages).toEqual([ - { - id: archivedAssistantSegmentMessageId("assistant-segment-0", "turn-a"), - role: "assistant", - text: "Replayed response.", - streaming: false, - turnId: "turn-a", - createdAt: "2026-06-24T00:29:27.101Z", - updatedAt: "2026-06-24T00:29:27.101Z", - }, - { - id: "assistant-segment-0", - role: "assistant", - text: "New ", - streaming: true, - turnId: null, - createdAt: "2026-06-24T01:12:00.260Z", - updatedAt: "2026-06-24T01:12:00.260Z", - }, - ]); - }); - - it("keeps same-active null-turn chunks on the existing assistant row", () => { - const result = applyAssistantSegmentMessageUpdate( - [ - { - id: "assistant-segment-0", - role: "assistant", - text: "Still active.", - streaming: false, - turnId: "turn-a", - createdAt: "2026-06-24T01:12:00.260Z", - updatedAt: "2026-06-24T01:12:00.260Z", - }, - ], - { - id: "assistant-segment-0", - role: "assistant", - text: " More.", - streaming: true, - turnId: null, - createdAt: "2026-06-24T01:12:01.000Z", - updatedAt: "2026-06-24T01:12:01.000Z", - }, - { activeTurnId: "turn-a", turnStillActive: true }, - ); - - expect(result.messages).toEqual([ - { - id: "assistant-segment-0", - role: "assistant", - text: "Still active. More.", - streaming: true, - turnId: null, - createdAt: "2026-06-24T01:12:00.260Z", - updatedAt: "2026-06-24T01:12:01.000Z", - }, - ]); - }); - - it("drops stale null-turn chunks when another turn owns the active prompt", () => { - const messages: AssistantSegmentThreadMessage[] = [ - { - id: archivedAssistantSegmentMessageId("assistant-segment-0", "turn-a"), - role: "assistant", - text: "Archived response.", - streaming: false, - turnId: "turn-a", - createdAt: "2026-06-24T00:29:27.101Z", - updatedAt: "2026-06-24T00:29:27.101Z", - }, - { - id: "assistant-segment-0", - role: "assistant", - text: "Current response.", - streaming: false, - turnId: "turn-b", - createdAt: "2026-06-24T01:12:00.260Z", - updatedAt: "2026-06-24T01:12:00.260Z", - }, - ]; - - const result = applyAssistantSegmentMessageUpdate( - messages, - { - id: "assistant-segment-0", - role: "assistant", - text: " stale", - streaming: true, - turnId: null, - createdAt: "2026-06-24T01:12:01.000Z", - updatedAt: "2026-06-24T01:12:01.000Z", - }, - { activeTurnId: "turn-c", turnStillActive: false }, - ); - - expect(result.messages).toBe(messages); - }); - - it("archives prior-turn assistant rows when a reused segment rebinds via completion", () => { - const result = applyAssistantSegmentMessageUpdate( - [ - { - id: "assistant-segment-0", - role: "assistant", - text: "Replayed response.", - streaming: false, - turnId: "turn-a", - createdAt: "2026-06-24T00:29:27.101Z", - updatedAt: "2026-06-24T00:29:27.101Z", - }, - ], - { - id: "assistant-segment-0", - role: "assistant", - text: "New completed response.", - streaming: false, - turnId: "turn-b", - createdAt: "2026-06-24T01:12:00.260Z", - updatedAt: "2026-06-24T01:12:00.260Z", - }, - ); - - expect(result.messages).toEqual([ - { - id: archivedAssistantSegmentMessageId("assistant-segment-0", "turn-a"), - role: "assistant", - text: "Replayed response.", - streaming: false, - turnId: "turn-a", - createdAt: "2026-06-24T00:29:27.101Z", - updatedAt: "2026-06-24T00:29:27.101Z", - }, - { - id: "assistant-segment-0", - role: "assistant", - text: "New completed response.", - streaming: false, - turnId: "turn-b", - createdAt: "2026-06-24T01:12:00.260Z", - updatedAt: "2026-06-24T01:12:00.260Z", - }, - ]); - }); - - it("appends rebound live rows after newer messages", () => { - const result = applyAssistantSegmentMessageUpdate( - [ - { - id: "assistant-segment-0", - role: "assistant", - text: "Replayed response.", - streaming: false, - turnId: "turn-a", - createdAt: "2026-06-24T00:29:27.101Z", - updatedAt: "2026-06-24T00:29:27.101Z", - }, - { - id: "user-follow-up", - role: "user", - text: "Next prompt", - streaming: false, - turnId: "turn-b", - createdAt: "2026-06-24T01:11:59.000Z", - updatedAt: "2026-06-24T01:11:59.000Z", - }, - ], - { - id: "assistant-segment-0", - role: "assistant", - text: "New response.", - streaming: false, - turnId: "turn-b", - createdAt: "2026-06-24T01:12:00.260Z", - updatedAt: "2026-06-24T01:12:00.260Z", - }, - ); - - expect(result.messages.map((message) => message.id)).toEqual([ - archivedAssistantSegmentMessageId("assistant-segment-0", "turn-a"), - "user-follow-up", - "assistant-segment-0", - ]); - }); - - it("uses the replay occurrence when archiving null-turn completed rows", () => { - const result = applyAssistantSegmentMessageUpdate( - [ - { - id: "assistant-segment-0", - role: "assistant", - text: "Null replay response.", - streaming: false, - turnId: null, - createdAt: "2026-06-24T00:29:27.101Z", - updatedAt: "2026-06-24T00:29:27.101Z", - }, - ], - { - id: "assistant-segment-0", - role: "assistant", - text: "New ", - streaming: true, - turnId: "turn-a", - createdAt: "2026-06-24T01:12:00.260Z", - updatedAt: "2026-06-24T01:12:00.260Z", - }, - ); - - expect(result.messages[0]?.id).toBe( - archivedAssistantSegmentMessageId("assistant-segment-0", null, "2026-06-24T00:29:27.101Z"), - ); - expect(result.messages[1]?.id).toBe("assistant-segment-0"); - }); - - it("archives prior-turn assistant rows when a reused segment rebinds", () => { - const result = applyAssistantSegmentMessageUpdate( - [ - { - id: "assistant-segment-0", - role: "assistant", - text: "Replayed response.", - streaming: false, - turnId: "turn-a", - createdAt: "2026-06-24T00:29:27.101Z", - updatedAt: "2026-06-24T00:29:27.101Z", - }, - ], - { - id: "assistant-segment-0", - role: "assistant", - text: "New ", - streaming: true, - turnId: "turn-b", - createdAt: "2026-06-24T01:12:00.260Z", - updatedAt: "2026-06-24T01:12:00.260Z", - }, - ); - - expect(result.messages).toEqual([ - { - id: archivedAssistantSegmentMessageId("assistant-segment-0", "turn-a"), - role: "assistant", - text: "Replayed response.", - streaming: false, - turnId: "turn-a", - createdAt: "2026-06-24T00:29:27.101Z", - updatedAt: "2026-06-24T00:29:27.101Z", - }, - { - id: "assistant-segment-0", - role: "assistant", - text: "New ", - streaming: true, - turnId: "turn-b", - createdAt: "2026-06-24T01:12:00.260Z", - updatedAt: "2026-06-24T01:12:00.260Z", - }, - ]); - expect(result.checkpointsToRepoint).toEqual({ - providerMessageId: "assistant-segment-0", - archivedMessageId: archivedAssistantSegmentMessageId("assistant-segment-0", "turn-a"), - archivedTurnId: "turn-a", - }); - }); - - it("repoints checkpoint assistant message ids to the archived row", () => { - const checkpoints = repointCheckpointsForArchivedAssistantSegment( - [ - { - turnId: "turn-a", - assistantMessageId: "assistant-segment-0", - }, - ], - "assistant-segment-0", - archivedAssistantSegmentMessageId("assistant-segment-0", "turn-a"), - "turn-a", - ); - - expect(checkpoints[0]?.assistantMessageId).toBe( - archivedAssistantSegmentMessageId("assistant-segment-0", "turn-a"), - ); - }); - - it("clears checkpoint assistant message ids when the archived row is not retained", () => { - const checkpoints = repointCheckpointsForArchivedAssistantSegment( - [ - { - turnId: "turn-a", - assistantMessageId: "assistant-segment-0", - }, - ], - "assistant-segment-0", - null, - "turn-a", - ); - - expect(checkpoints[0]?.assistantMessageId).toBeNull(); - }); -}); diff --git a/packages/shared/src/orchestrationMessages.ts b/packages/shared/src/orchestrationMessages.ts deleted file mode 100644 index a83c6401c98..00000000000 --- a/packages/shared/src/orchestrationMessages.ts +++ /dev/null @@ -1,396 +0,0 @@ -type AssistantSegmentMessage = { - readonly role: string; - readonly streaming: boolean; - readonly turnId: string | null; -}; - -export type AssistantSegmentThreadMessage = AssistantSegmentMessage & { - readonly id: string; - readonly text: string; - readonly createdAt: string; - readonly updatedAt: string; - readonly attachments?: ReadonlyArray | undefined; -}; - -export function assistantSegmentTurnChanged( - existing: Pick | undefined, - incoming: Pick, -): boolean { - if (existing === undefined || incoming.turnId == null) { - return false; - } - if (existing.turnId != null) { - return existing.turnId !== incoming.turnId; - } - // In-flight rebound: keep appending until the segment settles. Completed replay - // rows without a turnId are rebounding to a new live turn and should reset. - return !existing.streaming; -} - -/** - * Grok can deliver trailing session/update chunks after a turn has already - * settled. Ignore cross-turn late streaming deltas so completed assistant rows - * keep a stable timeline anchor for ordering, but keep appending when the - * provider is still finishing the same turn (for example after prompt_complete - * races ahead of trailing session/update chunks). - */ -export function isLateStreamingOnCompletedAssistant(input: { - readonly existing: AssistantSegmentMessage | undefined; - readonly incoming: AssistantSegmentMessage; - readonly turnStillActive?: boolean; -}): boolean { - if (input.incoming.role !== "assistant" || !input.incoming.streaming) { - return false; - } - const existing = input.existing; - if (existing === undefined || existing.role !== "assistant" || existing.streaming) { - return false; - } - // Provider ingestion can emit the first rebound delta before turnId is known. - if (input.incoming.turnId === null) { - return false; - } - if (input.turnStillActive) { - return false; - } - if ( - input.incoming.turnId !== null && - existing.turnId !== null && - existing.turnId === input.incoming.turnId - ) { - return false; - } - return !assistantSegmentTurnChanged(existing, input.incoming); -} - -/** - * Grok can deliver a stale assistant segment for an older turn after the live - * provider message id has already advanced to a newer turn. - */ -export function isLateAssistantSegmentFromPriorTurn(input: { - readonly existing: AssistantSegmentMessage | undefined; - readonly incoming: AssistantSegmentMessage; - readonly providerMessageId?: string; - readonly archivedTurnIds?: ReadonlySet; - readonly turnStillActive?: boolean; -}): boolean { - if (input.incoming.role !== "assistant" || input.existing?.role !== "assistant") { - return false; - } - if (input.incoming.turnId === null) { - const archivedTurnIds = input.archivedTurnIds; - return ( - input.incoming.streaming && - archivedTurnIds !== undefined && - archivedTurnIds.size > 0 && - input.turnStillActive !== true - ); - } - if (input.existing.turnId === input.incoming.turnId) { - return false; - } - const providerMessageId = input.providerMessageId; - if (providerMessageId === undefined) { - return false; - } - const archivedTurnIds = input.archivedTurnIds; - if (archivedTurnIds !== undefined) { - return archivedTurnIds.has(input.incoming.turnId); - } - return false; -} - -export function assistantSegmentBelongsToActiveTurn(input: { - readonly activeTurnId: string | null | undefined; - readonly existingTurnId: string | null | undefined; - readonly incomingTurnId: string | null; -}): boolean { - const activeTurnId = input.activeTurnId ?? null; - if (activeTurnId === null) { - return false; - } - if (input.incomingTurnId !== null) { - return input.incomingTurnId === activeTurnId; - } - return ( - input.existingTurnId === undefined || - input.existingTurnId === null || - input.existingTurnId === activeTurnId - ); -} - -export function archivedAssistantSegmentTurnIds( - messages: ReadonlyArray<{ readonly id: string; readonly turnId: string | null }>, - providerMessageId: string, -): ReadonlySet { - const prefix = `${providerMessageId}@turn:`; - const archivedTurnIds = new Set(); - for (const message of messages) { - if (message.id === undefined || !message.id.startsWith(prefix)) { - continue; - } - archivedTurnIds.add(message.turnId); - } - return archivedTurnIds; -} - -export function assistantSegmentRebindArchives( - existing: AssistantSegmentMessage | undefined, - incoming: Pick, - options?: { - readonly activeTurnId?: string | null; - readonly turnStillActive?: boolean; - }, -): boolean { - const existingTurnStillActive = - options?.turnStillActive === true && - options.activeTurnId != null && - existing?.turnId === options.activeTurnId; - return ( - existing !== undefined && - existing.role === "assistant" && - !existing.streaming && - existing.turnId !== null && - incoming.streaming && - incoming.turnId === null && - !existingTurnStillActive - ); -} - -export function archivedAssistantSegmentMessageId( - messageId: string, - turnId: string | null, - occurrence?: string, -): string { - if (turnId !== null) { - return `${messageId}@turn:${turnId}`; - } - return `${messageId}@turn:replay${occurrence === undefined ? "" : `:${occurrence}`}`; -} - -export function assistantSegmentStreamingTextResets( - existing: AssistantSegmentMessage | undefined, - incoming: Pick, - options?: { - readonly activeTurnId?: string | null; - readonly turnStillActive?: boolean; - }, -): boolean { - if (!incoming.streaming || existing?.role !== "assistant") { - return false; - } - if (assistantSegmentTurnChanged(existing, incoming)) { - return true; - } - // Completed replay rows with a known turn keep stale text until the first - // rebound chunk arrives with turnId still unknown. Null-to-null continuation - // within the same live segment must keep appending. - const existingTurnStillActive = - options?.turnStillActive === true && - options.activeTurnId != null && - existing.turnId === options.activeTurnId; - return ( - existing !== undefined && - !existing.streaming && - existing.turnId !== null && - incoming.turnId === null && - !existingTurnStillActive - ); -} - -export function assistantSegmentTimelineAnchorResets( - existing: AssistantSegmentMessage | undefined, - incoming: Pick, - options?: { - readonly activeTurnId?: string | null; - readonly turnStillActive?: boolean; - }, -): boolean { - return ( - assistantSegmentTurnChanged(existing, incoming) || - assistantSegmentStreamingTextResets(existing, incoming, options) - ); -} - -export function resolveAssistantSegmentText( - existing: Pick | undefined, - incoming: Pick, - textResets: boolean, -): string { - if (incoming.streaming) { - return textResets ? incoming.text : `${existing?.text ?? ""}${incoming.text}`; - } - if (incoming.text.length === 0 && !textResets) { - return existing?.text ?? incoming.text; - } - return incoming.text; -} - -export function resolveAssistantSegmentAttachments( - existingAttachments: ReadonlyArray | undefined, - incoming: { readonly attachments?: ReadonlyArray | undefined }, - turnChanged: boolean, -): ReadonlyArray | undefined { - if (incoming.attachments !== undefined) { - return incoming.attachments; - } - if (turnChanged) { - return undefined; - } - return existingAttachments; -} - -export function repointCheckpointsForArchivedAssistantSegment< - T extends { readonly turnId: string; readonly assistantMessageId: string | null }, ->( - checkpoints: ReadonlyArray, - providerMessageId: string, - archivedMessageId: string | null, - archivedTurnId: string, -): readonly T[] { - return checkpoints.map((entry) => - entry.turnId === archivedTurnId && entry.assistantMessageId === providerMessageId - ? { ...entry, assistantMessageId: archivedMessageId as T["assistantMessageId"] } - : entry, - ); -} - -export function repointLatestTurnForArchivedAssistantSegment< - T extends { readonly turnId: string; readonly assistantMessageId: string | null }, ->( - latestTurn: T | null, - repoint: { - readonly providerMessageId: string; - readonly archivedMessageId: string | null; - readonly archivedTurnId: string; - }, -): T | null { - if ( - latestTurn === null || - latestTurn.turnId !== repoint.archivedTurnId || - latestTurn.assistantMessageId !== repoint.providerMessageId - ) { - return latestTurn; - } - return { - ...latestTurn, - assistantMessageId: repoint.archivedMessageId as T["assistantMessageId"], - }; -} - -export function applyAssistantSegmentMessageUpdate( - messages: ReadonlyArray, - incoming: T, - options?: { - readonly activeTurnId?: string | null; - readonly turnStillActive?: boolean; - }, -): { - readonly messages: readonly T[]; - readonly checkpointsToRepoint: - | { - readonly providerMessageId: string; - readonly archivedMessageId: string; - readonly archivedTurnId: string; - } - | undefined; -} { - const existingMessage = messages.find((entry) => entry.id === incoming.id); - const archivedTurnIds = archivedAssistantSegmentTurnIds(messages, incoming.id); - if ( - isLateAssistantSegmentFromPriorTurn({ - existing: existingMessage, - incoming, - providerMessageId: incoming.id, - archivedTurnIds, - turnStillActive: options?.turnStillActive === true, - }) || - isLateStreamingOnCompletedAssistant({ - existing: existingMessage, - incoming, - turnStillActive: options?.turnStillActive === true, - }) - ) { - return { messages, checkpointsToRepoint: undefined }; - } - - const turnChanged = assistantSegmentTurnChanged(existingMessage, incoming); - const textResets = assistantSegmentStreamingTextResets(existingMessage, incoming, { - activeTurnId: options?.activeTurnId ?? null, - turnStillActive: options?.turnStillActive === true, - }); - const rebindArchives = assistantSegmentRebindArchives(existingMessage, incoming, { - activeTurnId: options?.activeTurnId ?? null, - turnStillActive: options?.turnStillActive === true, - }); - const shouldArchive = turnChanged || rebindArchives; - const timelineAnchorResets = assistantSegmentTimelineAnchorResets(existingMessage, incoming, { - activeTurnId: options?.activeTurnId ?? null, - turnStillActive: options?.turnStillActive === true, - }); - const nextText = resolveAssistantSegmentText(existingMessage, incoming, textResets); - const resolvedAttachments = resolveAssistantSegmentAttachments( - existingMessage?.attachments, - incoming, - shouldArchive, - ); - const attachmentFields = - resolvedAttachments !== undefined - ? { attachments: resolvedAttachments } - : shouldArchive - ? { attachments: undefined } - : {}; - const liveMessage: T = { - ...(existingMessage ?? incoming), - ...incoming, - text: nextText, - createdAt: timelineAnchorResets - ? incoming.createdAt - : (existingMessage?.createdAt ?? incoming.createdAt), - ...attachmentFields, - }; - - if (existingMessage === undefined) { - return { messages: [...messages, liveMessage], checkpointsToRepoint: undefined }; - } - - const existingIndex = messages.findIndex((entry) => entry.id === incoming.id); - - if (shouldArchive) { - const archivedTurnId = existingMessage.turnId; - const archivedMessage: T = { - ...existingMessage, - id: archivedAssistantSegmentMessageId( - incoming.id, - archivedTurnId, - existingMessage.createdAt, - ) as T["id"], - streaming: false, - }; - return { - messages: [ - ...messages.slice(0, existingIndex), - archivedMessage, - ...messages.slice(existingIndex + 1), - liveMessage, - ], - checkpointsToRepoint: - archivedTurnId !== null - ? { - providerMessageId: incoming.id, - archivedMessageId: archivedMessage.id, - archivedTurnId, - } - : undefined, - }; - } - - return { - messages: [ - ...messages.slice(0, existingIndex), - liveMessage, - ...messages.slice(existingIndex + 1), - ], - checkpointsToRepoint: undefined, - }; -} From 9e49f278fc49d367c04597d55120e8ab0c80ed43 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 25 Jun 2026 23:13:38 -0700 Subject: [PATCH 6/7] refactor(grok): isolate the xAI ACP fallback Keep the shared ACP session runtime provider-neutral and decorate it with Grok's private prompt-completion behavior at the provider boundary. Reject late settlements unless both session and turn lineage match. Co-authored-by: codex --- .../src/provider/Layers/GrokAdapter.test.ts | 13 + .../server/src/provider/Layers/GrokAdapter.ts | 66 +---- .../provider/acp/AcpJsonRpcConnection.test.ts | 113 +------- .../src/provider/acp/AcpSessionRuntime.ts | 252 +---------------- .../server/src/provider/acp/GrokAcpSupport.ts | 4 +- .../src/provider/acp/XAiAcpExtension.test.ts | 84 +++++- .../src/provider/acp/XAiAcpExtension.ts | 259 ++++++++++++++++++ 7 files changed, 381 insertions(+), 410 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 6441d33218b..7b6f0972ae8 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -95,13 +95,26 @@ it("requires a settlement to match the live Grok turn", () => { assert.isFalse( grokPromptSettlementBelongsToContext({ + liveAcpSessionId: "session-1", + expectedAcpSessionId: "session-1", liveActiveTurnId: replacementTurnId, liveSessionActiveTurnId: replacementTurnId, turnId: staleTurnId, }), ); + assert.isFalse( + grokPromptSettlementBelongsToContext({ + liveAcpSessionId: "replacement-session", + expectedAcpSessionId: "stale-session", + liveActiveTurnId: staleTurnId, + liveSessionActiveTurnId: staleTurnId, + turnId: staleTurnId, + }), + ); assert.isTrue( grokPromptSettlementBelongsToContext({ + liveAcpSessionId: "session-1", + expectedAcpSessionId: "session-1", liveActiveTurnId: staleTurnId, liveSessionActiveTurnId: staleTurnId, turnId: staleTurnId, diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 12469c91ee3..ccaa74908b6 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -42,7 +42,6 @@ import { ProviderAdapterValidationError, } from "../Errors.ts"; import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; -import { promptResponseHasMissingXAiStopReason } from "../acp/AcpSessionRuntime.ts"; import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; import { makeAcpAssistantItemEvent, @@ -64,6 +63,7 @@ import { extractXAiAskUserQuestions, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, + promptResponseHasMissingXAiStopReason, XAiAskUserQuestionRequest, } from "../acp/XAiAcpExtension.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; @@ -212,11 +212,16 @@ function completedStopReasonFromPromptResponse( } export function grokPromptSettlementBelongsToContext(input: { + readonly liveAcpSessionId: string; + readonly expectedAcpSessionId: string; readonly liveActiveTurnId: TurnId | undefined; readonly liveSessionActiveTurnId: TurnId | undefined; readonly turnId: TurnId; }): boolean { - return input.liveActiveTurnId === input.turnId || input.liveSessionActiveTurnId === input.turnId; + return ( + input.liveAcpSessionId === input.expectedAcpSessionId && + (input.liveActiveTurnId === input.turnId || input.liveSessionActiveTurnId === input.turnId) + ); } export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapterLiveOptions) { @@ -307,6 +312,8 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte return; } const settlementBelongsToLiveContext = grokPromptSettlementBelongsToContext({ + liveAcpSessionId: liveCtx.acpSessionId, + expectedAcpSessionId, liveActiveTurnId: liveCtx.activeTurnId, liveSessionActiveTurnId: liveCtx.session.activeTurnId, turnId, @@ -315,7 +322,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte // interruptTurn already consumed every prompt slot for this turn. A // late prompt result must neither emit a second terminal event nor // consume a slot belonging to a newer turn on the same ACP session. - if (liveCtx.interruptedTurnIds.has(turnId)) { + if ( + liveCtx.acpSessionId !== expectedAcpSessionId || + liveCtx.interruptedTurnIds.has(turnId) + ) { return; } if (options?.emitTurnCompletion !== false) { @@ -347,56 +357,6 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } return; } - if (liveCtx.acpSessionId !== expectedAcpSessionId) { - liveCtx.promptsInFlight = options?.settleAllPrompts - ? 0 - : Math.max(0, liveCtx.promptsInFlight - 1); - if (liveCtx.promptsInFlight > 0) { - return; - } - const updatedAt = yield* nowIso; - const canEmitTurnCompletion = - liveCtx.session.status === "running" || liveCtx.session.status === "connecting"; - const shouldEmitFailedTurn = options?.errorMessage !== undefined && canEmitTurnCompletion; - const shouldEmitCompletedTurn = - options?.completedStopReason !== undefined && canEmitTurnCompletion; - const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; - liveCtx.activeTurnId = undefined; - liveCtx.session = { - ...readySession, - status: "ready", - updatedAt, - }; - if (options?.emitTurnCompletion === false) { - return; - } - if (shouldEmitFailedTurn) { - yield* offerRuntimeEvent({ - type: "turn.completed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId, - turnId, - payload: { - state: "failed", - errorMessage: options.errorMessage, - }, - }); - } else if (shouldEmitCompletedTurn) { - yield* offerRuntimeEvent({ - type: "turn.completed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId, - turnId, - payload: { - state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", - stopReason: options.completedStopReason ?? null, - }, - }); - } - return; - } let settleTurnId = turnId; if (options?.settleAllPrompts) { liveCtx.promptsInFlight = 0; diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 294290c176e..4e9700dab7d 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -160,46 +160,7 @@ describe("AcpSessionRuntime", () => { ), ); - it.effect("resolves a prompt from xAI prompt completion when the prompt RPC hangs", () => - Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - yield* runtime.start(); - - const promptResult = yield* runtime.prompt({ - prompt: [{ type: "text", text: "hi" }], - }); - - const promptId = promptResult._meta?.promptId; - expect(typeof promptId).toBe("string"); - expect(promptResult).toMatchObject({ - stopReason: "end_turn", - _meta: { - sessionId: "mock-session-1", - promptId, - requestId: promptId, - }, - }); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - env: { - T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1", - }, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - authMethodId: "test", - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ), - ); - - it.effect("serializes prompt calls so only one session/prompt is in flight", () => + it.effect("supports successive standard ACP prompts", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; yield* runtime.start(); @@ -211,34 +172,14 @@ describe("AcpSessionRuntime", () => { prompt: [{ type: "text", text: "second" }], }); - const firstPromptId = firstPromptResult._meta?.promptId; - const secondPromptId = secondPromptResult._meta?.promptId; - expect(typeof firstPromptId).toBe("string"); - expect(typeof secondPromptId).toBe("string"); - expect(firstPromptId).not.toBe(secondPromptId); - expect(firstPromptResult).toMatchObject({ - stopReason: "end_turn", - _meta: { - promptId: firstPromptId, - requestId: firstPromptId, - }, - }); - expect(secondPromptResult).toMatchObject({ - stopReason: "end_turn", - _meta: { - promptId: secondPromptId, - requestId: secondPromptId, - }, - }); + expect(firstPromptResult).toMatchObject({ stopReason: "end_turn" }); + expect(secondPromptResult).toMatchObject({ stopReason: "end_turn" }); }).pipe( Effect.provide( AcpSessionRuntime.layer({ spawn: { command: mockAgentCommand, args: mockAgentArgs, - env: { - T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1", - }, }, cwd: process.cwd(), clientInfo: { name: "t3-test", version: "0.0.0" }, @@ -291,54 +232,6 @@ describe("AcpSessionRuntime", () => { ), ); - it.effect("ignores stale xAI prompt completion for an already completed prompt", () => - Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - yield* runtime.start(); - - const firstPromptResult = yield* runtime.prompt({ - prompt: [{ type: "text", text: "first" }], - }); - expect(firstPromptResult).toMatchObject({ - stopReason: "end_turn", - _meta: { - promptId: "mock-stale-xai-prompt-1", - }, - }); - - const secondPromptResult = yield* runtime.prompt({ - prompt: [{ type: "text", text: "second" }], - }); - const secondPromptId = secondPromptResult._meta?.promptId; - expect(typeof secondPromptId).toBe("string"); - expect(secondPromptId).not.toBe("mock-stale-xai-prompt-1"); - expect(secondPromptResult).toMatchObject({ - stopReason: "end_turn", - _meta: { - promptId: secondPromptId, - requestId: secondPromptId, - }, - }); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - env: { - T3_ACP_EMIT_STALE_XAI_PROMPT_COMPLETE_BEFORE_SECOND_HANG: "1", - }, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - authMethodId: "test", - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ), - ); - it.effect("segments assistant text around ACP tool calls", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 8060e17b68f..bc2df3aa8d4 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -11,7 +11,6 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; -import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -40,21 +39,6 @@ function formatConfigOptionValue(value: string | boolean): string { return JSON.stringify(value); } -const XAiPromptCompleteNotification = Schema.Struct({ - sessionId: Schema.String, - promptId: Schema.optional(Schema.String), - stopReason: Schema.optional(Schema.String), - agentResult: Schema.optional(Schema.NullOr(Schema.Unknown)), -}); - -type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type; - -interface PendingXAiPromptCompletion { - readonly sessionId: string; - readonly promptId: string; - readonly deferred: Deferred.Deferred; -} - export interface AcpSessionEventStreamBarrier { readonly _tag: "EventStreamBarrier"; readonly acknowledge: Deferred.Deferred; @@ -62,10 +46,8 @@ export interface AcpSessionEventStreamBarrier { export type AcpSessionRuntimeEvent = AcpParsedSessionEvent | AcpSessionEventStreamBarrier; -const completedXAiPromptIdLimit = 128; const defaultSessionLoadTimeout = Duration.seconds(90); const defaultSessionLoadReplayIdleGap = Duration.seconds(2); -const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; export interface AcpSpawnInput { readonly command: string; @@ -303,16 +285,7 @@ export const make = ( const activePromptFiberRef = yield* Ref.make< Option.Option> >(Option.none()); - const pendingXAiPromptCompletionsRef = yield* Ref.make< - ReadonlyArray - >([]); - const completedXAiPromptIdsRef = yield* Ref.make>([]); const sessionLoadGateRef = yield* Ref.make>(Option.none()); - let nextXAiPromptFallbackId = 0; - const allocateXAiPromptFallbackId = Effect.sync(() => { - nextXAiPromptFallbackId += 1; - return `t3-xai-prompt-${nextXAiPromptFallbackId}`; - }); const logRequest = (event: AcpSessionRequestLogEvent) => options.requestLogger ? options.requestLogger(event) : Effect.void; @@ -418,17 +391,6 @@ export const make = ( }); }), ); - yield* acp.handleExtNotification( - "_x.ai/session/prompt_complete", - XAiPromptCompleteNotification, - (notification) => - resolveXAiPromptCompletionFallback({ - pendingRef: pendingXAiPromptCompletionsRef, - completedPromptIdsRef: completedXAiPromptIdsRef, - notification, - }), - ); - const initializeClientCapabilities = { fs: { readTextFile: false, @@ -746,60 +708,32 @@ export const make = ( promptSerializationSemaphore.withPermit( Effect.gen(function* () { const started = yield* getStartedState; - const promptId = yield* allocateXAiPromptFallbackId; yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef, }); - const fallback = yield* registerXAiPromptCompletionFallback( - pendingXAiPromptCompletionsRef, - started.sessionId, - promptId, - ); const requestPayload = { sessionId: started.sessionId, ...payload, - _meta: { - ...payload._meta, - promptId: fallback.promptId, - requestId: fallback.promptId, - }, } satisfies EffectAcpSchema.PromptRequest; - const cancelledResponse = promptResponseFromXAi({ - sessionId: started.sessionId, - promptId: fallback.promptId, + const cancelledResponse = { stopReason: "cancelled", - agentResult: null, - }); - // Grok can emit its private prompt-complete notification even when the - // standard session/prompt RPC never settles. Race the RPC with that - // notification so callers still emit turn completion and clear UI state. + } satisfies EffectAcpSchema.PromptResponse; const promptRpcFiber = yield* runLoggedRequest( "session/prompt", requestPayload, acp.agent.prompt(requestPayload), ).pipe(Effect.forkIn(runtimeScope)); yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber)); - return yield* Effect.raceFirst( - Fiber.join(promptRpcFiber).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.succeed(cancelledResponse) - : Effect.failCause(cause), - ), - ), - Deferred.await(fallback.deferred), - ).pipe( - Effect.tap((response) => - rememberCompletedXAiPromptId(completedXAiPromptIdsRef, response, fallback.promptId), + return yield* Fiber.join(promptRpcFiber).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.succeed(cancelledResponse) + : Effect.failCause(cause), ), Effect.ensuring( Effect.gen(function* () { yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore); - yield* unregisterXAiPromptCompletionFallback( - pendingXAiPromptCompletionsRef, - fallback.deferred, - ); yield* Ref.set(activePromptFiberRef, Option.none()); }), ), @@ -819,7 +753,6 @@ export const make = ( if (Option.isSome(activePromptFiber)) { yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore); } - yield* abortPendingPromptCompletions(pendingXAiPromptCompletionsRef, started.sessionId); yield* acp.agent .cancel({ sessionId: started.sessionId }) .pipe(Effect.ignore, Effect.forkIn(runtimeScope)); @@ -1053,174 +986,3 @@ const closeActiveAssistantSegment = ({ } satisfies AcpAssistantSegmentState, ] as const; }).pipe(Effect.flatMap((event) => (event ? Queue.offer(queue, event) : Effect.void))); - -const registerXAiPromptCompletionFallback = ( - pendingRef: Ref.Ref>, - sessionId: string, - promptId: string, -) => - Deferred.make().pipe( - Effect.tap((deferred) => - Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]), - ), - Effect.map((deferred) => ({ deferred, promptId })), - ); - -const unregisterXAiPromptCompletionFallback = ( - pendingRef: Ref.Ref>, - deferred: Deferred.Deferred, -) => Ref.update(pendingRef, (pending) => pending.filter((entry) => entry.deferred !== deferred)); - -const abortPendingPromptCompletions = ( - pendingRef: Ref.Ref>, - sessionId: string, -) => - Ref.modify(pendingRef, (pending) => { - const [toAbort, remaining] = pending.reduce< - [ReadonlyArray, ReadonlyArray] - >( - ([aborting, kept], entry) => - entry.sessionId === sessionId ? [[...aborting, entry], kept] : [aborting, [...kept, entry]], - [[], []], - ); - if (toAbort.length === 0) { - return [Effect.void, pending] as const; - } - return [ - Effect.forEach( - toAbort, - (entry) => - Deferred.succeed( - entry.deferred, - promptResponseFromXAi({ - sessionId: entry.sessionId, - promptId: entry.promptId, - stopReason: "cancelled", - agentResult: null, - }), - ), - { concurrency: "unbounded" }, - ).pipe(Effect.asVoid), - remaining, - ] as const; - }).pipe(Effect.flatten); - -const resolveXAiPromptCompletionFallback = ({ - pendingRef, - completedPromptIdsRef, - notification, -}: { - readonly pendingRef: Ref.Ref>; - readonly completedPromptIdsRef: Ref.Ref>; - readonly notification: XAiPromptCompleteNotification; -}) => - Ref.get(completedPromptIdsRef).pipe( - Effect.flatMap((completedPromptIds) => { - if ( - notification.promptId !== undefined && - completedPromptIds.includes(notification.promptId) - ) { - return Effect.void; - } - return Ref.modify(pendingRef, (pending) => { - const index = - notification.promptId !== undefined - ? pending.findIndex( - (entry) => - entry.sessionId === notification.sessionId && - entry.promptId === notification.promptId, - ) - : (() => { - const sessionPendingIndexes = pending.flatMap((entry, entryIndex) => - entry.sessionId === notification.sessionId ? [entryIndex] : [], - ); - if (sessionPendingIndexes.length === 0) { - return -1; - } - // xAI may omit promptId while multiple steered prompts are in flight. - // Resolve the oldest pending fallback FIFO for this session. - return sessionPendingIndexes[0] ?? -1; - })(); - if (index < 0) { - return [Effect.void, pending] as const; - } - const entry = pending[index]; - if (!entry) { - return [Effect.void, pending] as const; - } - return [ - Deferred.succeed(entry.deferred, promptResponseFromXAi(notification)).pipe(Effect.asVoid), - [...pending.slice(0, index), ...pending.slice(index + 1)], - ] as const; - }).pipe(Effect.flatten); - }), - ); - -const rememberCompletedXAiPromptId = ( - completedPromptIdsRef: Ref.Ref>, - response: EffectAcpSchema.PromptResponse, - fallbackPromptId: string, -) => { - const promptId = promptIdFromResponse(response) ?? fallbackPromptId; - if (promptId.length === 0) { - return Effect.void; - } - return Ref.update(completedPromptIdsRef, (completedPromptIds) => { - if (completedPromptIds.includes(promptId)) { - return completedPromptIds; - } - return [...completedPromptIds, promptId].slice(-completedXAiPromptIdLimit); - }); -}; - -function promptIdFromResponse(response: EffectAcpSchema.PromptResponse): string | undefined { - const meta = response._meta; - if (meta === null || typeof meta !== "object") { - return undefined; - } - const promptId = meta.promptId ?? meta.requestId; - return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined; -} - -export function promptResponseHasMissingXAiStopReason( - response: EffectAcpSchema.PromptResponse, -): boolean { - const meta = response._meta; - return meta !== null && typeof meta === "object" && meta[xAiStopReasonMissingMetaKey] === true; -} - -function promptResponseFromXAi( - notification: XAiPromptCompleteNotification, -): EffectAcpSchema.PromptResponse { - const stopReason = normalizeXAiStopReason(notification.stopReason); - const meta: Record = { - sessionId: notification.sessionId, - }; - if (notification.stopReason === undefined) { - meta[xAiStopReasonMissingMetaKey] = true; - } - if (notification.promptId !== undefined) { - meta.promptId = notification.promptId; - meta.requestId = notification.promptId; - } - if (notification.agentResult !== undefined) { - meta.agentResult = notification.agentResult; - } - return { - stopReason, - _meta: meta, - }; -} - -function normalizeXAiStopReason(value: string | undefined): EffectAcpSchema.StopReason { - switch (value) { - case "cancelled": - case "end_turn": - case "max_tokens": - case "max_turn_requests": - case "refusal": - return value; - default: - return "end_turn"; - } -} diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index ee8af1e5266..3e6d63a5393 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -8,6 +8,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { normalizeModelSlug } from "@t3tools/shared/model"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import { makeXAiPromptCompletionRuntime } from "./XAiAcpExtension.ts"; const GROK_API_KEY_ENV = "XAI_API_KEY"; const GROK_OAUTH2_REFERRER_ENV = "GROK_OAUTH2_REFERRER"; @@ -68,9 +69,10 @@ export const makeGrokAcpRuntime = ( ), ), ); - return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( Effect.provide(acpContext), ); + return yield* makeXAiPromptCompletionRuntime(runtime); }); export function resolveGrokAcpBaseModelId(model: string | null | undefined): string { diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts index a42561d9ae2..c435269fd76 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -1,12 +1,39 @@ -import { describe, expect, it } from "vite-plus/test"; +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import { describe, expect } from "vite-plus/test"; import { extractXAiAskUserQuestions, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, + makeXAiPromptCompletionRuntime, XAiAskUserQuestionRequest, } from "./XAiAcpExtension.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); + +const makePromptCompletionRuntime = (env: NodeJS.ProcessEnv) => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.make({ + spawn: { + command: process.execPath, + args: [mockAgentPath], + env, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }); + return yield* makeXAiPromptCompletionRuntime(runtime); + }); const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest); @@ -247,4 +274,59 @@ describe("XAiAcpExtension", () => { }, }); }); + + it.effect("resolves a hung standard prompt from xAI prompt completion", () => + Effect.gen(function* () { + const runtime = yield* makePromptCompletionRuntime({ + T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1", + }); + yield* runtime.start(); + + const promptResult = yield* runtime.prompt({ + prompt: [{ type: "text", text: "hi" }], + }); + const promptId = promptResult._meta?.promptId; + + expect(typeof promptId).toBe("string"); + expect(promptResult).toMatchObject({ + stopReason: "end_turn", + _meta: { + sessionId: "mock-session-1", + promptId, + requestId: promptId, + }, + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("ignores stale xAI completion from an already settled prompt", () => + Effect.gen(function* () { + const runtime = yield* makePromptCompletionRuntime({ + T3_ACP_EMIT_STALE_XAI_PROMPT_COMPLETE_BEFORE_SECOND_HANG: "1", + }); + yield* runtime.start(); + + const firstPromptResult = yield* runtime.prompt({ + prompt: [{ type: "text", text: "first" }], + }); + expect(firstPromptResult).toMatchObject({ + stopReason: "end_turn", + _meta: { promptId: "mock-stale-xai-prompt-1" }, + }); + + const secondPromptResult = yield* runtime.prompt({ + prompt: [{ type: "text", text: "second" }], + }); + const secondPromptId = secondPromptResult._meta?.promptId; + expect(typeof secondPromptId).toBe("string"); + expect(secondPromptId).not.toBe("mock-stale-xai-prompt-1"); + expect(secondPromptResult).toMatchObject({ + stopReason: "end_turn", + _meta: { + promptId: secondPromptId, + requestId: secondPromptId, + }, + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index 6c774c7f8d5..d36a5fcfc89 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -1,5 +1,29 @@ import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const XAiPromptCompleteNotification = Schema.Struct({ + sessionId: Schema.String, + promptId: Schema.optional(Schema.String), + stopReason: Schema.optional(Schema.String), + agentResult: Schema.optional(Schema.NullOr(Schema.Unknown)), +}); + +type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type; + +interface PendingXAiPromptCompletion { + readonly sessionId: string; + readonly promptId: string; + readonly deferred: Deferred.Deferred; +} + +const completedXAiPromptIdLimit = 128; +const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; const XAiAskUserQuestionOption = Schema.Struct({ label: Schema.String, @@ -171,3 +195,238 @@ export function makeXAiAskUserQuestionResponse( export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCancelledResponse { return { outcome: "cancelled" }; } + +/** + * Adds Grok's private prompt-completion fallback around a standards-only ACP runtime. + * The underlying runtime remains unaware of xAI methods and metadata. + */ +export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletionRuntime")( + function* (runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]) { + const activeSessionIdRef = yield* Ref.make(undefined); + const pendingRef = yield* Ref.make>([]); + const completedPromptIdsRef = yield* Ref.make>([]); + let nextPromptFallbackId = 0; + const allocatePromptFallbackId = Effect.sync(() => { + nextPromptFallbackId += 1; + return `t3-xai-prompt-${nextPromptFallbackId}`; + }); + + yield* runtime.handleExtNotification( + "_x.ai/session/prompt_complete", + XAiPromptCompleteNotification, + (notification) => + resolveXAiPromptCompletionFallback({ + pendingRef, + completedPromptIdsRef, + notification, + }), + ); + + return { + ...runtime, + start: () => + runtime + .start() + .pipe(Effect.tap((started) => Ref.set(activeSessionIdRef, started.sessionId))), + prompt: (payload) => + Effect.gen(function* () { + const sessionId = yield* Ref.get(activeSessionIdRef); + if (sessionId === undefined) { + return yield* runtime.prompt(payload); + } + + const promptId = yield* allocatePromptFallbackId; + const fallback = yield* registerXAiPromptCompletionFallback( + pendingRef, + sessionId, + promptId, + ); + const requestPayload = { + ...payload, + _meta: { + ...payload._meta, + promptId: fallback.promptId, + requestId: fallback.promptId, + }, + } satisfies Omit; + + return yield* Effect.raceFirst( + runtime.prompt(requestPayload), + Deferred.await(fallback.deferred), + ).pipe( + Effect.tap((response) => + rememberCompletedXAiPromptId(completedPromptIdsRef, response, fallback.promptId), + ), + Effect.ensuring(unregisterXAiPromptCompletionFallback(pendingRef, fallback.deferred)), + ); + }), + cancel: Ref.get(activeSessionIdRef).pipe( + Effect.flatMap((sessionId) => + sessionId === undefined + ? runtime.cancel + : abortPendingPromptCompletions(pendingRef, sessionId).pipe( + Effect.andThen(runtime.cancel), + ), + ), + ), + } satisfies AcpSessionRuntime.AcpSessionRuntime["Service"]; + }, +); + +const registerXAiPromptCompletionFallback = ( + pendingRef: Ref.Ref>, + sessionId: string, + promptId: string, +) => + Deferred.make().pipe( + Effect.tap((deferred) => + Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]), + ), + Effect.map((deferred) => ({ deferred, promptId })), + ); + +const unregisterXAiPromptCompletionFallback = ( + pendingRef: Ref.Ref>, + deferred: Deferred.Deferred, +) => Ref.update(pendingRef, (pending) => pending.filter((entry) => entry.deferred !== deferred)); + +const abortPendingPromptCompletions = ( + pendingRef: Ref.Ref>, + sessionId: string, +) => + Ref.modify(pendingRef, (pending) => { + const [toAbort, remaining] = pending.reduce< + [ReadonlyArray, ReadonlyArray] + >( + ([aborting, kept], entry) => + entry.sessionId === sessionId ? [[...aborting, entry], kept] : [aborting, [...kept, entry]], + [[], []], + ); + if (toAbort.length === 0) { + return [Effect.void, pending] as const; + } + return [ + Effect.forEach( + toAbort, + (entry) => + Deferred.succeed( + entry.deferred, + promptResponseFromXAi({ + sessionId: entry.sessionId, + promptId: entry.promptId, + stopReason: "cancelled", + agentResult: null, + }), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.asVoid), + remaining, + ] as const; + }).pipe(Effect.flatten); + +const resolveXAiPromptCompletionFallback = ({ + pendingRef, + completedPromptIdsRef, + notification, +}: { + readonly pendingRef: Ref.Ref>; + readonly completedPromptIdsRef: Ref.Ref>; + readonly notification: XAiPromptCompleteNotification; +}) => + Ref.get(completedPromptIdsRef).pipe( + Effect.flatMap((completedPromptIds) => { + if ( + notification.promptId !== undefined && + completedPromptIds.includes(notification.promptId) + ) { + return Effect.void; + } + return Ref.modify(pendingRef, (pending) => { + const index = + notification.promptId !== undefined + ? pending.findIndex( + (entry) => + entry.sessionId === notification.sessionId && + entry.promptId === notification.promptId, + ) + : pending.findIndex((entry) => entry.sessionId === notification.sessionId); + if (index < 0) { + return [Effect.void, pending] as const; + } + const entry = pending[index]; + if (!entry) { + return [Effect.void, pending] as const; + } + return [ + Deferred.succeed(entry.deferred, promptResponseFromXAi(notification)).pipe(Effect.asVoid), + [...pending.slice(0, index), ...pending.slice(index + 1)], + ] as const; + }).pipe(Effect.flatten); + }), + ); + +const rememberCompletedXAiPromptId = ( + completedPromptIdsRef: Ref.Ref>, + response: EffectAcpSchema.PromptResponse, + fallbackPromptId: string, +) => { + const promptId = promptIdFromResponse(response) ?? fallbackPromptId; + return Ref.update(completedPromptIdsRef, (completedPromptIds) => { + if (completedPromptIds.includes(promptId)) { + return completedPromptIds; + } + return [...completedPromptIds, promptId].slice(-completedXAiPromptIdLimit); + }); +}; + +function promptIdFromResponse(response: EffectAcpSchema.PromptResponse): string | undefined { + const meta = response._meta; + if (meta === null || typeof meta !== "object") { + return undefined; + } + const promptId = meta.promptId ?? meta.requestId; + return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined; +} + +export function promptResponseHasMissingXAiStopReason( + response: EffectAcpSchema.PromptResponse, +): boolean { + const meta = response._meta; + return meta !== null && typeof meta === "object" && meta[xAiStopReasonMissingMetaKey] === true; +} + +function promptResponseFromXAi( + notification: XAiPromptCompleteNotification, +): EffectAcpSchema.PromptResponse { + const stopReason = normalizeXAiStopReason(notification.stopReason); + const meta: Record = { + sessionId: notification.sessionId, + }; + if (notification.stopReason === undefined) { + meta[xAiStopReasonMissingMetaKey] = true; + } + if (notification.promptId !== undefined) { + meta.promptId = notification.promptId; + meta.requestId = notification.promptId; + } + if (notification.agentResult !== undefined) { + meta.agentResult = notification.agentResult; + } + return { + stopReason, + _meta: meta, + }; +} + +function normalizeXAiStopReason(value: string | undefined): EffectAcpSchema.StopReason { + switch (value) { + case "cancelled": + case "end_turn": + case "max_tokens": + case "max_turn_requests": + case "refusal": + return value; + default: + return "end_turn"; + } +} From bf4b5b513749f6dd774063d93de6f518ca8f0d8f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 25 Jun 2026 23:30:51 -0700 Subject: [PATCH 7/7] fix(grok): serialize completion drain Keep the xAI notification drain under the thread lock so trailing text is ordered before completion without allowing a new turn to steer into the settling prompt. Mark Stop targets before waiting on the lock so cancellation still wins during the drain window. Co-authored-by: codex --- .../server/src/provider/Layers/GrokAdapter.ts | 124 ++++++++++++------ 1 file changed, 83 insertions(+), 41 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index ccaa74908b6..c22b2180183 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -1091,11 +1091,6 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ), ); - for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { - yield* Effect.yieldNow; - } - yield* prepared.acp.drainEvents; - return yield* withThreadLock( input.threadId, Effect.gen(function* () { @@ -1117,6 +1112,13 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte detail: "Grok session changed before the turn completed.", }); } + // Keep prompt settlement atomic with respect to Stop and steering. + // interruptTurn marks its target before waiting for this lock, so + // cancellation can still win while queued ACP events are drained. + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + yield* prepared.acp.drainEvents; if (ctx.interruptedTurnIds.has(prepared.turnId)) { yield* Ref.set(promptSettled, true); return { @@ -1270,47 +1272,87 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }); const interruptTurn: GrokAdapterShape["interruptTurn"] = (threadId, turnId) => - withThreadLock( - threadId, - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); + Effect.gen(function* () { + const observed = yield* Effect.sync(() => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return { + _tag: "Proceed" as const, + acpSessionId: undefined, + interruptedTurnId: turnId, + }; + } const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { - return; + return { _tag: "Ignore" as const }; } - const interruptedTurnId = turnId ?? activeTurnId ?? ctx.session.activeTurnId; - yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); - yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); - yield* Effect.ignore( - ctx.acp.cancel.pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), - ), - ), - ); - if (interruptedTurnId) { + const interruptedTurnId = turnId ?? activeTurnId; + if (interruptedTurnId !== undefined) { ctx.interruptedTurnIds.add(interruptedTurnId); - yield* settlePromptInFlight(threadId, interruptedTurnId, ctx.acpSessionId, { - completedStopReason: "cancelled", - settleAllPrompts: true, - }); - } else if ( - ctx.promptsInFlight > 0 || - ctx.session.status === "running" || - ctx.session.status === "connecting" - ) { - const updatedAt = yield* nowIso; - ctx.promptsInFlight = 0; - ctx.activeTurnId = undefined; - const { activeTurnId: _activeTurnId, ...readySession } = ctx.session; - ctx.session = { - ...readySession, - status: "ready", - updatedAt, - }; } - }), - ); + return { + _tag: "Proceed" as const, + acpSessionId: ctx.acpSessionId, + interruptedTurnId, + }; + }); + if (observed._tag === "Ignore") { + return; + } + + yield* withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (observed.acpSessionId !== undefined && ctx.acpSessionId !== observed.acpSessionId) { + return; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return; + } + if ( + observed.interruptedTurnId !== undefined && + activeTurnId !== undefined && + activeTurnId !== observed.interruptedTurnId + ) { + return; + } + const interruptedTurnId = + observed.interruptedTurnId ?? turnId ?? activeTurnId ?? ctx.session.activeTurnId; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + if (interruptedTurnId) { + ctx.interruptedTurnIds.add(interruptedTurnId); + yield* settlePromptInFlight(threadId, interruptedTurnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + settleAllPrompts: true, + }); + } else if ( + ctx.promptsInFlight > 0 || + ctx.session.status === "running" || + ctx.session.status === "connecting" + ) { + const updatedAt = yield* nowIso; + ctx.promptsInFlight = 0; + ctx.activeTurnId = undefined; + const { activeTurnId: _activeTurnId, ...readySession } = ctx.session; + ctx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + }), + ); + }); const respondToRequest: GrokAdapterShape["respondToRequest"] = ( threadId,