authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-10 23:51:43-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-10 23:51:43-07:00
log57f6bf7edbaec3a652245c15b65b320d58571deb
tree1d5dac346020853363f7cc390e2d7a2df5c2c8f3
parent0baf1986e8261c62035cbebc326d0da4ffd2defb
signature Signed by PGP key B5690EEEBB952194

Fix turn fold proejctions (#3041)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

24 files changed, 1646 insertions(+), 434 deletions(-)

apps/server/scripts/acp-mock-agent.ts+5
......@@ -22,6 +22,7 @@ const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION ===
2222const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
2323const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
2424const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
25const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0");
2526const permissionOptionIds = {
2627 allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once",
2728 allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always",
......@@ -364,6 +365,10 @@ const program = Effect.gen(function* () {
364365 Effect.gen(function* () {
365366 const requestedSessionId = String(request.sessionId ?? sessionId);
366367
368 if (Number.isFinite(promptDelayMs) && promptDelayMs > 0) {
369 yield* Effect.sleep(`${promptDelayMs} millis`);
370 }
371
367372 if (emitInterleavedAssistantToolCalls) {
368373 const toolCallId = "tool-call-1";
369374
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+221
......@@ -1232,6 +1232,227 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
12321232 }),
12331233 );
12341234
1235 it.effect("keeps the turn running across interim assistant messages until the session ends", () =>
1236 Effect.gen(function* () {
1237 const projectionPipeline = yield* OrchestrationProjectionPipeline;
1238 const eventStore = yield* OrchestrationEventStore;
1239 const sql = yield* SqlClient.SqlClient;
1240 const now = "2026-01-01T00:00:00.000Z";
1241 const threadId = ThreadId.make("thread-turn-lifecycle");
1242 const turnId = TurnId.make("turn-lifecycle-1");
1243
1244 yield* eventStore.append({
1245 type: "thread.created",
1246 eventId: EventId.make("evt-tl1"),
1247 aggregateKind: "thread",
1248 aggregateId: threadId,
1249 occurredAt: now,
1250 commandId: CommandId.make("cmd-tl1"),
1251 causationEventId: null,
1252 correlationId: CorrelationId.make("cmd-tl1"),
1253 metadata: {},
1254 payload: {
1255 threadId,
1256 projectId: ProjectId.make("project-turn-lifecycle"),
1257 title: "Turn lifecycle",
1258 modelSelection: {
1259 instanceId: ProviderInstanceId.make("claude"),
1260 model: "claude-opus",
1261 },
1262 runtimeMode: "full-access",
1263 branch: null,
1264 worktreePath: null,
1265 createdAt: now,
1266 updatedAt: now,
1267 },
1268 });
1269
1270 yield* eventStore.append({
1271 type: "thread.session-set",
1272 eventId: EventId.make("evt-tl2"),
1273 aggregateKind: "thread",
1274 aggregateId: threadId,
1275 occurredAt: "2026-01-01T00:00:01.000Z",
1276 commandId: CommandId.make("cmd-tl2"),
1277 causationEventId: null,
1278 correlationId: CorrelationId.make("cmd-tl2"),
1279 metadata: {},
1280 payload: {
1281 threadId,
1282 session: {
1283 threadId,
1284 status: "running",
1285 providerName: "claude",
1286 runtimeMode: "full-access",
1287 activeTurnId: turnId,
1288 lastError: null,
1289 updatedAt: "2026-01-01T00:00:01.000Z",
1290 },
1291 },
1292 });
1293
1294 // Interim assistant message completes mid-turn (commentary between
1295 // tool calls) — the turn must stay running and unsettled.
1296 yield* eventStore.append({
1297 type: "thread.message-sent",
1298 eventId: EventId.make("evt-tl3"),
1299 aggregateKind: "thread",
1300 aggregateId: threadId,
1301 occurredAt: "2026-01-01T00:00:05.000Z",
1302 commandId: CommandId.make("cmd-tl3"),
1303 causationEventId: null,
1304 correlationId: CorrelationId.make("cmd-tl3"),
1305 metadata: {},
1306 payload: {
1307 threadId,
1308 messageId: MessageId.make("message-tl-interim"),
1309 role: "assistant",
1310 text: "interim commentary",
1311 turnId,
1312 streaming: false,
1313 createdAt: "2026-01-01T00:00:05.000Z",
1314 updatedAt: "2026-01-01T00:00:05.000Z",
1315 },
1316 });
1317
1318 yield* projectionPipeline.bootstrap;
1319
1320 const runningRows = yield* sql<{
1321 readonly state: string;
1322 readonly completedAt: string | null;
1323 }>`
1324 SELECT state, completed_at AS "completedAt"
1325 FROM projection_turns
1326 WHERE thread_id = ${threadId} AND turn_id = ${turnId}
1327 `;
1328 assert.deepEqual(runningRows, [{ state: "running", completedAt: null }]);
1329
1330 // The session leaving "running" is the turn-end signal.
1331 yield* eventStore.append({
1332 type: "thread.session-set",
1333 eventId: EventId.make("evt-tl4"),
1334 aggregateKind: "thread",
1335 aggregateId: threadId,
1336 occurredAt: "2026-01-01T00:01:00.000Z",
1337 commandId: CommandId.make("cmd-tl4"),
1338 causationEventId: null,
1339 correlationId: CorrelationId.make("cmd-tl4"),
1340 metadata: {},
1341 payload: {
1342 threadId,
1343 session: {
1344 threadId,
1345 status: "ready",
1346 providerName: "claude",
1347 runtimeMode: "full-access",
1348 activeTurnId: null,
1349 lastError: null,
1350 updatedAt: "2026-01-01T00:01:00.000Z",
1351 },
1352 },
1353 });
1354
1355 yield* projectionPipeline.bootstrap;
1356
1357 const settledRows = yield* sql<{
1358 readonly state: string;
1359 readonly completedAt: string | null;
1360 }>`
1361 SELECT state, completed_at AS "completedAt"
1362 FROM projection_turns
1363 WHERE thread_id = ${threadId} AND turn_id = ${turnId}
1364 `;
1365 assert.deepEqual(settledRows, [
1366 { state: "completed", completedAt: "2026-01-01T00:01:00.000Z" },
1367 ]);
1368 }),
1369 );
1370
1371 it.effect("settles a superseded running turn when a new turn becomes active", () =>
1372 Effect.gen(function* () {
1373 const projectionPipeline = yield* OrchestrationProjectionPipeline;
1374 const eventStore = yield* OrchestrationEventStore;
1375 const sql = yield* SqlClient.SqlClient;
1376 const now = "2026-01-01T00:00:00.000Z";
1377 const threadId = ThreadId.make("thread-turn-supersede");
1378 const oldTurnId = TurnId.make("turn-superseded");
1379 const newTurnId = TurnId.make("turn-steer");
1380
1381 yield* eventStore.append({
1382 type: "thread.created",
1383 eventId: EventId.make("evt-ts1"),
1384 aggregateKind: "thread",
1385 aggregateId: threadId,
1386 occurredAt: now,
1387 commandId: CommandId.make("cmd-ts1"),
1388 causationEventId: null,
1389 correlationId: CorrelationId.make("cmd-ts1"),
1390 metadata: {},
1391 payload: {
1392 threadId,
1393 projectId: ProjectId.make("project-turn-supersede"),
1394 title: "Turn supersede",
1395 modelSelection: {
1396 instanceId: ProviderInstanceId.make("opencode"),
1397 model: "big-pickle",
1398 },
1399 runtimeMode: "full-access",
1400 branch: null,
1401 worktreePath: null,
1402 createdAt: now,
1403 updatedAt: now,
1404 },
1405 });
1406
1407 const appendRunningSessionSet = (eventId: string, turnId: TurnId, updatedAt: string) =>
1408 eventStore.append({
1409 type: "thread.session-set",
1410 eventId: EventId.make(eventId),
1411 aggregateKind: "thread",
1412 aggregateId: threadId,
1413 occurredAt: updatedAt,
1414 commandId: CommandId.make(`cmd-${eventId}`),
1415 causationEventId: null,
1416 correlationId: CorrelationId.make(`cmd-${eventId}`),
1417 metadata: {},
1418 payload: {
1419 threadId,
1420 session: {
1421 threadId,
1422 status: "running",
1423 providerName: "opencode",
1424 runtimeMode: "full-access",
1425 activeTurnId: turnId,
1426 lastError: null,
1427 updatedAt,
1428 },
1429 },
1430 });
1431
1432 yield* appendRunningSessionSet("evt-ts2", oldTurnId, "2026-01-01T00:00:01.000Z");
1433 // A steer: a new turn becomes active without the provider ever
1434 // completing the previous one.
1435 yield* appendRunningSessionSet("evt-ts3", newTurnId, "2026-01-01T00:00:30.000Z");
1436
1437 yield* projectionPipeline.bootstrap;
1438
1439 const rows = yield* sql<{
1440 readonly turnId: string;
1441 readonly state: string;
1442 readonly completedAt: string | null;
1443 }>`
1444 SELECT turn_id AS "turnId", state, completed_at AS "completedAt"
1445 FROM projection_turns
1446 WHERE thread_id = ${threadId}
1447 ORDER BY requested_at
1448 `;
1449 assert.deepEqual(rows, [
1450 { turnId: oldTurnId, state: "completed", completedAt: "2026-01-01T00:00:30.000Z" },
1451 { turnId: newTurnId, state: "running", completedAt: null },
1452 ]);
1453 }),
1454 );
1455
12351456 it.effect("keeps accumulated assistant text when completion payload text is empty", () =>
12361457 Effect.gen(function* () {
12371458 const projectionPipeline = yield* OrchestrationProjectionPipeline;
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+105-11
......@@ -2,6 +2,7 @@ import {
22 ApprovalRequestId,
33 type ChatAttachment,
44 type OrchestrationEvent,
5 type OrchestrationSessionStatus,
56 ThreadId,
67} from "@t3tools/contracts";
78import * as Effect from "effect/Effect";
......@@ -69,6 +70,29 @@ export const ORCHESTRATION_PROJECTOR_NAMES = {
6970type ProjectorName =
7071 (typeof ORCHESTRATION_PROJECTOR_NAMES)[keyof typeof ORCHESTRATION_PROJECTOR_NAMES];
7172
73/**
74 * Turn state to settle still-running turns with when their session leaves the
75 * "running" status, or null while the session is (re)starting or running and
76 * turns must stay unsettled.
77 */
78function settledTurnStateForSessionStatus(
79 status: OrchestrationSessionStatus,
80): "completed" | "interrupted" | "error" | null {
81 switch (status) {
82 case "idle":
83 case "ready":
84 return "completed";
85 case "error":
86 return "error";
87 case "interrupted":
88 case "stopped":
89 return "interrupted";
90 case "starting":
91 case "running":
92 return null;
93 }
94}
95
7296interface ProjectorDefinition {
7397 readonly name: ProjectorName;
7498 readonly apply: (
......@@ -999,9 +1023,57 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
9991023 case "thread.session-set": {
10001024 const turnId = event.payload.session.activeTurnId;
10011025 if (turnId === null || event.payload.session.status !== "running") {
1026 // Leaving the "running" session status is the turn-end signal:
1027 // settle still-running turns so their duration reflects the whole
1028 // turn rather than the last assistant message.
1029 const settledTurnState = settledTurnStateForSessionStatus(event.payload.session.status);
1030 if (settledTurnState === null) {
1031 return;
1032 }
1033 const existingTurns = yield* projectionTurnRepository.listByThreadId({
1034 threadId: event.payload.threadId,
1035 });
1036 yield* Effect.forEach(
1037 existingTurns.filter((turn) => turn.turnId !== null && turn.state === "running"),
1038 (turn) =>
1039 turn.turnId === null
1040 ? Effect.void
1041 : projectionTurnRepository.upsertByTurnId({
1042 ...turn,
1043 turnId: turn.turnId,
1044 state: settledTurnState,
1045 // A running turn's completedAt can only hold a mid-turn
1046 // placeholder checkpoint timestamp — the session leaving
1047 // "running" is the authoritative turn end.
1048 completedAt: event.payload.session.updatedAt,
1049 }),
1050 { concurrency: 1 },
1051 );
10021052 return;
10031053 }
10041054
1055 // A new active turn supersedes any still-running turn on the same
1056 // thread — steering can open a new turn without the provider ever
1057 // completing the previous one.
1058 const otherRunningTurns = yield* projectionTurnRepository.listByThreadId({
1059 threadId: event.payload.threadId,
1060 });
1061 yield* Effect.forEach(
1062 otherRunningTurns.filter(
1063 (turn) => turn.turnId !== null && turn.turnId !== turnId && turn.state === "running",
1064 ),
1065 (turn) =>
1066 turn.turnId === null
1067 ? Effect.void
1068 : projectionTurnRepository.upsertByTurnId({
1069 ...turn,
1070 turnId: turn.turnId,
1071 state: "completed",
1072 completedAt: event.payload.session.updatedAt,
1073 }),
1074 { concurrency: 1 },
1075 );
1076
10051077 const existingTurn = yield* projectionTurnRepository.getByTurnId({
10061078 threadId: event.payload.threadId,
10071079 turnId,
......@@ -1080,6 +1152,19 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
10801152 if (event.payload.turnId === null || event.payload.role !== "assistant") {
10811153 return;
10821154 }
1155 // A completed assistant message only settles the turn once the
1156 // session is no longer running it — providers may emit several
1157 // assistant messages per turn (commentary between tool calls), and
1158 // the turn must stay unsettled until the provider reports turn end
1159 // (projected as thread.session-set leaving the "running" status).
1160 const session = yield* projectionThreadSessionRepository.getByThreadId({
1161 threadId: event.payload.threadId,
1162 });
1163 const turnStillRunning =
1164 Option.isSome(session) &&
1165 session.value.status === "running" &&
1166 session.value.activeTurnId === event.payload.turnId;
1167 const settlesTurn = !event.payload.streaming && !turnStillRunning;
10831168 const existingTurn = yield* projectionTurnRepository.getByTurnId({
10841169 threadId: event.payload.threadId,
10851170 turnId: event.payload.turnId,
......@@ -1088,16 +1173,16 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
10881173 yield* projectionTurnRepository.upsertByTurnId({
10891174 ...existingTurn.value,
10901175 assistantMessageId: event.payload.messageId,
1091 state: event.payload.streaming
1092 ? existingTurn.value.state
1093 : existingTurn.value.state === "interrupted"
1176 state: settlesTurn
1177 ? existingTurn.value.state === "interrupted"
10941178 ? "interrupted"
10951179 : existingTurn.value.state === "error"
10961180 ? "error"
1097 : "completed",
1098 completedAt: event.payload.streaming
1099 ? existingTurn.value.completedAt
1100 : (existingTurn.value.completedAt ?? event.payload.updatedAt),
1181 : "completed"
1182 : existingTurn.value.state,
1183 completedAt: settlesTurn
1184 ? (existingTurn.value.completedAt ?? event.payload.updatedAt)
1185 : existingTurn.value.completedAt,
11011186 startedAt: existingTurn.value.startedAt ?? event.payload.createdAt,
11021187 requestedAt: existingTurn.value.requestedAt ?? event.payload.createdAt,
11031188 });
......@@ -1110,10 +1195,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
11101195 sourceProposedPlanThreadId: null,
11111196 sourceProposedPlanId: null,
11121197 assistantMessageId: event.payload.messageId,
1113 state: event.payload.streaming ? "running" : "completed",
1198 state: settlesTurn ? "completed" : "running",
11141199 requestedAt: event.payload.createdAt,
11151200 startedAt: event.payload.createdAt,
1116 completedAt: event.payload.streaming ? null : event.payload.updatedAt,
1201 completedAt: settlesTurn ? event.payload.updatedAt : null,
11171202 checkpointTurnCount: null,
11181203 checkpointRef: null,
11191204 checkpointStatus: null,
......@@ -1160,6 +1245,15 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
11601245 }
11611246
11621247 case "thread.turn-diff-completed": {
1248 // Mid-turn diff updates produce placeholder checkpoints; record the
1249 // checkpoint, but don't settle a turn its session is still running.
1250 const session = yield* projectionThreadSessionRepository.getByThreadId({
1251 threadId: event.payload.threadId,
1252 });
1253 const turnStillRunning =
1254 Option.isSome(session) &&
1255 session.value.status === "running" &&
1256 session.value.activeTurnId === event.payload.turnId;
11631257 const existingTurn = yield* projectionTurnRepository.getByTurnId({
11641258 threadId: event.payload.threadId,
11651259 turnId: event.payload.turnId,
......@@ -1175,7 +1269,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
11751269 yield* projectionTurnRepository.upsertByTurnId({
11761270 ...existingTurn.value,
11771271 assistantMessageId: event.payload.assistantMessageId,
1178 state: nextState,
1272 state: turnStillRunning ? existingTurn.value.state : nextState,
11791273 checkpointTurnCount: event.payload.checkpointTurnCount,
11801274 checkpointRef: event.payload.checkpointRef,
11811275 checkpointStatus: event.payload.status,
......@@ -1193,7 +1287,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
11931287 sourceProposedPlanThreadId: null,
11941288 sourceProposedPlanId: null,
11951289 assistantMessageId: event.payload.assistantMessageId,
1196 state: nextState,
1290 state: turnStillRunning ? "running" : nextState,
11971291 requestedAt: event.payload.completedAt,
11981292 startedAt: event.payload.completedAt,
11991293 completedAt: event.payload.completedAt,
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+117-31
......@@ -1117,39 +1117,39 @@ describe("ProviderRuntimeIngestion", () => {
11171117 const createdAt = "2026-01-01T00:00:00.000Z";
11181118
11191119 await Effect.runPromise(
1120 harness.engine.dispatch({
1121 type: "thread.create",
1122 commandId: CommandId.make("cmd-thread-create-plan-source-guarded"),
1123 threadId: sourceThreadId,
1124 projectId: asProjectId("project-1"),
1125 title: "Plan Source",
1126 modelSelection: {
1127 instanceId: ProviderInstanceId.make("codex"),
1128 model: "gpt-5-codex",
1129 },
1130 interactionMode: "plan",
1131 runtimeMode: "approval-required",
1132 branch: null,
1133 worktreePath: null,
1134 createdAt,
1135 }),
1136 );
1137 await Effect.runPromise(
1138 harness.engine.dispatch({
1139 type: "thread.session.set",
1140 commandId: CommandId.make("cmd-session-set-plan-source-guarded"),
1141 threadId: sourceThreadId,
1142 session: {
1120 Effect.andThen(
1121 harness.engine.dispatch({
1122 type: "thread.create",
1123 commandId: CommandId.make("cmd-thread-create-plan-source-guarded"),
11431124 threadId: sourceThreadId,
1144 status: "ready",
1145 providerName: "codex",
1125 projectId: asProjectId("project-1"),
1126 title: "Plan Source",
1127 modelSelection: {
1128 instanceId: ProviderInstanceId.make("codex"),
1129 model: "gpt-5-codex",
1130 },
1131 interactionMode: "plan",
11461132 runtimeMode: "approval-required",
1147 activeTurnId: null,
1148 updatedAt: createdAt,
1149 lastError: null,
1150 },
1151 createdAt,
1152 }),
1133 branch: null,
1134 worktreePath: null,
1135 createdAt,
1136 }),
1137 harness.engine.dispatch({
1138 type: "thread.session.set",
1139 commandId: CommandId.make("cmd-session-set-plan-source-guarded"),
1140 threadId: sourceThreadId,
1141 session: {
1142 threadId: sourceThreadId,
1143 status: "ready",
1144 providerName: "codex",
1145 runtimeMode: "approval-required",
1146 activeTurnId: null,
1147 updatedAt: createdAt,
1148 lastError: null,
1149 },
1150 createdAt,
1151 }),
1152 ),
11531153 );
11541154 harness.setProviderSession({
11551155 provider: ProviderDriverKind.make("codex"),
......@@ -1260,6 +1260,92 @@ describe("ProviderRuntimeIngestion", () => {
12601260 expect(targetThreadAfterRejectedStart?.session?.activeTurnId).toBe(activeTurnId);
12611261 });
12621262
1263 it("accepts a conflicting turn.started for a pending turn start when the provider expects that turn", async () => {
1264 // Steering a running turn: the server requests a new turn while the old
1265 // one is still active, and providers like opencode open the new turn
1266 // without ever completing the superseded one. The new turn.started must
1267 // replace the active turn instead of being rejected as stale.
1268 const harness = await createHarness();
1269 const threadId = asThreadId("thread-1");
1270 const oldTurnId = asTurnId("turn-steered-over");
1271 const newTurnId = asTurnId("turn-from-steer");
1272 const createdAt = "2026-01-01T00:00:00.000Z";
1273
1274 harness.setProviderSession({
1275 provider: ProviderDriverKind.make("codex"),
1276 status: "running",
1277 runtimeMode: "approval-required",
1278 threadId,
1279 createdAt,
1280 updatedAt: createdAt,
1281 activeTurnId: oldTurnId,
1282 });
1283 harness.emit({
1284 type: "turn.started",
1285 eventId: asEventId("evt-turn-started-steered-over"),
1286 provider: ProviderDriverKind.make("codex"),
1287 createdAt,
1288 threadId,
1289 turnId: oldTurnId,
1290 });
1291 await waitForThread(
1292 harness.readModel,
1293 (thread) =>
1294 thread.session?.status === "running" && thread.session?.activeTurnId === oldTurnId,
1295 2_000,
1296 threadId,
1297 );
1298
1299 // The steer: a user-requested turn start while the old turn still runs.
1300 await Effect.runPromise(
1301 harness.engine.dispatch({
1302 type: "thread.turn.start",
1303 commandId: CommandId.make("cmd-turn-start-steer"),
1304 threadId,
1305 message: {
1306 messageId: asMessageId("msg-steer"),
1307 role: "user",
1308 text: "actually, do 15 instead",
1309 attachments: [],
1310 },
1311 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1312 runtimeMode: "approval-required",
1313 createdAt,
1314 }),
1315 );
1316
1317 // The provider session tracks the new turn before emitting turn.started
1318 // (sendTurn updates the session first).
1319 harness.setProviderSession({
1320 provider: ProviderDriverKind.make("codex"),
1321 status: "running",
1322 runtimeMode: "approval-required",
1323 threadId,
1324 createdAt,
1325 updatedAt: createdAt,
1326 activeTurnId: newTurnId,
1327 });
1328 harness.emit({
1329 type: "turn.started",
1330 eventId: asEventId("evt-turn-started-from-steer"),
1331 provider: ProviderDriverKind.make("codex"),
1332 createdAt,
1333 threadId,
1334 turnId: newTurnId,
1335 });
1336
1337 const threadAfterSteer = await waitForThread(
1338 harness.readModel,
1339 (thread) =>
1340 thread.session?.status === "running" && thread.session?.activeTurnId === newTurnId,
1341 2_000,
1342 threadId,
1343 );
1344 expect(threadAfterSteer.session?.activeTurnId).toBe(newTurnId);
1345 expect(threadAfterSteer.latestTurn?.turnId).toBe(newTurnId);
1346 expect(threadAfterSteer.latestTurn?.state).toBe("running");
1347 });
1348
12631349 it("does not mark the source proposed plan implemented for an unrelated turn.started when no thread active turn is tracked", async () => {
12641350 const harness = await createHarness();
12651351 const sourceThreadId = asThreadId("thread-plan");
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+17-1
......@@ -1226,6 +1226,22 @@ const make = Effect.gen(function* () {
12261226 activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId);
12271227 const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined;
12281228
1229 // A turn.started that conflicts with the active turn is legitimate when
1230 // the server itself has a turn start pending for this thread AND the
1231 // provider session already tracks the event's turn as its active turn:
1232 // steering a running turn makes some providers (e.g. opencode) open a
1233 // new turn without ever completing the superseded one. A stale
1234 // turn.started for some other turn id still gets rejected.
1235 const conflictingTurnStartIsPendingTurnStart =
1236 event.type === "turn.started" && conflictsWithActiveTurn
1237 ? sameId(yield* getExpectedProviderTurnIdForThread(thread.id), eventTurnId) &&
1238 Option.isSome(
1239 yield* projectionTurnRepository.getPendingTurnStartByThreadId({
1240 threadId: thread.id,
1241 }),
1242 )
1243 : false;
1244
12291245 const shouldApplyThreadLifecycle = (() => {
12301246 if (!STRICT_PROVIDER_LIFECYCLE_GUARD) {
12311247 return true;
......@@ -1237,7 +1253,7 @@ const make = Effect.gen(function* () {
12371253 case "thread.started":
12381254 return true;
12391255 case "turn.started":
1240 return !conflictsWithActiveTurn;
1256 return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart;
12411257 case "turn.completed":
12421258 if (conflictsWithActiveTurn || missingTurnForActiveTurn) {
12431259 return false;
apps/server/src/orchestration/projector.test.ts+62-23
......@@ -267,37 +267,76 @@ describe("orchestration projector", () => {
267267 ),
268268 );
269269
270 const afterRunning = await Effect.runPromise(
271 projectEvent(
272 afterCreate,
273 makeEvent({
274 sequence: 2,
275 type: "thread.session-set",
276 aggregateKind: "thread",
277 aggregateId: "thread-1",
278 occurredAt: startedAt,
279 commandId: "cmd-running",
280 payload: {
281 threadId: "thread-1",
282 session: {
270 const settledAt = "2026-02-23T08:01:00.000Z";
271 const [afterRunning, afterReady] = await Effect.runPromise(
272 Effect.flatMap(
273 projectEvent(
274 afterCreate,
275 makeEvent({
276 sequence: 2,
277 type: "thread.session-set",
278 aggregateKind: "thread",
279 aggregateId: "thread-1",
280 occurredAt: startedAt,
281 commandId: "cmd-running",
282 payload: {
283283 threadId: "thread-1",
284 status: "running",
285 providerName: "codex",
286 providerSessionId: "session-1",
287 providerThreadId: "provider-thread-1",
288 runtimeMode: "approval-required",
289 activeTurnId: "turn-1",
290 lastError: null,
291 updatedAt: startedAt,
284 session: {
285 threadId: "thread-1",
286 status: "running",
287 providerName: "codex",
288 providerSessionId: "session-1",
289 providerThreadId: "provider-thread-1",
290 runtimeMode: "approval-required",
291 activeTurnId: "turn-1",
292 lastError: null,
293 updatedAt: startedAt,
294 },
292295 },
293 },
294 }),
296 }),
297 ),
298 (running) =>
299 Effect.map(
300 projectEvent(
301 running,
302 makeEvent({
303 sequence: 3,
304 type: "thread.session-set",
305 aggregateKind: "thread",
306 aggregateId: "thread-1",
307 occurredAt: settledAt,
308 commandId: "cmd-ready",
309 payload: {
310 threadId: "thread-1",
311 session: {
312 threadId: "thread-1",
313 status: "ready",
314 providerName: "codex",
315 providerSessionId: "session-1",
316 providerThreadId: "provider-thread-1",
317 runtimeMode: "approval-required",
318 activeTurnId: null,
319 lastError: null,
320 updatedAt: settledAt,
321 },
322 },
323 }),
324 ),
325 (ready) => [running, ready] as const,
326 ),
295327 ),
296328 );
297329
298330 const thread = afterRunning.threads[0];
299331 expect(thread?.latestTurn?.turnId).toBe("turn-1");
300332 expect(thread?.session?.status).toBe("running");
333
334 // Leaving the "running" session status settles the running turn with the
335 // session timestamp as the turn end.
336 const settledThread = afterReady.threads[0];
337 expect(settledThread?.latestTurn?.turnId).toBe("turn-1");
338 expect(settledThread?.latestTurn?.state).toBe("completed");
339 expect(settledThread?.latestTurn?.completedAt).toBe(settledAt);
301340 });
302341
303342 it("updates canonical thread runtime mode from thread.runtime-mode-set", async () => {
apps/server/src/orchestration/projector.ts+59-15
......@@ -38,6 +38,29 @@ function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error"
3838 return "completed" as const;
3939}
4040
41/**
42 * Turn state to settle a still-running latest turn with when its session
43 * leaves the "running" status, or null while the session is (re)starting or
44 * running and the turn must stay unsettled.
45 */
46function settledTurnStateForSessionStatus(
47 status: OrchestrationSession["status"],
48): "completed" | "interrupted" | "error" | null {
49 switch (status) {
50 case "idle":
51 case "ready":
52 return "completed";
53 case "error":
54 return "error";
55 case "interrupted":
56 case "stopped":
57 return "interrupted";
58 case "starting":
59 case "running":
60 return null;
61 }
62}
63
4164function updateThread(
4265 threads: ReadonlyArray<OrchestrationThread>,
4366 threadId: ThreadId,
......@@ -438,6 +461,9 @@ export function projectEvent(
438461 "session",
439462 );
440463
464 // Leaving the "running" session status is the turn-end signal: settle
465 // a still-running latest turn so its duration reflects the whole turn.
466 const settledTurnState = settledTurnStateForSessionStatus(session.status);
441467 return {
442468 ...nextBase,
443469 threads: updateThread(nextBase.threads, payload.threadId, {
......@@ -461,7 +487,18 @@ export function projectEvent(
461487 ? thread.latestTurn.assistantMessageId
462488 : null,
463489 }
464 : thread.latestTurn,
490 : thread.latestTurn !== null &&
491 thread.latestTurn.state === "running" &&
492 settledTurnState !== null
493 ? {
494 ...thread.latestTurn,
495 state: settledTurnState,
496 // A running turn's completedAt can only hold a mid-turn
497 // placeholder checkpoint timestamp — the session leaving
498 // "running" is the authoritative turn end.
499 completedAt: session.updatedAt,
500 }
501 : thread.latestTurn,
465502 updatedAt: event.occurredAt,
466503 }),
467504 };
......@@ -544,24 +581,31 @@ export function projectEvent(
544581 .toSorted((left, right) => left.checkpointTurnCount - right.checkpointTurnCount)
545582 .slice(-MAX_THREAD_CHECKPOINTS);
546583
584 // Mid-turn diff updates produce placeholder checkpoints; record the
585 // checkpoint, but don't settle a turn its session is still running.
586 const turnStillRunning =
587 thread.session?.status === "running" && thread.session.activeTurnId === payload.turnId;
588
547589 return {
548590 ...nextBase,
549591 threads: updateThread(nextBase.threads, payload.threadId, {
550592 checkpoints,
551 latestTurn: {
552 turnId: payload.turnId,
553 state: checkpointStatusToLatestTurnState(payload.status),
554 requestedAt:
555 thread.latestTurn?.turnId === payload.turnId
556 ? thread.latestTurn.requestedAt
557 : payload.completedAt,
558 startedAt:
559 thread.latestTurn?.turnId === payload.turnId
560 ? (thread.latestTurn.startedAt ?? payload.completedAt)
561 : payload.completedAt,
562 completedAt: payload.completedAt,
563 assistantMessageId: payload.assistantMessageId,
564 },
593 latestTurn: turnStillRunning
594 ? thread.latestTurn
595 : {
596 turnId: payload.turnId,
597 state: checkpointStatusToLatestTurnState(payload.status),
598 requestedAt:
599 thread.latestTurn?.turnId === payload.turnId
600 ? thread.latestTurn.requestedAt
601 : payload.completedAt,
602 startedAt:
603 thread.latestTurn?.turnId === payload.turnId
604 ? (thread.latestTurn.startedAt ?? payload.completedAt)
605 : payload.completedAt,
606 completedAt: payload.completedAt,
607 assistantMessageId: payload.assistantMessageId,
608 },
565609 updatedAt: event.occurredAt,
566610 }),
567611 };
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+67
......@@ -890,6 +890,73 @@ describe("ClaudeAdapterLive", () => {
890890 );
891891 });
892892
893 it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () => {
894 const harness = makeHarness();
895 return Effect.gen(function* () {
896 const adapter = yield* ClaudeAdapter;
897
898 const runtimeEventsFiber = yield* Stream.takeUntil(
899 adapter.streamEvents,
900 (event) => event.type === "turn.completed",
901 ).pipe(Stream.runCollect, Effect.forkChild);
902
903 const session = yield* adapter.startSession({
904 threadId: THREAD_ID,
905 provider: ProviderDriverKind.make("claudeAgent"),
906 runtimeMode: "full-access",
907 });
908
909 const turn = yield* adapter.sendTurn({
910 threadId: session.threadId,
911 input: "run 5 commands",
912 attachments: [],
913 });
914
915 // Steer: a second sendTurn while the turn is still running continues
916 // the same turn — the message is queued into the live agent loop.
917 const steeredTurn = yield* adapter.sendTurn({
918 threadId: session.threadId,
919 input: "actually run 15",
920 attachments: [],
921 });
922 assert.equal(String(steeredTurn.turnId), String(turn.turnId));
923
924 harness.query.emit({
925 type: "assistant",
926 session_id: "sdk-session-steer",
927 uuid: "assistant-steer-1",
928 parent_tool_use_id: null,
929 message: {
930 id: "assistant-message-steer-1",
931 content: [{ type: "text", text: "Adjusting to 15." }],
932 },
933 } as unknown as SDKMessage);
934
935 harness.query.emit({
936 type: "result",
937 subtype: "success",
938 is_error: false,
939 errors: [],
940 session_id: "sdk-session-steer",
941 uuid: "result-steer-1",
942 } as unknown as SDKMessage);
943
944 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
945 const turnStartedEvents = runtimeEvents.filter((event) => event.type === "turn.started");
946 const turnCompletedEvents = runtimeEvents.filter((event) => event.type === "turn.completed");
947
948 // One turn boundary for the whole run: the steer produced no
949 // turn.completed/turn.started pair.
950 assert.equal(turnStartedEvents.length, 1);
951 assert.equal(String(turnStartedEvents[0]?.turnId), String(turn.turnId));
952 assert.equal(turnCompletedEvents.length, 1);
953 assert.equal(String(turnCompletedEvents[0]?.turnId), String(turn.turnId));
954 }).pipe(
955 Effect.provideService(Random.Random, makeDeterministicRandomService()),
956 Effect.provide(harness.layer),
957 );
958 });
959
893960 it.effect("maps Claude reasoning deltas, streamed tool inputs, and tool results", () => {
894961 const harness = makeHarness();
895962 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+47-32
......@@ -122,6 +122,13 @@ interface ClaudeResumeState {
122122interface ClaudeTurnState {
123123 readonly turnId: TurnId;
124124 readonly startedAt: string;
125 /**
126 * True for turns auto-started by assistant output arriving without an
127 * active turn (background agent/subagent responses between user prompts).
128 * Synthetic turns are auto-closed by the next sendTurn; real turns are
129 * steered instead (the queued message continues the same turn).
130 */
131 readonly synthetic?: boolean;
125132 readonly items: Array<unknown>;
126133 readonly assistantTextBlocks: Map<number, AssistantTextBlockState>;
127134 readonly assistantTextBlockOrder: Array<AssistantTextBlockState>;
......@@ -2486,6 +2493,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
24862493 context.turnState = {
24872494 turnId,
24882495 startedAt,
2496 synthetic: true,
24892497 items: [],
24902498 assistantTextBlocks: new Map(),
24912499 assistantTextBlockOrder: [],
......@@ -3629,9 +3637,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
36293637 ? input.modelSelection
36303638 : undefined;
36313639
3632 if (context.turnState) {
3633 // Auto-close a stale synthetic turn (from background agent responses
3634 // between user prompts) to prevent blocking the user's next turn.
3640 // A sendTurn while a real turn is running is a steer: the message is
3641 // queued into the live SDK agent loop and the work continues as the same
3642 // turn — no synthetic turn boundary. Stale synthetic turns (from
3643 // background agent responses between user prompts) are auto-closed
3644 // instead, so they don't block the user's next turn.
3645 const steeringTurnState =
3646 context.turnState && context.turnState.synthetic !== true ? context.turnState : null;
3647 if (context.turnState && steeringTurnState === null) {
36353648 yield* completeTurn(context, "completed");
36363649 }
36373650
......@@ -3666,37 +3679,39 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
36663679 });
36673680 }
36683681
3669 const turnId = TurnId.make(yield* randomUUIDv4);
3670 const turnState: ClaudeTurnState = {
3671 turnId,
3672 startedAt: yield* nowIso,
3673 items: [],
3674 assistantTextBlocks: new Map(),
3675 assistantTextBlockOrder: [],
3676 capturedProposedPlanKeys: new Set(),
3677 nextSyntheticAssistantBlockIndex: -1,
3678 };
3682 const turnId = steeringTurnState?.turnId ?? TurnId.make(yield* randomUUIDv4);
3683 if (steeringTurnState === null) {
3684 const turnState: ClaudeTurnState = {
3685 turnId,
3686 startedAt: yield* nowIso,
3687 items: [],
3688 assistantTextBlocks: new Map(),
3689 assistantTextBlockOrder: [],
3690 capturedProposedPlanKeys: new Set(),
3691 nextSyntheticAssistantBlockIndex: -1,
3692 };
36793693
3680 const updatedAt = yield* nowIso;
3681 context.turnState = turnState;
3682 context.session = {
3683 ...context.session,
3684 status: "running",
3685 activeTurnId: turnId,
3686 updatedAt,
3687 };
3694 const updatedAt = yield* nowIso;
3695 context.turnState = turnState;
3696 context.session = {
3697 ...context.session,
3698 status: "running",
3699 activeTurnId: turnId,
3700 updatedAt,
3701 };
36883702
3689 const turnStartedStamp = yield* makeEventStamp();
3690 yield* offerRuntimeEvent({
3691 type: "turn.started",
3692 eventId: turnStartedStamp.eventId,
3693 provider: PROVIDER,
3694 createdAt: turnStartedStamp.createdAt,
3695 threadId: context.session.threadId,
3696 turnId,
3697 payload: modelSelection?.model ? { model: modelSelection.model } : {},
3698 providerRefs: {},
3699 });
3703 const turnStartedStamp = yield* makeEventStamp();
3704 yield* offerRuntimeEvent({
3705 type: "turn.started",
3706 eventId: turnStartedStamp.eventId,
3707 provider: PROVIDER,
3708 createdAt: turnStartedStamp.createdAt,
3709 threadId: context.session.threadId,
3710 turnId,
3711 payload: modelSelection?.model ? { model: modelSelection.model } : {},
3712 providerRefs: {},
3713 });
3714 }
37003715
37013716 const message = yield* buildUserMessageEffect(input, {
37023717 fileSystem,
apps/server/src/provider/Layers/CursorAdapter.test.ts+77
......@@ -13,6 +13,7 @@ import * as Fiber from "effect/Fiber";
1313import * as Layer from "effect/Layer";
1414import * as Schema from "effect/Schema";
1515import * as Stream from "effect/Stream";
16import * as TestClock from "effect/testing/TestClock";
1617import { createModelSelection } from "@t3tools/shared/model";
1718
1819import {
......@@ -232,6 +233,82 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
232233 }),
233234 );
234235
236 it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () =>
237 Effect.gen(function* () {
238 const adapter = yield* CursorAdapter;
239 const settings = yield* ServerSettingsService;
240 const threadId = ThreadId.make("cursor-steer-thread");
241
242 // Keep the first prompt in flight long enough for the steer to land.
243 const wrapperPath = yield* Effect.promise(() =>
244 makeMockAgentWrapper({ T3_ACP_PROMPT_DELAY_MS: "1500" }),
245 );
246 yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
247
248 const runtimeEventsFiber = yield* adapter.streamEvents.pipe(
249 Stream.filter((event) => event.threadId === threadId),
250 Stream.takeUntil((event) => event.type === "turn.completed"),
251 Stream.runCollect,
252 Effect.forkChild,
253 );
254
255 yield* adapter.startSession({
256 threadId,
257 provider: ProviderDriverKind.make("cursor"),
258 cwd: process.cwd(),
259 runtimeMode: "full-access",
260 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" },
261 });
262
263 const firstTurnFiber = yield* adapter
264 .sendTurn({
265 threadId,
266 input: "run 5 commands",
267 attachments: [],
268 })
269 .pipe(Effect.forkChild);
270
271 // Poll until the first prompt is in flight — sendTurn binds the active
272 // turn id before prompting. The mock agent runs on the real clock, so
273 // each TestClock.adjust just provides the scheduler hops for its stdio
274 // responses to land.
275 yield* Effect.gen(function* () {
276 for (let attempt = 0; attempt < 200; attempt += 1) {
277 const sessions = yield* adapter.listSessions();
278 const session = sessions.find((entry) => entry.threadId === threadId);
279 if (session?.activeTurnId !== undefined) {
280 return;
281 }
282 yield* TestClock.adjust("10 millis");
283 }
284 throw new Error("Timed out waiting for the first prompt to be in flight.");
285 });
286
287 // Steer: a second sendTurn while the first prompt is still in flight
288 // continues the same turn.
289 const steeredTurn = yield* adapter.sendTurn({
290 threadId,
291 input: "actually run 15",
292 attachments: [],
293 });
294 const firstTurn = yield* Fiber.join(firstTurnFiber);
295 assert.equal(String(steeredTurn.turnId), String(firstTurn.turnId));
296
297 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
298 const turnStartedEvents = runtimeEvents.filter((event) => event.type === "turn.started");
299 const turnCompletedEvents = runtimeEvents.filter((event) => event.type === "turn.completed");
300
301 // One turn boundary for the whole run: the superseded first prompt
302 // resolving must not settle the merged turn.
303 assert.equal(turnStartedEvents.length, 1);
304 assert.equal(String(turnStartedEvents[0]?.turnId), String(firstTurn.turnId));
305 assert.equal(turnCompletedEvents.length, 1);
306 assert.equal(String(turnCompletedEvents[0]?.turnId), String(firstTurn.turnId));
307
308 yield* adapter.stopSession(threadId);
309 }),
310 );
311
235312 it.effect("closes the ACP child process when a session stops", () =>
236313 Effect.gen(function* () {
237314 const adapter = yield* CursorAdapter;
apps/server/src/provider/Layers/CursorAdapter.ts+141-105
......@@ -132,6 +132,10 @@ interface CursorSessionContext {
132132 readonly turns: Array<{ id: TurnId; items: Array<unknown> }>;
133133 lastPlanFingerprint: string | undefined;
134134 activeTurnId: TurnId | undefined;
135 /** Number of sendTurn prompts currently in flight or being prepared.
136 * >0 means a turn is actively running, so a new sendTurn is a steer that
137 * continues it, and only the last remaining prompt settles the turn. */
138 promptsInFlight: number;
135139 stopped: boolean;
136140}
137141
......@@ -754,6 +758,7 @@ export function makeCursorAdapter(
754758 turns: [],
755759 lastPlanFingerprint: undefined,
756760 activeTurnId: undefined,
761 promptsInFlight: 0,
757762 stopped: false,
758763 };
759764
......@@ -882,121 +887,152 @@ export function makeCursorAdapter(
882887 const sendTurn: CursorAdapterShape["sendTurn"] = (input) =>
883888 Effect.gen(function* () {
884889 const ctx = yield* requireSession(input.threadId);
885 const turnId = TurnId.make(yield* randomUUIDv4);
886 const turnModelSelection =
887 input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;
888 const model = turnModelSelection?.model ?? ctx.session.model;
889 const resolvedModel = resolveCursorAcpBaseModelId(model);
890 yield* applyRequestedSessionConfiguration({
891 runtime: ctx.acp,
892 runtimeMode: ctx.session.runtimeMode,
893 interactionMode: input.interactionMode,
894 modelSelection:
895 model === undefined
896 ? undefined
897 : {
898 model,
899 options: turnModelSelection?.options,
900 },
901 mapError: ({ cause, method }) =>
902 mapAcpToAdapterError(PROVIDER, input.threadId, method, cause),
903 });
904 ctx.activeTurnId = turnId;
905 ctx.lastPlanFingerprint = undefined;
906 ctx.session = {
907 ...ctx.session,
908 activeTurnId: turnId,
909 updatedAt: yield* nowIso,
910 };
911
912 yield* offerRuntimeEvent({
913 type: "turn.started",
914 ...(yield* makeEventStamp()),
915 provider: PROVIDER,
916 threadId: input.threadId,
917 turnId,
918 payload: { model: resolvedModel },
919 });
890 // A sendTurn while a prompt is in flight is a steer: the agent folds
891 // the new prompt into the ongoing work, so the active turn id is
892 // reused instead of opening a new turn.
893 const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined;
894 const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4);
895 // Count this prompt immediately so a superseded in-flight prompt
896 // resolving from here on does not settle the turn; the matching
897 // decrement is the `ensuring` below.
898 ctx.promptsInFlight += 1;
899
900 return yield* Effect.gen(function* () {
901 const turnModelSelection =
902 input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;
903 const model = turnModelSelection?.model ?? ctx.session.model;
904 const resolvedModel = resolveCursorAcpBaseModelId(model);
905 yield* applyRequestedSessionConfiguration({
906 runtime: ctx.acp,
907 runtimeMode: ctx.session.runtimeMode,
908 interactionMode: input.interactionMode,
909 modelSelection:
910 model === undefined
911 ? undefined
912 : {
913 model,
914 options: turnModelSelection?.options,
915 },
916 mapError: ({ cause, method }) =>
917 mapAcpToAdapterError(PROVIDER, input.threadId, method, cause),
918 });
919 ctx.activeTurnId = turnId;
920 if (steeringTurnId === undefined) {
921 ctx.lastPlanFingerprint = undefined;
922 }
923 ctx.session = {
924 ...ctx.session,
925 activeTurnId: turnId,
926 updatedAt: yield* nowIso,
927 };
920928
921 const promptParts: Array<EffectAcpSchema.ContentBlock> = [];
922 if (input.input?.trim()) {
923 promptParts.push({ type: "text", text: input.input.trim() });
924 }
925 if (input.attachments && input.attachments.length > 0) {
926 for (const attachment of input.attachments) {
927 const attachmentPath = resolveAttachmentPath({
928 attachmentsDir: serverConfig.attachmentsDir,
929 attachment,
929 if (steeringTurnId === undefined) {
930 yield* offerRuntimeEvent({
931 type: "turn.started",
932 ...(yield* makeEventStamp()),
933 provider: PROVIDER,
934 threadId: input.threadId,
935 turnId,
936 payload: { model: resolvedModel },
930937 });
931 if (!attachmentPath) {
932 return yield* new ProviderAdapterRequestError({
933 provider: PROVIDER,
934 method: "session/prompt",
935 detail: `Invalid attachment id '${attachment.id}'.`,
938 }
939
940 const promptParts: Array<EffectAcpSchema.ContentBlock> = [];
941 if (input.input?.trim()) {
942 promptParts.push({ type: "text", text: input.input.trim() });
943 }
944 if (input.attachments && input.attachments.length > 0) {
945 for (const attachment of input.attachments) {
946 const attachmentPath = resolveAttachmentPath({
947 attachmentsDir: serverConfig.attachmentsDir,
948 attachment,
949 });
950 if (!attachmentPath) {
951 return yield* new ProviderAdapterRequestError({
952 provider: PROVIDER,
953 method: "session/prompt",
954 detail: `Invalid attachment id '${attachment.id}'.`,
955 });
956 }
957 const bytes = yield* fileSystem.readFile(attachmentPath).pipe(
958 Effect.mapError(
959 (cause) =>
960 new ProviderAdapterRequestError({
961 provider: PROVIDER,
962 method: "session/prompt",
963 detail: cause.message,
964 cause,
965 }),
966 ),
967 );
968 promptParts.push({
969 type: "image",
970 data: Buffer.from(bytes).toString("base64"),
971 mimeType: attachment.mimeType,
936972 });
937973 }
938 const bytes = yield* fileSystem.readFile(attachmentPath).pipe(
939 Effect.mapError(
940 (cause) =>
941 new ProviderAdapterRequestError({
942 provider: PROVIDER,
943 method: "session/prompt",
944 detail: cause.message,
945 cause,
946 }),
947 ),
948 );
949 promptParts.push({
950 type: "image",
951 data: Buffer.from(bytes).toString("base64"),
952 mimeType: attachment.mimeType,
953 });
954974 }
955 }
956975
957 if (promptParts.length === 0) {
958 return yield* new ProviderAdapterValidationError({
959 provider: PROVIDER,
960 operation: "sendTurn",
961 issue: "Turn requires non-empty text or attachments.",
962 });
963 }
976 if (promptParts.length === 0) {
977 return yield* new ProviderAdapterValidationError({
978 provider: PROVIDER,
979 operation: "sendTurn",
980 issue: "Turn requires non-empty text or attachments.",
981 });
982 }
964983
965 const result = yield* ctx.acp
966 .prompt({
967 prompt: promptParts,
968 })
969 .pipe(
970 Effect.mapError((error) =>
971 mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error),
972 ),
973 );
984 const result = yield* ctx.acp
985 .prompt({
986 prompt: promptParts,
987 })
988 .pipe(
989 Effect.mapError((error) =>
990 mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error),
991 ),
992 );
974993
975 ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] });
976 ctx.session = {
977 ...ctx.session,
978 activeTurnId: turnId,
979 updatedAt: yield* nowIso,
980 model: resolvedModel,
981 };
994 const turnRecord = ctx.turns.find((turn) => turn.id === turnId);
995 if (turnRecord) {
996 turnRecord.items.push({ prompt: promptParts, result });
997 } else {
998 ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] });
999 }
1000 ctx.session = {
1001 ...ctx.session,
1002 activeTurnId: turnId,
1003 updatedAt: yield* nowIso,
1004 model: resolvedModel,
1005 };
9821006
983 yield* offerRuntimeEvent({
984 type: "turn.completed",
985 ...(yield* makeEventStamp()),
986 provider: PROVIDER,
987 threadId: input.threadId,
988 turnId,
989 payload: {
990 state: result.stopReason === "cancelled" ? "cancelled" : "completed",
991 stopReason: result.stopReason ?? null,
992 },
993 });
1007 // Only the last remaining prompt settles the turn — a steer-
1008 // superseded prompt resolving (usually cancelled) while another is
1009 // in flight or pending must leave the merged turn running.
1010 if (ctx.promptsInFlight === 1) {
1011 yield* offerRuntimeEvent({
1012 type: "turn.completed",
1013 ...(yield* makeEventStamp()),
1014 provider: PROVIDER,
1015 threadId: input.threadId,
1016 turnId,
1017 payload: {
1018 state: result.stopReason === "cancelled" ? "cancelled" : "completed",
1019 stopReason: result.stopReason ?? null,
1020 },
1021 });
1022 }
9941023
995 return {
996 threadId: input.threadId,
997 turnId,
998 resumeCursor: ctx.session.resumeCursor,
999 };
1024 return {
1025 threadId: input.threadId,
1026 turnId,
1027 resumeCursor: ctx.session.resumeCursor,
1028 };
1029 }).pipe(
1030 Effect.ensuring(
1031 Effect.sync(() => {
1032 ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1);
1033 }),
1034 ),
1035 );
10001036 });
10011037
10021038 const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId) =>
apps/server/src/provider/Layers/GrokAdapter.ts+188-134
......@@ -107,6 +107,10 @@ interface GrokSessionContext {
107107 turns: Array<{ id: TurnId; items: Array<unknown> }>;
108108 lastPlanFingerprint: string | undefined;
109109 activeTurnId: TurnId | undefined;
110 /** Number of sendTurn prompts currently in flight or being prepared.
111 * >0 means a turn is actively running, so a new sendTurn is a steer that
112 * continues it, and only the last remaining prompt settles the turn. */
113 promptsInFlight: number;
110114 currentModelId: string | undefined;
111115 stopped: boolean;
112116}
......@@ -555,6 +559,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
555559 turns: [],
556560 lastPlanFingerprint: undefined,
557561 activeTurnId: undefined,
562 promptsInFlight: 0,
558563 currentModelId: boundModelId,
559564 stopped: false,
560565 };
......@@ -664,150 +669,199 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
664669 input.threadId,
665670 Effect.gen(function* () {
666671 const ctx = yield* requireSession(input.threadId);
667 const turnId = TurnId.make(yield* randomUUIDv4);
668 const turnModelSelection =
669 input.modelSelection?.instanceId === boundInstanceId
670 ? input.modelSelection
672 // A sendTurn while a prompt is in flight is a steer: the agent
673 // folds the new prompt into the ongoing work, so the active turn
674 // id is reused instead of opening a new turn.
675 const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined;
676 const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4);
677 // Count this prompt immediately so a superseded in-flight prompt
678 // resolving from here on does not settle the turn; decremented on
679 // preparation failure here, and after the prompt below otherwise.
680 ctx.promptsInFlight += 1;
681
682 return yield* Effect.gen(function* () {
683 const turnModelSelection =
684 input.modelSelection?.instanceId === boundInstanceId
685 ? input.modelSelection
686 : undefined;
687 const requestedTurnModelId = turnModelSelection?.model
688 ? resolveGrokAcpBaseModelId(turnModelSelection.model)
671689 : undefined;
672 const requestedTurnModelId = turnModelSelection?.model
673 ? resolveGrokAcpBaseModelId(turnModelSelection.model)
674 : undefined;
675 const currentModelId = yield* applyGrokAcpModelSelection({
676 runtime: ctx.acp,
677 currentModelId: ctx.currentModelId,
678 requestedModelId: requestedTurnModelId,
679 mapError: (cause) =>
680 mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause),
681 });
690 const currentModelId = yield* applyGrokAcpModelSelection({
691 runtime: ctx.acp,
692 currentModelId: ctx.currentModelId,
693 requestedModelId: requestedTurnModelId,
694 mapError: (cause) =>
695 mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause),
696 });
682697
683 const text = input.input?.trim();
684 const imagePromptParts = yield* Effect.forEach(input.attachments ?? [], (attachment) =>
685 Effect.gen(function* () {
686 const attachmentPath = resolveAttachmentPath({
687 attachmentsDir: serverConfig.attachmentsDir,
688 attachment,
689 });
690 if (!attachmentPath) {
691 return yield* new ProviderAdapterRequestError({
692 provider: PROVIDER,
693 method: "session/prompt",
694 detail: `Invalid attachment id '${attachment.id}'.`,
695 });
696 }
697 const bytes = yield* fileSystem.readFile(attachmentPath).pipe(
698 Effect.mapError(
699 (cause) =>
700 new ProviderAdapterRequestError({
698 const text = input.input?.trim();
699 const imagePromptParts = yield* Effect.forEach(
700 input.attachments ?? [],
701 (attachment) =>
702 Effect.gen(function* () {
703 const attachmentPath = resolveAttachmentPath({
704 attachmentsDir: serverConfig.attachmentsDir,
705 attachment,
706 });
707 if (!attachmentPath) {
708 return yield* new ProviderAdapterRequestError({
701709 provider: PROVIDER,
702710 method: "session/prompt",
703 detail: cause.message,
704 cause,
705 }),
706 ),
707 );
708 return {
709 type: "image",
710 data: Buffer.from(bytes).toString("base64"),
711 mimeType: attachment.mimeType,
712 } satisfies EffectAcpSchema.ContentBlock;
713 }),
714 );
715 const promptParts: Array<EffectAcpSchema.ContentBlock> = [
716 ...(text ? [{ type: "text" as const, text }] : []),
717 ...imagePromptParts,
718 ];
719
720 if (promptParts.length === 0) {
721 return yield* new ProviderAdapterValidationError({
722 provider: PROVIDER,
723 operation: "sendTurn",
724 issue: "Turn requires non-empty text or attachments.",
725 });
726 }
727
728 ctx.currentModelId = currentModelId;
729 const displayModel = currentModelId
730 ? resolveGrokAcpBaseModelId(currentModelId)
731 : undefined;
732 ctx.activeTurnId = turnId;
733 ctx.lastPlanFingerprint = undefined;
734 ctx.session = {
735 ...ctx.session,
736 activeTurnId: turnId,
737 updatedAt: yield* nowIso,
738 ...(displayModel ? { model: displayModel } : {}),
739 };
740
741 yield* offerRuntimeEvent({
742 type: "turn.started",
743 ...(yield* makeEventStamp()),
744 provider: PROVIDER,
745 threadId: input.threadId,
746 turnId,
747 payload: displayModel ? { model: displayModel } : {},
748 });
711 detail: `Invalid attachment id '${attachment.id}'.`,
712 });
713 }
714 const bytes = yield* fileSystem.readFile(attachmentPath).pipe(
715 Effect.mapError(
716 (cause) =>
717 new ProviderAdapterRequestError({
718 provider: PROVIDER,
719 method: "session/prompt",
720 detail: cause.message,
721 cause,
722 }),
723 ),
724 );
725 return {
726 type: "image",
727 data: Buffer.from(bytes).toString("base64"),
728 mimeType: attachment.mimeType,
729 } satisfies EffectAcpSchema.ContentBlock;
730 }),
731 );
732 const promptParts: Array<EffectAcpSchema.ContentBlock> = [
733 ...(text ? [{ type: "text" as const, text }] : []),
734 ...imagePromptParts,
735 ];
736
737 if (promptParts.length === 0) {
738 return yield* new ProviderAdapterValidationError({
739 provider: PROVIDER,
740 operation: "sendTurn",
741 issue: "Turn requires non-empty text or attachments.",
742 });
743 }
749744
750 return {
751 acp: ctx.acp,
752 acpSessionId: ctx.acpSessionId,
753 displayModel,
754 promptParts,
755 turnId,
756 };
745 ctx.currentModelId = currentModelId;
746 const displayModel = currentModelId
747 ? resolveGrokAcpBaseModelId(currentModelId)
748 : undefined;
749 ctx.activeTurnId = turnId;
750 if (steeringTurnId === undefined) {
751 ctx.lastPlanFingerprint = undefined;
752 }
753 ctx.session = {
754 ...ctx.session,
755 activeTurnId: turnId,
756 updatedAt: yield* nowIso,
757 ...(displayModel ? { model: displayModel } : {}),
758 };
759
760 if (steeringTurnId === undefined) {
761 yield* offerRuntimeEvent({
762 type: "turn.started",
763 ...(yield* makeEventStamp()),
764 provider: PROVIDER,
765 threadId: input.threadId,
766 turnId,
767 payload: displayModel ? { model: displayModel } : {},
768 });
769 }
770
771 return {
772 acp: ctx.acp,
773 acpSessionId: ctx.acpSessionId,
774 displayModel,
775 promptParts,
776 turnId,
777 };
778 }).pipe(
779 Effect.tapCause(() =>
780 Effect.sync(() => {
781 ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1);
782 }),
783 ),
784 );
757785 }),
758786 );
759787
760 const result = yield* prepared.acp
761 .prompt({
762 prompt: prepared.promptParts,
763 })
764 .pipe(
765 Effect.mapError((error) =>
766 mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error),
767 ),
768 );
788 return yield* Effect.gen(function* () {
789 const result = yield* prepared.acp
790 .prompt({
791 prompt: prepared.promptParts,
792 })
793 .pipe(
794 Effect.mapError((error) =>
795 mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error),
796 ),
797 );
769798
770 return yield* withThreadLock(
771 input.threadId,
772 Effect.gen(function* () {
773 const ctx = yield* requireSession(input.threadId);
774 if (ctx.acpSessionId !== prepared.acpSessionId) {
775 return yield* new ProviderAdapterRequestError({
776 provider: PROVIDER,
777 method: "session/prompt",
778 detail: "Grok session changed before the turn completed.",
779 });
780 }
781
782 ctx.turns = [
783 ...ctx.turns,
784 { id: prepared.turnId, items: [{ prompt: prepared.promptParts, result }] },
785 ];
786 ctx.session = {
787 ...ctx.session,
788 activeTurnId: prepared.turnId,
789 updatedAt: yield* nowIso,
790 ...(prepared.displayModel ? { model: prepared.displayModel } : {}),
791 };
792
793 yield* offerRuntimeEvent({
794 type: "turn.completed",
795 ...(yield* makeEventStamp()),
796 provider: PROVIDER,
797 threadId: input.threadId,
798 turnId: prepared.turnId,
799 payload: {
800 state: result.stopReason === "cancelled" ? "cancelled" : "completed",
801 stopReason: result.stopReason ?? null,
802 },
803 });
799 return yield* withThreadLock(
800 input.threadId,
801 Effect.gen(function* () {
802 const ctx = yield* requireSession(input.threadId);
803 if (ctx.acpSessionId !== prepared.acpSessionId) {
804 return yield* new ProviderAdapterRequestError({
805 provider: PROVIDER,
806 method: "session/prompt",
807 detail: "Grok session changed before the turn completed.",
808 });
809 }
804810
805 return {
806 threadId: input.threadId,
807 turnId: prepared.turnId,
808 resumeCursor: ctx.session.resumeCursor,
809 };
810 }),
811 const existingTurnRecord = ctx.turns.find((turn) => turn.id === prepared.turnId);
812 ctx.turns = existingTurnRecord
813 ? ctx.turns.map((turn) =>
814 turn.id === prepared.turnId
815 ? {
816 ...turn,
817 items: [...turn.items, { prompt: prepared.promptParts, result }],
818 }
819 : turn,
820 )
821 : [
822 ...ctx.turns,
823 { id: prepared.turnId, items: [{ prompt: prepared.promptParts, result }] },
824 ];
825 ctx.session = {
826 ...ctx.session,
827 activeTurnId: prepared.turnId,
828 updatedAt: yield* nowIso,
829 ...(prepared.displayModel ? { model: prepared.displayModel } : {}),
830 };
831
832 // Only the last remaining prompt settles the turn — a steer-
833 // superseded prompt resolving (usually cancelled) while another
834 // is in flight or pending must leave the merged turn running.
835 if (ctx.promptsInFlight === 1) {
836 yield* offerRuntimeEvent({
837 type: "turn.completed",
838 ...(yield* makeEventStamp()),
839 provider: PROVIDER,
840 threadId: input.threadId,
841 turnId: prepared.turnId,
842 payload: {
843 state: result.stopReason === "cancelled" ? "cancelled" : "completed",
844 stopReason: result.stopReason ?? null,
845 },
846 });
847 }
848
849 return {
850 threadId: input.threadId,
851 turnId: prepared.turnId,
852 resumeCursor: ctx.session.resumeCursor,
853 };
854 }),
855 );
856 }).pipe(
857 Effect.ensuring(
858 Effect.sync(() => {
859 const liveCtx = sessions.get(input.threadId);
860 if (liveCtx) {
861 liveCtx.promptsInFlight = Math.max(0, liveCtx.promptsInFlight - 1);
862 }
863 }),
864 ),
811865 );
812866 });
813867
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+79
......@@ -395,6 +395,85 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
395395 }),
396396 );
397397
398 it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () =>
399 Effect.gen(function* () {
400 const adapter = yield* OpenCodeAdapter;
401 const threadId = asThreadId("thread-steer");
402 yield* adapter.startSession({
403 provider: ProviderDriverKind.make("opencode"),
404 threadId,
405 runtimeMode: "full-access",
406 });
407
408 const turn = yield* adapter.sendTurn({
409 threadId,
410 input: "run 5 commands",
411 modelSelection: {
412 instanceId: ProviderInstanceId.make("opencode"),
413 model: "openai/gpt-5",
414 },
415 });
416
417 // Steer: OpenCode queues the prompt into the busy session, so the
418 // active turn id is reused instead of opening a new turn.
419 const steeredTurn = yield* adapter.sendTurn({
420 threadId,
421 input: "actually run 15",
422 modelSelection: {
423 instanceId: ProviderInstanceId.make("opencode"),
424 model: "openai/gpt-5",
425 },
426 });
427 assert.equal(String(steeredTurn.turnId), String(turn.turnId));
428
429 const sessions = yield* adapter.listSessions();
430 const session = sessions.find((entry) => entry.threadId === threadId);
431 assert.equal(session?.status, "running");
432 assert.equal(String(session?.activeTurnId), String(turn.turnId));
433 assert.equal(runtimeMock.state.promptCalls.length, 2);
434 }),
435 );
436
437 it.effect("keeps the running turn when a steer prompt fails", () =>
438 Effect.gen(function* () {
439 const adapter = yield* OpenCodeAdapter;
440 const threadId = asThreadId("thread-steer-failure");
441 yield* adapter.startSession({
442 provider: ProviderDriverKind.make("opencode"),
443 threadId,
444 runtimeMode: "full-access",
445 });
446
447 const turn = yield* adapter.sendTurn({
448 threadId,
449 input: "run 5 commands",
450 modelSelection: {
451 instanceId: ProviderInstanceId.make("opencode"),
452 model: "openai/gpt-5",
453 },
454 });
455
456 runtimeMock.state.promptAsyncError = new Error("steer failed");
457 const error = yield* adapter
458 .sendTurn({
459 threadId,
460 input: "actually run 15",
461 modelSelection: {
462 instanceId: ProviderInstanceId.make("opencode"),
463 model: "openai/gpt-5",
464 },
465 })
466 .pipe(Effect.flip);
467
468 // The original turn keeps running — only the steer prompt failed.
469 assert.equal(error._tag, "ProviderAdapterRequestError");
470 const sessions = yield* adapter.listSessions();
471 const session = sessions.find((entry) => entry.threadId === threadId);
472 assert.equal(session?.status, "running");
473 assert.equal(String(session?.activeTurnId), String(turn.turnId));
474 }),
475 );
476
398477 it.effect("passes agent and variant options for the adapter's bound custom instance id", () => {
399478 const instanceId = ProviderInstanceId.make("opencode_zen");
400479 const adapterLayer = Layer.effect(
apps/server/src/provider/Layers/OpenCodeAdapter.ts+46-37
......@@ -1151,7 +1151,11 @@ export function makeOpenCodeAdapter(
11511151
11521152 const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) {
11531153 const context = ensureSessionContext(sessions, input.threadId);
1154 const turnId = TurnId.make(`opencode-turn-${yield* randomUUIDv4}`);
1154 // A sendTurn while a turn is active is a steer: OpenCode queues the
1155 // prompt into the busy session and the work continues as one turn, so
1156 // the active turn id is reused instead of opening a new turn.
1157 const steeringTurnId = context.activeTurnId;
1158 const turnId = steeringTurnId ?? TurnId.make(`opencode-turn-${yield* randomUUIDv4}`);
11551159 const modelSelection =
11561160 input.modelSelection ??
11571161 (context.session.model
......@@ -1206,14 +1210,16 @@ export function makeOpenCodeAdapter(
12061210 { clearLastError: true },
12071211 );
12081212
1209 yield* emit({
1210 ...(yield* buildEventBase({ threadId: input.threadId, turnId })),
1211 type: "turn.started",
1212 payload: {
1213 model: modelSelection?.model ?? context.session.model,
1214 ...(variant ? { effort: variant } : {}),
1215 },
1216 });
1213 if (steeringTurnId === undefined) {
1214 yield* emit({
1215 ...(yield* buildEventBase({ threadId: input.threadId, turnId })),
1216 type: "turn.started",
1217 payload: {
1218 model: modelSelection?.model ?? context.session.model,
1219 ...(variant ? { effort: variant } : {}),
1220 },
1221 });
1222 }
12171223
12181224 yield* runOpenCodeSdk("session.promptAsync", () =>
12191225 context.client.session.promptAsync({
......@@ -1225,35 +1231,38 @@ export function makeOpenCodeAdapter(
12251231 }),
12261232 ).pipe(
12271233 Effect.mapError(toRequestError),
1228 // On failure: clear active-turn state, flip the session back to ready
1229 // with lastError set, emit turn.aborted, then let the typed error
1230 // propagate. We don't need to rebuild the error here — `toRequestError`
1231 // already produced the right shape.
1234 // On failure of a fresh turn: clear active-turn state, flip the
1235 // session back to ready with lastError set, emit turn.aborted, then
1236 // let the typed error propagate. We don't need to rebuild the error
1237 // here — `toRequestError` already produced the right shape. A failed
1238 // steer leaves the still-running original turn untouched.
12321239 Effect.tapError((requestError) =>
1233 Effect.gen(function* () {
1234 context.activeTurnId = undefined;
1235 context.activeAgent = undefined;
1236 context.activeVariant = undefined;
1237 yield* updateProviderSession(
1238 context,
1239 {
1240 status: "ready",
1241 model: modelSelection?.model ?? context.session.model,
1242 lastError: requestError.detail,
1243 },
1244 { clearActiveTurnId: true },
1245 );
1246 yield* emit({
1247 ...(yield* buildEventBase({
1248 threadId: input.threadId,
1249 turnId,
1250 })),
1251 type: "turn.aborted",
1252 payload: {
1253 reason: requestError.detail,
1254 },
1255 });
1256 }),
1240 steeringTurnId !== undefined
1241 ? Effect.void
1242 : Effect.gen(function* () {
1243 context.activeTurnId = undefined;
1244 context.activeAgent = undefined;
1245 context.activeVariant = undefined;
1246 yield* updateProviderSession(
1247 context,
1248 {
1249 status: "ready",
1250 model: modelSelection?.model ?? context.session.model,
1251 lastError: requestError.detail,
1252 },
1253 { clearActiveTurnId: true },
1254 );
1255 yield* emit({
1256 ...(yield* buildEventBase({
1257 threadId: input.threadId,
1258 turnId,
1259 })),
1260 type: "turn.aborted",
1261 payload: {
1262 reason: requestError.detail,
1263 },
1264 });
1265 }),
12571266 ),
12581267 );
12591268
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+96-2
......@@ -451,8 +451,8 @@ describe("deriveMessagesTimelineRows", () => {
451451 );
452452 expect(foldRow?.turnId).toBe("turn-1");
453453 expect(foldRow?.expanded).toBe(false);
454 // First fold entry (00:00:05) → terminal message completedAt (00:00:22).
455 expect(foldRow?.label).toBe("Worked for 17s");
454 // User message boundary (00:00:00) → terminal message completedAt (00:00:22).
455 expect(foldRow?.label).toBe("Worked for 22s");
456456 expect(collapsedRows.map((row) => row.id)).toEqual([
457457 "user-entry",
458458 "turn-fold:turn-1",
......@@ -480,6 +480,100 @@ describe("deriveMessagesTimelineRows", () => {
480480 ).toBeDefined();
481481 });
482482
483 it("derives a sane duration for a steer-superseded turn with one instant commentary message", () => {
484 // A steer ends the previous turn early: its only message completes the
485 // instant it is created, and trailing work entries land after it. The
486 // fold duration must span from the user message that started the turn to
487 // the last entry, not message createdAt → message completedAt (~0ms).
488 const rows = deriveMessagesTimelineRows({
489 timelineEntries: [
490 {
491 id: "user-entry",
492 kind: "message",
493 createdAt: "2026-01-01T00:00:00Z",
494 message: {
495 id: "user-1" as never,
496 role: "user" as const,
497 text: "do it once more",
498 turnId: null,
499 createdAt: "2026-01-01T00:00:00Z",
500 streaming: false,
501 },
502 },
503 {
504 id: "assistant-commentary-entry",
505 kind: "message",
506 createdAt: "2026-01-01T00:00:09Z",
507 message: {
508 id: "assistant-commentary" as never,
509 role: "assistant" as const,
510 text: "Kicking off call 1.",
511 turnId: "turn-1" as never,
512 createdAt: "2026-01-01T00:00:09Z",
513 completedAt: "2026-01-01T00:00:09Z",
514 streaming: false,
515 },
516 },
517 {
518 id: "work-entry-1",
519 kind: "work",
520 createdAt: "2026-01-01T00:00:12Z",
521 entry: {
522 id: "work-1",
523 createdAt: "2026-01-01T00:00:12Z",
524 turnId: "turn-1" as never,
525 label: "Ran command",
526 tone: "tool" as const,
527 },
528 },
529 {
530 id: "steer-user-entry",
531 kind: "message",
532 createdAt: "2026-01-01T00:00:14Z",
533 message: {
534 id: "user-2" as never,
535 role: "user" as const,
536 text: "actually do 15",
537 turnId: null,
538 createdAt: "2026-01-01T00:00:14Z",
539 streaming: false,
540 },
541 },
542 {
543 id: "assistant-next-turn-entry",
544 kind: "message",
545 createdAt: "2026-01-01T00:00:17Z",
546 message: {
547 id: "assistant-next" as never,
548 role: "assistant" as const,
549 text: "One down — adjusting.",
550 turnId: "turn-2" as never,
551 createdAt: "2026-01-01T00:00:17Z",
552 streaming: true,
553 },
554 },
555 ],
556 latestTurn: {
557 turnId: "turn-2" as never,
558 state: "running",
559 startedAt: "2026-01-01T00:00:14Z",
560 completedAt: null,
561 },
562 isWorking: true,
563 activeTurnStartedAt: "2026-01-01T00:00:14Z",
564 turnDiffSummaryByAssistantMessageId: new Map(),
565 revertTurnCountByUserMessageId: new Map(),
566 });
567
568 const foldRow = rows.find(
569 (row): row is Extract<(typeof rows)[number], { kind: "turn-fold" }> =>
570 row.kind === "turn-fold",
571 );
572 // User message (00:00:00) → trailing work entry (00:00:12).
573 expect(foldRow?.turnId).toBe("turn-1");
574 expect(foldRow?.label).toBe("Worked for 12s");
575 });
576
483577 it("uses latest-turn timings and the stopped label for an interrupted latest turn", () => {
484578 const rows = deriveMessagesTimelineRows({
485579 timelineEntries: [
apps/web/src/components/chat/MessagesTimeline.logic.ts+45-11
......@@ -12,6 +12,16 @@ function computeElapsedMs(startIso: string, endIso: string): number | null {
1212 return Math.max(0, end - start);
1313}
1414
15function maxIsoTimestamp(a: string | null, b: string | null): string | null {
16 if (a === null) return b;
17 if (b === null) return a;
18 const aMs = Date.parse(a);
19 const bMs = Date.parse(b);
20 if (!Number.isFinite(aMs)) return b;
21 if (!Number.isFinite(bMs)) return a;
22 return bMs > aMs ? b : a;
23}
24
1525export interface TimelineDurationMessage {
1626 id: string;
1727 role: "user" | "assistant" | "system";
......@@ -167,10 +177,22 @@ function deriveTurnFolds(input: {
167177 entries: Array<TimelineEntry>;
168178 terminalEntry: Extract<TimelineEntry, { kind: "message" }> | null;
169179 hasStreamingMessage: boolean;
180 /**
181 * The user message that kicked the turn off. Entry timestamps alone
182 * undercount the duration (the first entry appears only once the
183 * provider starts producing output), and a turn cut short by a steer may
184 * hold a single instantaneous commentary message.
185 */
186 startBoundary: string | null;
170187 }
171188 const groupsByTurnId = new Map<TurnId, TurnGroup>();
172189
190 let pendingUserBoundary: string | null = null;
173191 for (const entry of input.timelineEntries) {
192 if (entry.kind === "message" && entry.message.role === "user") {
193 pendingUserBoundary = entry.message.createdAt;
194 continue;
195 }
174196 const turnId =
175197 entry.kind === "message" && entry.message.role === "assistant"
176198 ? (entry.message.turnId ?? null)
......@@ -180,11 +202,20 @@ function deriveTurnFolds(input: {
180202 if (!turnId) {
181203 continue;
182204 }
183 const group = groupsByTurnId.get(turnId) ?? {
184 entries: [],
185 terminalEntry: null,
186 hasStreamingMessage: false,
187 };
205 let group = groupsByTurnId.get(turnId);
206 if (!group) {
207 group = {
208 entries: [],
209 terminalEntry: null,
210 hasStreamingMessage: false,
211 // Each user boundary starts at most one turn; a second turn after the
212 // same user message (e.g. a steer-superseded continuation) falls back
213 // to its own first entry.
214 startBoundary: pendingUserBoundary,
215 };
216 pendingUserBoundary = null;
217 groupsByTurnId.set(turnId, group);
218 }
188219 group.entries.push(entry);
189220 if (entry.kind === "message") {
190221 if (input.terminalAssistantMessageIds.has(entry.message.id)) {
......@@ -194,7 +225,6 @@ function deriveTurnFolds(input: {
194225 group.hasStreamingMessage = true;
195226 }
196227 }
197 groupsByTurnId.set(turnId, group);
198228 }
199229
200230 const foldsByAnchorEntryId = new Map<string, TurnFold>();
......@@ -223,17 +253,21 @@ function deriveTurnFolds(input: {
223253
224254 const isLatestInterruptedTurn =
225255 input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted";
256 // A turn cut short by a steer leaves trailing work entries behind its
257 // terminal message — take whichever ended last.
258 const lastEntryEnd =
259 lastEntry.kind === "message"
260 ? (lastEntry.message.completedAt ?? lastEntry.createdAt)
261 : lastEntry.createdAt;
226262 const elapsedMs =
227263 input.latestTurn?.turnId === turnId &&
228264 input.latestTurn.startedAt &&
229265 input.latestTurn.completedAt
230266 ? computeElapsedMs(input.latestTurn.startedAt, input.latestTurn.completedAt)
231267 : computeElapsedMs(
232 firstEntry.createdAt,
233 group.terminalEntry?.message.completedAt ??
234 (lastEntry.kind === "message"
235 ? (lastEntry.message.completedAt ?? lastEntry.createdAt)
236 : lastEntry.createdAt),
268 group.startBoundary ?? firstEntry.createdAt,
269 maxIsoTimestamp(group.terminalEntry?.message.completedAt ?? null, lastEntryEnd) ??
270 lastEntryEnd,
237271 );
238272 const duration = elapsedMs !== null ? formatDuration(elapsedMs) : null;
239273 const label = isLatestInterruptedTurn
apps/web/src/components/chat/MessagesTimeline.tsx+1-1
......@@ -368,7 +368,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
368368 maintainScrollAtEndThreshold={0.1}
369369 maintainVisibleContentPosition
370370 onScroll={handleScroll}
371 className="h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5"
371 className="scrollbar-gutter-both h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5"
372372 ListHeaderComponent={TIMELINE_LIST_HEADER}
373373 ListFooterComponent={TIMELINE_LIST_FOOTER}
374374 />
apps/web/src/session-logic.ts+5-1
......@@ -264,7 +264,11 @@ export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolea
264264export function formatDuration(durationMs: number): string {
265265 if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms";
266266 if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`;
267 if (durationMs < 10_000) return `${(durationMs / 1_000).toFixed(1)}s`;
267 if (durationMs < 10_000) {
268 const tenths = Math.round(durationMs / 100) / 10;
269 // 9.95s+ rounds up to the next bucket — render "10s", not "10.0s".
270 return tenths >= 10 ? "10s" : `${tenths.toFixed(1)}s`;
271 }
268272 if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`;
269273 const minutes = Math.floor(durationMs / 60_000);
270274 const seconds = Math.round((durationMs % 60_000) / 1_000);
apps/web/src/store.test.ts+32-1
......@@ -776,9 +776,40 @@ describe("incremental orchestration updates", () => {
776776 localEnvironmentId,
777777 );
778778
779 // A completed assistant message must not settle the turn while the
780 // session is still running it — providers emit interim assistant
781 // messages between tool calls.
779782 expect(threadsOf(next)[0]?.session?.status).toBe("running");
780 expect(threadsOf(next)[0]?.latestTurn?.state).toBe("completed");
783 expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running");
784 expect(threadsOf(next)[0]?.latestTurn?.completedAt).toBeNull();
781785 expect(threadsOf(next)[0]?.messages).toHaveLength(1);
786
787 const settled = applyOrchestrationEvents(
788 next,
789 [
790 makeEvent(
791 "thread.session-set",
792 {
793 threadId: thread.id,
794 session: {
795 threadId: thread.id,
796 status: "ready",
797 providerName: "codex",
798 runtimeMode: "full-access",
799 activeTurnId: null,
800 lastError: null,
801 updatedAt: "2026-02-27T00:00:04.000Z",
802 },
803 },
804 { sequence: 4 },
805 ),
806 ],
807 localEnvironmentId,
808 );
809
810 // Leaving the running session status is the turn-end signal.
811 expect(threadsOf(settled)[0]?.latestTurn?.state).toBe("completed");
812 expect(threadsOf(settled)[0]?.latestTurn?.completedAt).toBe("2026-02-27T00:00:04.000Z");
782813 });
783814
784815 it("does not regress latestTurn when an older turn diff completes late", () => {
apps/web/src/store.ts+77-17
......@@ -885,6 +885,29 @@ function buildLatestTurn(params: {
885885 };
886886}
887887
888/**
889 * Turn state to settle a still-running latest turn with when its session
890 * leaves the "running" status, or null while the session is (re)starting or
891 * running and the turn must stay unsettled.
892 */
893function settledTurnStateForSessionStatus(
894 status: OrchestrationSessionStatus,
895): "completed" | "interrupted" | "error" | null {
896 switch (status) {
897 case "idle":
898 case "ready":
899 return "completed";
900 case "error":
901 return "error";
902 case "interrupted":
903 case "stopped":
904 return "interrupted";
905 case "starting":
906 case "running":
907 return null;
908 }
909}
910
888911function rebindTurnDiffSummariesForAssistantMessage(
889912 turnDiffSummaries: ReadonlyArray<TurnDiffSummary>,
890913 turnId: TurnId,
......@@ -1419,6 +1442,15 @@ function applyEnvironmentOrchestrationEvent(
14191442 event.payload.messageId,
14201443 )
14211444 : thread.turnDiffSummaries;
1445 // A completed assistant message only settles the turn once the
1446 // session is no longer running it — providers may emit several
1447 // assistant messages per turn (commentary between tool calls), and
1448 // the turn must stay unsettled until the provider reports turn end.
1449 const turnStillRunning =
1450 event.payload.turnId !== null &&
1451 thread.session?.orchestrationStatus === "running" &&
1452 thread.session.activeTurnId === event.payload.turnId;
1453 const settlesTurn = !event.payload.streaming && !turnStillRunning;
14221454 const latestTurn: Thread["latestTurn"] =
14231455 event.payload.role === "assistant" &&
14241456 event.payload.turnId !== null &&
......@@ -1426,13 +1458,13 @@ function applyEnvironmentOrchestrationEvent(
14261458 ? buildLatestTurn({
14271459 previous: thread.latestTurn,
14281460 turnId: event.payload.turnId,
1429 state: event.payload.streaming
1430 ? "running"
1431 : thread.latestTurn?.state === "interrupted"
1461 state: settlesTurn
1462 ? thread.latestTurn?.state === "interrupted"
14321463 ? "interrupted"
14331464 : thread.latestTurn?.state === "error"
14341465 ? "error"
1435 : "completed",
1466 : "completed"
1467 : "running",
14361468 requestedAt:
14371469 thread.latestTurn?.turnId === event.payload.turnId
14381470 ? thread.latestTurn.requestedAt
......@@ -1442,11 +1474,11 @@ function applyEnvironmentOrchestrationEvent(
14421474 ? (thread.latestTurn.startedAt ?? event.payload.createdAt)
14431475 : event.payload.createdAt,
14441476 sourceProposedPlan: thread.pendingSourceProposedPlan,
1445 completedAt: event.payload.streaming
1446 ? thread.latestTurn?.turnId === event.payload.turnId
1477 completedAt: settlesTurn
1478 ? event.payload.updatedAt
1479 : thread.latestTurn?.turnId === event.payload.turnId
14471480 ? (thread.latestTurn.completedAt ?? null)
1448 : null
1449 : event.payload.updatedAt,
1481 : null,
14501482 assistantMessageId: event.payload.messageId,
14511483 })
14521484 : thread.latestTurn;
......@@ -1460,11 +1492,12 @@ function applyEnvironmentOrchestrationEvent(
14601492 });
14611493
14621494 case "thread.session-set":
1463 return updateThreadState(state, event.payload.threadId, (thread) => ({
1464 ...thread,
1465 session: mapSession(event.payload.session),
1466 error: sanitizeThreadErrorMessage(event.payload.session.lastError),
1467 latestTurn:
1495 return updateThreadState(state, event.payload.threadId, (thread) => {
1496 // Leaving the "running" session status is the turn-end signal:
1497 // settle a still-running latest turn so its duration reflects the
1498 // whole turn, not the last assistant message.
1499 const settledTurnState = settledTurnStateForSessionStatus(event.payload.session.status);
1500 const latestTurn: Thread["latestTurn"] =
14681501 event.payload.session.status === "running" && event.payload.session.activeTurnId !== null
14691502 ? buildLatestTurn({
14701503 previous: thread.latestTurn,
......@@ -1485,9 +1518,30 @@ function applyEnvironmentOrchestrationEvent(
14851518 : null,
14861519 sourceProposedPlan: thread.pendingSourceProposedPlan,
14871520 })
1488 : thread.latestTurn,
1489 updatedAt: event.occurredAt,
1490 }));
1521 : thread.latestTurn !== null &&
1522 thread.latestTurn.state === "running" &&
1523 settledTurnState !== null
1524 ? buildLatestTurn({
1525 previous: thread.latestTurn,
1526 turnId: thread.latestTurn.turnId,
1527 state: settledTurnState,
1528 requestedAt: thread.latestTurn.requestedAt,
1529 startedAt: thread.latestTurn.startedAt,
1530 // A running turn's completedAt can only hold a mid-turn
1531 // placeholder checkpoint timestamp — the session leaving
1532 // "running" is the authoritative turn end.
1533 completedAt: event.payload.session.updatedAt,
1534 assistantMessageId: thread.latestTurn.assistantMessageId,
1535 })
1536 : thread.latestTurn;
1537 return {
1538 ...thread,
1539 session: mapSession(event.payload.session),
1540 error: sanitizeThreadErrorMessage(event.payload.session.lastError),
1541 latestTurn,
1542 updatedAt: event.occurredAt,
1543 };
1544 });
14911545
14921546 case "thread.session-stop-requested":
14931547 return updateThreadState(state, event.payload.threadId, (thread) =>
......@@ -1552,8 +1606,14 @@ function applyEnvironmentOrchestrationEvent(
15521606 (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER),
15531607 )
15541608 .slice(-MAX_THREAD_CHECKPOINTS);
1609 // Mid-turn diff updates produce placeholder checkpoints; record the
1610 // diff summary, but don't settle a turn its session is still running.
1611 const turnStillRunning =
1612 thread.session?.orchestrationStatus === "running" &&
1613 thread.session.activeTurnId === event.payload.turnId;
15551614 const latestTurn =
1556 thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId
1615 !turnStillRunning &&
1616 (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId)
15571617 ? buildLatestTurn({
15581618 previous: thread.latestTurn,
15591619 turnId: event.payload.turnId,
packages/client-runtime/src/threadDetailReducer.test.ts+89
......@@ -288,9 +288,98 @@ describe("applyThreadDetailEvent", () => {
288288 expect(result.thread.latestTurn?.assistantMessageId).toBe("msg-3");
289289 }
290290 });
291
292 it("keeps latestTurn running for interim assistant messages while the session runs the turn", () => {
293 const threadWithRunningSession: OrchestrationThread = {
294 ...baseThread,
295 session: {
296 threadId: ThreadId.make("thread-1"),
297 status: "running",
298 providerName: "claude",
299 runtimeMode: "full-access",
300 activeTurnId: TurnId.make("turn-1"),
301 lastError: null,
302 updatedAt: "2026-04-01T06:59:00.000Z",
303 },
304 latestTurn: {
305 turnId: TurnId.make("turn-1"),
306 state: "running",
307 requestedAt: "2026-04-01T06:59:00.000Z",
308 startedAt: "2026-04-01T06:59:00.000Z",
309 completedAt: null,
310 assistantMessageId: null,
311 },
312 };
313
314 const result = applyThreadDetailEvent(threadWithRunningSession, {
315 ...baseEventFields,
316 sequence: 8,
317 occurredAt: "2026-04-01T07:00:00.000Z",
318 aggregateKind: "thread",
319 aggregateId: ThreadId.make("thread-1"),
320 type: "thread.message-sent",
321 payload: {
322 threadId: ThreadId.make("thread-1"),
323 messageId: MessageId.make("msg-3"),
324 role: "assistant",
325 text: "Interim commentary between tool calls.",
326 turnId: TurnId.make("turn-1"),
327 streaming: false,
328 createdAt: "2026-04-01T07:00:00.000Z",
329 updatedAt: "2026-04-01T07:00:00.000Z",
330 },
331 });
332
333 expect(result.kind).toBe("updated");
334 if (result.kind === "updated") {
335 expect(result.thread.latestTurn?.state).toBe("running");
336 expect(result.thread.latestTurn?.completedAt).toBeNull();
337 }
338 });
291339 });
292340
293341 describe("thread.session-set", () => {
342 it("settles a running latestTurn when the session leaves the running status", () => {
343 const threadWithRunningTurn: OrchestrationThread = {
344 ...baseThread,
345 latestTurn: {
346 turnId: TurnId.make("turn-1"),
347 state: "running",
348 requestedAt: "2026-04-01T07:00:00.000Z",
349 startedAt: "2026-04-01T07:00:00.000Z",
350 completedAt: null,
351 assistantMessageId: MessageId.make("msg-3"),
352 },
353 };
354
355 const result = applyThreadDetailEvent(threadWithRunningTurn, {
356 ...baseEventFields,
357 sequence: 9,
358 occurredAt: "2026-04-01T08:00:00.000Z",
359 aggregateKind: "thread",
360 aggregateId: ThreadId.make("thread-1"),
361 type: "thread.session-set",
362 payload: {
363 threadId: ThreadId.make("thread-1"),
364 session: {
365 threadId: ThreadId.make("thread-1"),
366 status: "ready",
367 providerName: "claude",
368 runtimeMode: "full-access",
369 activeTurnId: null,
370 lastError: null,
371 updatedAt: "2026-04-01T08:00:00.000Z",
372 },
373 },
374 });
375
376 expect(result.kind).toBe("updated");
377 if (result.kind === "updated") {
378 expect(result.thread.latestTurn?.state).toBe("completed");
379 expect(result.thread.latestTurn?.completedAt).toBe("2026-04-01T08:00:00.000Z");
380 }
381 });
382
294383 it("updates session and latestTurn for a running session", () => {
295384 const result = applyThreadDetailEvent(baseThread, {
296385 ...baseEventFields,
packages/client-runtime/src/threadDetailReducer.ts+64-11
......@@ -7,6 +7,7 @@ import type {
77 OrchestrationEvent,
88 OrchestrationLatestTurn,
99 OrchestrationMessage,
10 OrchestrationSession,
1011 OrchestrationThread,
1112 OrchestrationThreadActivity,
1213 TurnId,
......@@ -232,20 +233,29 @@ export function applyThreadDetailEvent(
232233 : Arr.append(thread.messages, message);
233234 const cappedMessages = Arr.takeRight(messages, limits.maxMessages);
234235
235 // Update latestTurn for assistant messages bound to a turn.
236 // Update latestTurn for assistant messages bound to a turn. A completed
237 // assistant message only settles the turn once the session is no longer
238 // running it — providers may emit several assistant messages per turn
239 // (commentary between tool calls), and the turn must stay unsettled
240 // until the provider reports turn end.
241 const turnStillRunning =
242 event.payload.turnId !== null &&
243 thread.session?.status === "running" &&
244 thread.session.activeTurnId === event.payload.turnId;
245 const settlesTurn = !event.payload.streaming && !turnStillRunning;
236246 const latestTurn: OrchestrationThread["latestTurn"] =
237247 event.payload.role === "assistant" &&
238248 event.payload.turnId !== null &&
239249 (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId)
240250 ? {
241251 turnId: event.payload.turnId,
242 state: event.payload.streaming
243 ? "running"
244 : thread.latestTurn?.state === "interrupted"
252 state: settlesTurn
253 ? thread.latestTurn?.state === "interrupted"
245254 ? "interrupted"
246255 : thread.latestTurn?.state === "error"
247256 ? "error"
248 : "completed",
257 : "completed"
258 : "running",
249259 requestedAt:
250260 thread.latestTurn?.turnId === event.payload.turnId
251261 ? thread.latestTurn.requestedAt
......@@ -254,11 +264,11 @@ export function applyThreadDetailEvent(
254264 thread.latestTurn?.turnId === event.payload.turnId
255265 ? (thread.latestTurn.startedAt ?? event.payload.createdAt)
256266 : event.payload.createdAt,
257 completedAt: event.payload.streaming
258 ? thread.latestTurn?.turnId === event.payload.turnId
267 completedAt: settlesTurn
268 ? event.payload.updatedAt
269 : thread.latestTurn?.turnId === event.payload.turnId
259270 ? (thread.latestTurn.completedAt ?? null)
260 : null
261 : event.payload.updatedAt,
271 : null,
262272 assistantMessageId: event.payload.messageId,
263273 }
264274 : thread.latestTurn;
......@@ -287,6 +297,9 @@ export function applyThreadDetailEvent(
287297
288298 // ── Session ─────────────────────────────────────────────────────
289299 case "thread.session-set": {
300 // Leaving the "running" session status is the turn-end signal: settle a
301 // still-running latest turn so its duration reflects the whole turn.
302 const settledTurnState = settledTurnStateForSessionStatus(event.payload.session.status);
290303 const latestTurn: OrchestrationLatestTurn | null =
291304 event.payload.session.status === "running" && event.payload.session.activeTurnId !== null
292305 ? {
......@@ -306,7 +319,18 @@ export function applyThreadDetailEvent(
306319 ? thread.latestTurn.assistantMessageId
307320 : null,
308321 }
309 : thread.latestTurn;
322 : thread.latestTurn !== null &&
323 thread.latestTurn.state === "running" &&
324 settledTurnState !== null
325 ? {
326 ...thread.latestTurn,
327 state: settledTurnState,
328 // A running turn's completedAt can only hold a mid-turn
329 // placeholder checkpoint timestamp — the session leaving
330 // "running" is the authoritative turn end.
331 completedAt: event.payload.session.updatedAt,
332 }
333 : thread.latestTurn;
310334
311335 return {
312336 kind: "updated",
......@@ -380,8 +404,14 @@ export function applyThreadDetailEvent(
380404 Arr.takeRight(limits.maxCheckpoints),
381405 );
382406
407 // Mid-turn diff updates produce placeholder checkpoints; record the
408 // checkpoint, but don't settle a turn its session is still running.
409 const diffTurnStillRunning =
410 thread.session?.status === "running" &&
411 thread.session.activeTurnId === event.payload.turnId;
383412 const latestTurn =
384 thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId
413 !diffTurnStillRunning &&
414 (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId)
385415 ? {
386416 turnId: event.payload.turnId,
387417 state: checkpointStatusToTurnState(event.payload.status),
......@@ -482,6 +512,29 @@ export function applyThreadDetailEvent(
482512
483513// ── Helpers ──────────────────────────────────────────────────────────
484514
515/**
516 * Turn state to settle a still-running latest turn with when its session
517 * leaves the "running" status, or null while the session is (re)starting or
518 * running and the turn must stay unsettled.
519 */
520function settledTurnStateForSessionStatus(
521 status: OrchestrationSession["status"],
522): "completed" | "interrupted" | "error" | null {
523 switch (status) {
524 case "idle":
525 case "ready":
526 return "completed";
527 case "error":
528 return "error";
529 case "interrupted":
530 case "stopped":
531 return "interrupted";
532 case "starting":
533 case "running":
534 return null;
535 }
536}
537
485538function checkpointStatusToTurnState(
486539 status: "ready" | "missing" | "error",
487540): OrchestrationLatestTurn["state"] {
pnpm-lock.yaml+2-1
......@@ -50,6 +50,7 @@ overrides:
5050 '@effect/vitest': 4.0.0-beta.78
5151 '@effect/vitest>@vitest/runner': '-'
5252 '@effect/vitest>vitest': '-'
53 '@expo/metro-config': 56.0.13
5354 '@types/node': 24.12.4
5455 effect: 4.0.0-beta.78
5556 vite: npm:@voidzero-dev/vite-plus-core@0.1.24
......@@ -12620,7 +12621,7 @@ snapshots:
1262012621 '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7)
1262112622 '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7)
1262212623 '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7)
12623 '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7)
12624 '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7)
1262412625 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
1262512626 '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7)
1262612627 '@babel/plugin-transform-regenerator': 7.29.7(@babel/core@7.29.7)
pnpm-workspace.yaml+4
......@@ -56,6 +56,10 @@ overrides:
5656 "@effect/vitest": "catalog:"
5757 "@effect/vitest>@vitest/runner": "-"
5858 "@effect/vitest>vitest": "-"
59 # Pinned to the version the patch in patchedDependencies targets — a fresh
60 # resolve (e.g. release-smoke regenerating the lockfile) must not drift to a
61 # newer release and orphan the patch (ERR_PNPM_UNUSED_PATCH).
62 "@expo/metro-config": "56.0.13"
5963 "@types/node": "catalog:"
6064 effect: "catalog:"
6165 vite: "catalog:"