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 ===...@@ -22,6 +22,7 @@ const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION ===
22const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";22const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
23const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";23const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
24const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;24const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
25const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0");
25const permissionOptionIds = {26const permissionOptionIds = {
26 allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once",27 allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once",
27 allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always",28 allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always",
...@@ -364,6 +365,10 @@ const program = Effect.gen(function* () {...@@ -364,6 +365,10 @@ const program = Effect.gen(function* () {
364 Effect.gen(function* () {365 Effect.gen(function* () {
365 const requestedSessionId = String(request.sessionId ?? sessionId);366 const requestedSessionId = String(request.sessionId ?? sessionId);
366367
368 if (Number.isFinite(promptDelayMs) && promptDelayMs > 0) {
369 yield* Effect.sleep(`${promptDelayMs} millis`);
370 }
371
367 if (emitInterleavedAssistantToolCalls) {372 if (emitInterleavedAssistantToolCalls) {
368 const toolCallId = "tool-call-1";373 const toolCallId = "tool-call-1";
369374
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+221
...@@ -1232,6 +1232,227 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -1232,6 +1232,227 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
1232 }),1232 }),
1233 );1233 );
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
1235 it.effect("keeps accumulated assistant text when completion payload text is empty", () =>1456 it.effect("keeps accumulated assistant text when completion payload text is empty", () =>
1236 Effect.gen(function* () {1457 Effect.gen(function* () {
1237 const projectionPipeline = yield* OrchestrationProjectionPipeline;1458 const projectionPipeline = yield* OrchestrationProjectionPipeline;
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+105-11
...@@ -2,6 +2,7 @@ import {...@@ -2,6 +2,7 @@ import {
2 ApprovalRequestId,2 ApprovalRequestId,
3 type ChatAttachment,3 type ChatAttachment,
4 type OrchestrationEvent,4 type OrchestrationEvent,
5 type OrchestrationSessionStatus,
5 ThreadId,6 ThreadId,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
7import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
...@@ -69,6 +70,29 @@ export const ORCHESTRATION_PROJECTOR_NAMES = {...@@ -69,6 +70,29 @@ export const ORCHESTRATION_PROJECTOR_NAMES = {
69type ProjectorName =70type ProjectorName =
70 (typeof ORCHESTRATION_PROJECTOR_NAMES)[keyof typeof ORCHESTRATION_PROJECTOR_NAMES];71 (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
72interface ProjectorDefinition {96interface ProjectorDefinition {
73 readonly name: ProjectorName;97 readonly name: ProjectorName;
74 readonly apply: (98 readonly apply: (
...@@ -999,9 +1023,57 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -999,9 +1023,57 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
999 case "thread.session-set": {1023 case "thread.session-set": {
1000 const turnId = event.payload.session.activeTurnId;1024 const turnId = event.payload.session.activeTurnId;
1001 if (turnId === null || event.payload.session.status !== "running") {1025 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 );
1002 return;1052 return;
1003 }1053 }
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
1005 const existingTurn = yield* projectionTurnRepository.getByTurnId({1077 const existingTurn = yield* projectionTurnRepository.getByTurnId({
1006 threadId: event.payload.threadId,1078 threadId: event.payload.threadId,
1007 turnId,1079 turnId,
...@@ -1080,6 +1152,19 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -1080,6 +1152,19 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
1080 if (event.payload.turnId === null || event.payload.role !== "assistant") {1152 if (event.payload.turnId === null || event.payload.role !== "assistant") {
1081 return;1153 return;
1082 }1154 }
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;
1083 const existingTurn = yield* projectionTurnRepository.getByTurnId({1168 const existingTurn = yield* projectionTurnRepository.getByTurnId({
1084 threadId: event.payload.threadId,1169 threadId: event.payload.threadId,
1085 turnId: event.payload.turnId,1170 turnId: event.payload.turnId,
...@@ -1088,16 +1173,16 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -1088,16 +1173,16 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
1088 yield* projectionTurnRepository.upsertByTurnId({1173 yield* projectionTurnRepository.upsertByTurnId({
1089 ...existingTurn.value,1174 ...existingTurn.value,
1090 assistantMessageId: event.payload.messageId,1175 assistantMessageId: event.payload.messageId,
1091 state: event.payload.streaming1176 state: settlesTurn
1092 ? existingTurn.value.state1177 ? existingTurn.value.state === "interrupted"
1093 : existingTurn.value.state === "interrupted"
1094 ? "interrupted"1178 ? "interrupted"
1095 : existingTurn.value.state === "error"1179 : existingTurn.value.state === "error"
1096 ? "error"1180 ? "error"
1097 : "completed",1181 : "completed"
1098 completedAt: event.payload.streaming1182 : existingTurn.value.state,
1099 ? existingTurn.value.completedAt1183 completedAt: settlesTurn
1100 : (existingTurn.value.completedAt ?? event.payload.updatedAt),1184 ? (existingTurn.value.completedAt ?? event.payload.updatedAt)
1185 : existingTurn.value.completedAt,
1101 startedAt: existingTurn.value.startedAt ?? event.payload.createdAt,1186 startedAt: existingTurn.value.startedAt ?? event.payload.createdAt,
1102 requestedAt: existingTurn.value.requestedAt ?? event.payload.createdAt,1187 requestedAt: existingTurn.value.requestedAt ?? event.payload.createdAt,
1103 });1188 });
...@@ -1110,10 +1195,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -1110,10 +1195,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
1110 sourceProposedPlanThreadId: null,1195 sourceProposedPlanThreadId: null,
1111 sourceProposedPlanId: null,1196 sourceProposedPlanId: null,
1112 assistantMessageId: event.payload.messageId,1197 assistantMessageId: event.payload.messageId,
1113 state: event.payload.streaming ? "running" : "completed",1198 state: settlesTurn ? "completed" : "running",
1114 requestedAt: event.payload.createdAt,1199 requestedAt: event.payload.createdAt,
1115 startedAt: event.payload.createdAt,1200 startedAt: event.payload.createdAt,
1116 completedAt: event.payload.streaming ? null : event.payload.updatedAt,1201 completedAt: settlesTurn ? event.payload.updatedAt : null,
1117 checkpointTurnCount: null,1202 checkpointTurnCount: null,
1118 checkpointRef: null,1203 checkpointRef: null,
1119 checkpointStatus: null,1204 checkpointStatus: null,
...@@ -1160,6 +1245,15 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -1160,6 +1245,15 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
1160 }1245 }
11611246
1162 case "thread.turn-diff-completed": {1247 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;
1163 const existingTurn = yield* projectionTurnRepository.getByTurnId({1257 const existingTurn = yield* projectionTurnRepository.getByTurnId({
1164 threadId: event.payload.threadId,1258 threadId: event.payload.threadId,
1165 turnId: event.payload.turnId,1259 turnId: event.payload.turnId,
...@@ -1175,7 +1269,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -1175,7 +1269,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
1175 yield* projectionTurnRepository.upsertByTurnId({1269 yield* projectionTurnRepository.upsertByTurnId({
1176 ...existingTurn.value,1270 ...existingTurn.value,
1177 assistantMessageId: event.payload.assistantMessageId,1271 assistantMessageId: event.payload.assistantMessageId,
1178 state: nextState,1272 state: turnStillRunning ? existingTurn.value.state : nextState,
1179 checkpointTurnCount: event.payload.checkpointTurnCount,1273 checkpointTurnCount: event.payload.checkpointTurnCount,
1180 checkpointRef: event.payload.checkpointRef,1274 checkpointRef: event.payload.checkpointRef,
1181 checkpointStatus: event.payload.status,1275 checkpointStatus: event.payload.status,
...@@ -1193,7 +1287,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -1193,7 +1287,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
1193 sourceProposedPlanThreadId: null,1287 sourceProposedPlanThreadId: null,
1194 sourceProposedPlanId: null,1288 sourceProposedPlanId: null,
1195 assistantMessageId: event.payload.assistantMessageId,1289 assistantMessageId: event.payload.assistantMessageId,
1196 state: nextState,1290 state: turnStillRunning ? "running" : nextState,
1197 requestedAt: event.payload.completedAt,1291 requestedAt: event.payload.completedAt,
1198 startedAt: event.payload.completedAt,1292 startedAt: event.payload.completedAt,
1199 completedAt: event.payload.completedAt,1293 completedAt: event.payload.completedAt,
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+117-31
...@@ -1117,39 +1117,39 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1117,39 +1117,39 @@ describe("ProviderRuntimeIngestion", () => {
1117 const createdAt = "2026-01-01T00:00:00.000Z";1117 const createdAt = "2026-01-01T00:00:00.000Z";
11181118
1119 await Effect.runPromise(1119 await Effect.runPromise(
1120 harness.engine.dispatch({1120 Effect.andThen(
1121 type: "thread.create",1121 harness.engine.dispatch({
1122 commandId: CommandId.make("cmd-thread-create-plan-source-guarded"),1122 type: "thread.create",
1123 threadId: sourceThreadId,1123 commandId: CommandId.make("cmd-thread-create-plan-source-guarded"),
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: {
1143 threadId: sourceThreadId,1124 threadId: sourceThreadId,
1144 status: "ready",1125 projectId: asProjectId("project-1"),
1145 providerName: "codex",1126 title: "Plan Source",
1127 modelSelection: {
1128 instanceId: ProviderInstanceId.make("codex"),
1129 model: "gpt-5-codex",
1130 },
1131 interactionMode: "plan",
1146 runtimeMode: "approval-required",1132 runtimeMode: "approval-required",
1147 activeTurnId: null,1133 branch: null,
1148 updatedAt: createdAt,1134 worktreePath: null,
1149 lastError: null,1135 createdAt,
1150 },1136 }),
1151 createdAt,1137 harness.engine.dispatch({
1152 }),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 ),
1153 );1153 );
1154 harness.setProviderSession({1154 harness.setProviderSession({
1155 provider: ProviderDriverKind.make("codex"),1155 provider: ProviderDriverKind.make("codex"),
...@@ -1260,6 +1260,92 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1260,6 +1260,92 @@ describe("ProviderRuntimeIngestion", () => {
1260 expect(targetThreadAfterRejectedStart?.session?.activeTurnId).toBe(activeTurnId);1260 expect(targetThreadAfterRejectedStart?.session?.activeTurnId).toBe(activeTurnId);
1261 });1261 });
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
1263 it("does not mark the source proposed plan implemented for an unrelated turn.started when no thread active turn is tracked", async () => {1349 it("does not mark the source proposed plan implemented for an unrelated turn.started when no thread active turn is tracked", async () => {
1264 const harness = await createHarness();1350 const harness = await createHarness();
1265 const sourceThreadId = asThreadId("thread-plan");1351 const sourceThreadId = asThreadId("thread-plan");
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+17-1
...@@ -1226,6 +1226,22 @@ const make = Effect.gen(function* () {...@@ -1226,6 +1226,22 @@ const make = Effect.gen(function* () {
1226 activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId);1226 activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId);
1227 const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined;1227 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
1229 const shouldApplyThreadLifecycle = (() => {1245 const shouldApplyThreadLifecycle = (() => {
1230 if (!STRICT_PROVIDER_LIFECYCLE_GUARD) {1246 if (!STRICT_PROVIDER_LIFECYCLE_GUARD) {
1231 return true;1247 return true;
...@@ -1237,7 +1253,7 @@ const make = Effect.gen(function* () {...@@ -1237,7 +1253,7 @@ const make = Effect.gen(function* () {
1237 case "thread.started":1253 case "thread.started":
1238 return true;1254 return true;
1239 case "turn.started":1255 case "turn.started":
1240 return !conflictsWithActiveTurn;1256 return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart;
1241 case "turn.completed":1257 case "turn.completed":
1242 if (conflictsWithActiveTurn || missingTurnForActiveTurn) {1258 if (conflictsWithActiveTurn || missingTurnForActiveTurn) {
1243 return false;1259 return false;
apps/server/src/orchestration/projector.test.ts+62-23
...@@ -267,37 +267,76 @@ describe("orchestration projector", () => {...@@ -267,37 +267,76 @@ describe("orchestration projector", () => {
267 ),267 ),
268 );268 );
269269
270 const afterRunning = await Effect.runPromise(270 const settledAt = "2026-02-23T08:01:00.000Z";
271 projectEvent(271 const [afterRunning, afterReady] = await Effect.runPromise(
272 afterCreate,272 Effect.flatMap(
273 makeEvent({273 projectEvent(
274 sequence: 2,274 afterCreate,
275 type: "thread.session-set",275 makeEvent({
276 aggregateKind: "thread",276 sequence: 2,
277 aggregateId: "thread-1",277 type: "thread.session-set",
278 occurredAt: startedAt,278 aggregateKind: "thread",
279 commandId: "cmd-running",279 aggregateId: "thread-1",
280 payload: {280 occurredAt: startedAt,
281 threadId: "thread-1",281 commandId: "cmd-running",
282 session: {282 payload: {
283 threadId: "thread-1",283 threadId: "thread-1",
284 status: "running",284 session: {
285 providerName: "codex",285 threadId: "thread-1",
286 providerSessionId: "session-1",286 status: "running",
287 providerThreadId: "provider-thread-1",287 providerName: "codex",
288 runtimeMode: "approval-required",288 providerSessionId: "session-1",
289 activeTurnId: "turn-1",289 providerThreadId: "provider-thread-1",
290 lastError: null,290 runtimeMode: "approval-required",
291 updatedAt: startedAt,291 activeTurnId: "turn-1",
292 lastError: null,
293 updatedAt: startedAt,
294 },
292 },295 },
293 },296 }),
294 }),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 ),
295 ),327 ),
296 );328 );
297329
298 const thread = afterRunning.threads[0];330 const thread = afterRunning.threads[0];
299 expect(thread?.latestTurn?.turnId).toBe("turn-1");331 expect(thread?.latestTurn?.turnId).toBe("turn-1");
300 expect(thread?.session?.status).toBe("running");332 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);
301 });340 });
302341
303 it("updates canonical thread runtime mode from thread.runtime-mode-set", async () => {342 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"...@@ -38,6 +38,29 @@ function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error"
38 return "completed" as const;38 return "completed" as const;
39}39}
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
41function updateThread(64function updateThread(
42 threads: ReadonlyArray<OrchestrationThread>,65 threads: ReadonlyArray<OrchestrationThread>,
43 threadId: ThreadId,66 threadId: ThreadId,
...@@ -438,6 +461,9 @@ export function projectEvent(...@@ -438,6 +461,9 @@ export function projectEvent(
438 "session",461 "session",
439 );462 );
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);
441 return {467 return {
442 ...nextBase,468 ...nextBase,
443 threads: updateThread(nextBase.threads, payload.threadId, {469 threads: updateThread(nextBase.threads, payload.threadId, {
...@@ -461,7 +487,18 @@ export function projectEvent(...@@ -461,7 +487,18 @@ export function projectEvent(
461 ? thread.latestTurn.assistantMessageId487 ? thread.latestTurn.assistantMessageId
462 : null,488 : null,
463 }489 }
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,
465 updatedAt: event.occurredAt,502 updatedAt: event.occurredAt,
466 }),503 }),
467 };504 };
...@@ -544,24 +581,31 @@ export function projectEvent(...@@ -544,24 +581,31 @@ export function projectEvent(
544 .toSorted((left, right) => left.checkpointTurnCount - right.checkpointTurnCount)581 .toSorted((left, right) => left.checkpointTurnCount - right.checkpointTurnCount)
545 .slice(-MAX_THREAD_CHECKPOINTS);582 .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
547 return {589 return {
548 ...nextBase,590 ...nextBase,
549 threads: updateThread(nextBase.threads, payload.threadId, {591 threads: updateThread(nextBase.threads, payload.threadId, {
550 checkpoints,592 checkpoints,
551 latestTurn: {593 latestTurn: turnStillRunning
552 turnId: payload.turnId,594 ? thread.latestTurn
553 state: checkpointStatusToLatestTurnState(payload.status),595 : {
554 requestedAt:596 turnId: payload.turnId,
555 thread.latestTurn?.turnId === payload.turnId597 state: checkpointStatusToLatestTurnState(payload.status),
556 ? thread.latestTurn.requestedAt598 requestedAt:
557 : payload.completedAt,599 thread.latestTurn?.turnId === payload.turnId
558 startedAt:600 ? thread.latestTurn.requestedAt
559 thread.latestTurn?.turnId === payload.turnId601 : payload.completedAt,
560 ? (thread.latestTurn.startedAt ?? payload.completedAt)602 startedAt:
561 : payload.completedAt,603 thread.latestTurn?.turnId === payload.turnId
562 completedAt: payload.completedAt,604 ? (thread.latestTurn.startedAt ?? payload.completedAt)
563 assistantMessageId: payload.assistantMessageId,605 : payload.completedAt,
564 },606 completedAt: payload.completedAt,
607 assistantMessageId: payload.assistantMessageId,
608 },
565 updatedAt: event.occurredAt,609 updatedAt: event.occurredAt,
566 }),610 }),
567 };611 };
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+67
...@@ -890,6 +890,73 @@ describe("ClaudeAdapterLive", () => {...@@ -890,6 +890,73 @@ describe("ClaudeAdapterLive", () => {
890 );890 );
891 });891 });
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
893 it.effect("maps Claude reasoning deltas, streamed tool inputs, and tool results", () => {960 it.effect("maps Claude reasoning deltas, streamed tool inputs, and tool results", () => {
894 const harness = makeHarness();961 const harness = makeHarness();
895 return Effect.gen(function* () {962 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+47-32
...@@ -122,6 +122,13 @@ interface ClaudeResumeState {...@@ -122,6 +122,13 @@ interface ClaudeResumeState {
122interface ClaudeTurnState {122interface ClaudeTurnState {
123 readonly turnId: TurnId;123 readonly turnId: TurnId;
124 readonly startedAt: string;124 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;
125 readonly items: Array<unknown>;132 readonly items: Array<unknown>;
126 readonly assistantTextBlocks: Map<number, AssistantTextBlockState>;133 readonly assistantTextBlocks: Map<number, AssistantTextBlockState>;
127 readonly assistantTextBlockOrder: Array<AssistantTextBlockState>;134 readonly assistantTextBlockOrder: Array<AssistantTextBlockState>;
...@@ -2486,6 +2493,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2486,6 +2493,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2486 context.turnState = {2493 context.turnState = {
2487 turnId,2494 turnId,
2488 startedAt,2495 startedAt,
2496 synthetic: true,
2489 items: [],2497 items: [],
2490 assistantTextBlocks: new Map(),2498 assistantTextBlocks: new Map(),
2491 assistantTextBlockOrder: [],2499 assistantTextBlockOrder: [],
...@@ -3629,9 +3637,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3629,9 +3637,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3629 ? input.modelSelection3637 ? input.modelSelection
3630 : undefined;3638 : undefined;
36313639
3632 if (context.turnState) {3640 // A sendTurn while a real turn is running is a steer: the message is
3633 // Auto-close a stale synthetic turn (from background agent responses3641 // queued into the live SDK agent loop and the work continues as the same
3634 // between user prompts) to prevent blocking the user's next turn.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) {
3635 yield* completeTurn(context, "completed");3648 yield* completeTurn(context, "completed");
3636 }3649 }
36373650
...@@ -3666,37 +3679,39 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3666,37 +3679,39 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3666 });3679 });
3667 }3680 }
36683681
3669 const turnId = TurnId.make(yield* randomUUIDv4);3682 const turnId = steeringTurnState?.turnId ?? TurnId.make(yield* randomUUIDv4);
3670 const turnState: ClaudeTurnState = {3683 if (steeringTurnState === null) {
3671 turnId,3684 const turnState: ClaudeTurnState = {
3672 startedAt: yield* nowIso,3685 turnId,
3673 items: [],3686 startedAt: yield* nowIso,
3674 assistantTextBlocks: new Map(),3687 items: [],
3675 assistantTextBlockOrder: [],3688 assistantTextBlocks: new Map(),
3676 capturedProposedPlanKeys: new Set(),3689 assistantTextBlockOrder: [],
3677 nextSyntheticAssistantBlockIndex: -1,3690 capturedProposedPlanKeys: new Set(),
3678 };3691 nextSyntheticAssistantBlockIndex: -1,
3692 };
36793693
3680 const updatedAt = yield* nowIso;3694 const updatedAt = yield* nowIso;
3681 context.turnState = turnState;3695 context.turnState = turnState;
3682 context.session = {3696 context.session = {
3683 ...context.session,3697 ...context.session,
3684 status: "running",3698 status: "running",
3685 activeTurnId: turnId,3699 activeTurnId: turnId,
3686 updatedAt,3700 updatedAt,
3687 };3701 };
36883702
3689 const turnStartedStamp = yield* makeEventStamp();3703 const turnStartedStamp = yield* makeEventStamp();
3690 yield* offerRuntimeEvent({3704 yield* offerRuntimeEvent({
3691 type: "turn.started",3705 type: "turn.started",
3692 eventId: turnStartedStamp.eventId,3706 eventId: turnStartedStamp.eventId,
3693 provider: PROVIDER,3707 provider: PROVIDER,
3694 createdAt: turnStartedStamp.createdAt,3708 createdAt: turnStartedStamp.createdAt,
3695 threadId: context.session.threadId,3709 threadId: context.session.threadId,
3696 turnId,3710 turnId,
3697 payload: modelSelection?.model ? { model: modelSelection.model } : {},3711 payload: modelSelection?.model ? { model: modelSelection.model } : {},
3698 providerRefs: {},3712 providerRefs: {},
3699 });3713 });
3714 }
37003715
3701 const message = yield* buildUserMessageEffect(input, {3716 const message = yield* buildUserMessageEffect(input, {
3702 fileSystem,3717 fileSystem,
apps/server/src/provider/Layers/CursorAdapter.test.ts+77
...@@ -13,6 +13,7 @@ import * as Fiber from "effect/Fiber";...@@ -13,6 +13,7 @@ import * as Fiber from "effect/Fiber";
13import * as Layer from "effect/Layer";13import * as Layer from "effect/Layer";
14import * as Schema from "effect/Schema";14import * as Schema from "effect/Schema";
15import * as Stream from "effect/Stream";15import * as Stream from "effect/Stream";
16import * as TestClock from "effect/testing/TestClock";
16import { createModelSelection } from "@t3tools/shared/model";17import { createModelSelection } from "@t3tools/shared/model";
1718
18import {19import {
...@@ -232,6 +233,82 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {...@@ -232,6 +233,82 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
232 }),233 }),
233 );234 );
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
235 it.effect("closes the ACP child process when a session stops", () =>312 it.effect("closes the ACP child process when a session stops", () =>
236 Effect.gen(function* () {313 Effect.gen(function* () {
237 const adapter = yield* CursorAdapter;314 const adapter = yield* CursorAdapter;
apps/server/src/provider/Layers/CursorAdapter.ts+141-105
...@@ -132,6 +132,10 @@ interface CursorSessionContext {...@@ -132,6 +132,10 @@ interface CursorSessionContext {
132 readonly turns: Array<{ id: TurnId; items: Array<unknown> }>;132 readonly turns: Array<{ id: TurnId; items: Array<unknown> }>;
133 lastPlanFingerprint: string | undefined;133 lastPlanFingerprint: string | undefined;
134 activeTurnId: TurnId | undefined;134 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;
135 stopped: boolean;139 stopped: boolean;
136}140}
137141
...@@ -754,6 +758,7 @@ export function makeCursorAdapter(...@@ -754,6 +758,7 @@ export function makeCursorAdapter(
754 turns: [],758 turns: [],
755 lastPlanFingerprint: undefined,759 lastPlanFingerprint: undefined,
756 activeTurnId: undefined,760 activeTurnId: undefined,
761 promptsInFlight: 0,
757 stopped: false,762 stopped: false,
758 };763 };
759764
...@@ -882,121 +887,152 @@ export function makeCursorAdapter(...@@ -882,121 +887,152 @@ export function makeCursorAdapter(
882 const sendTurn: CursorAdapterShape["sendTurn"] = (input) =>887 const sendTurn: CursorAdapterShape["sendTurn"] = (input) =>
883 Effect.gen(function* () {888 Effect.gen(function* () {
884 const ctx = yield* requireSession(input.threadId);889 const ctx = yield* requireSession(input.threadId);
885 const turnId = TurnId.make(yield* randomUUIDv4);890 // A sendTurn while a prompt is in flight is a steer: the agent folds
886 const turnModelSelection =891 // the new prompt into the ongoing work, so the active turn id is
887 input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;892 // reused instead of opening a new turn.
888 const model = turnModelSelection?.model ?? ctx.session.model;893 const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined;
889 const resolvedModel = resolveCursorAcpBaseModelId(model);894 const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4);
890 yield* applyRequestedSessionConfiguration({895 // Count this prompt immediately so a superseded in-flight prompt
891 runtime: ctx.acp,896 // resolving from here on does not settle the turn; the matching
892 runtimeMode: ctx.session.runtimeMode,897 // decrement is the `ensuring` below.
893 interactionMode: input.interactionMode,898 ctx.promptsInFlight += 1;
894 modelSelection:899
895 model === undefined900 return yield* Effect.gen(function* () {
896 ? undefined901 const turnModelSelection =
897 : {902 input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;
898 model,903 const model = turnModelSelection?.model ?? ctx.session.model;
899 options: turnModelSelection?.options,904 const resolvedModel = resolveCursorAcpBaseModelId(model);
900 },905 yield* applyRequestedSessionConfiguration({
901 mapError: ({ cause, method }) =>906 runtime: ctx.acp,
902 mapAcpToAdapterError(PROVIDER, input.threadId, method, cause),907 runtimeMode: ctx.session.runtimeMode,
903 });908 interactionMode: input.interactionMode,
904 ctx.activeTurnId = turnId;909 modelSelection:
905 ctx.lastPlanFingerprint = undefined;910 model === undefined
906 ctx.session = {911 ? undefined
907 ...ctx.session,912 : {
908 activeTurnId: turnId,913 model,
909 updatedAt: yield* nowIso,914 options: turnModelSelection?.options,
910 };915 },
911916 mapError: ({ cause, method }) =>
912 yield* offerRuntimeEvent({917 mapAcpToAdapterError(PROVIDER, input.threadId, method, cause),
913 type: "turn.started",918 });
914 ...(yield* makeEventStamp()),919 ctx.activeTurnId = turnId;
915 provider: PROVIDER,920 if (steeringTurnId === undefined) {
916 threadId: input.threadId,921 ctx.lastPlanFingerprint = undefined;
917 turnId,922 }
918 payload: { model: resolvedModel },923 ctx.session = {
919 });924 ...ctx.session,
925 activeTurnId: turnId,
926 updatedAt: yield* nowIso,
927 };
920928
921 const promptParts: Array<EffectAcpSchema.ContentBlock> = [];929 if (steeringTurnId === undefined) {
922 if (input.input?.trim()) {930 yield* offerRuntimeEvent({
923 promptParts.push({ type: "text", text: input.input.trim() });931 type: "turn.started",
924 }932 ...(yield* makeEventStamp()),
925 if (input.attachments && input.attachments.length > 0) {933 provider: PROVIDER,
926 for (const attachment of input.attachments) {934 threadId: input.threadId,
927 const attachmentPath = resolveAttachmentPath({935 turnId,
928 attachmentsDir: serverConfig.attachmentsDir,936 payload: { model: resolvedModel },
929 attachment,
930 });937 });
931 if (!attachmentPath) {938 }
932 return yield* new ProviderAdapterRequestError({939
933 provider: PROVIDER,940 const promptParts: Array<EffectAcpSchema.ContentBlock> = [];
934 method: "session/prompt",941 if (input.input?.trim()) {
935 detail: `Invalid attachment id '${attachment.id}'.`,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,
936 });972 });
937 }973 }
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 });
954 }974 }
955 }
956975
957 if (promptParts.length === 0) {976 if (promptParts.length === 0) {
958 return yield* new ProviderAdapterValidationError({977 return yield* new ProviderAdapterValidationError({
959 provider: PROVIDER,978 provider: PROVIDER,
960 operation: "sendTurn",979 operation: "sendTurn",
961 issue: "Turn requires non-empty text or attachments.",980 issue: "Turn requires non-empty text or attachments.",
962 });981 });
963 }982 }
964983
965 const result = yield* ctx.acp984 const result = yield* ctx.acp
966 .prompt({985 .prompt({
967 prompt: promptParts,986 prompt: promptParts,
968 })987 })
969 .pipe(988 .pipe(
970 Effect.mapError((error) =>989 Effect.mapError((error) =>
971 mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error),990 mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error),
972 ),991 ),
973 );992 );
974993
975 ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] });994 const turnRecord = ctx.turns.find((turn) => turn.id === turnId);
976 ctx.session = {995 if (turnRecord) {
977 ...ctx.session,996 turnRecord.items.push({ prompt: promptParts, result });
978 activeTurnId: turnId,997 } else {
979 updatedAt: yield* nowIso,998 ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] });
980 model: resolvedModel,999 }
981 };1000 ctx.session = {
1001 ...ctx.session,
1002 activeTurnId: turnId,
1003 updatedAt: yield* nowIso,
1004 model: resolvedModel,
1005 };
9821006
983 yield* offerRuntimeEvent({1007 // Only the last remaining prompt settles the turn — a steer-
984 type: "turn.completed",1008 // superseded prompt resolving (usually cancelled) while another is
985 ...(yield* makeEventStamp()),1009 // in flight or pending must leave the merged turn running.
986 provider: PROVIDER,1010 if (ctx.promptsInFlight === 1) {
987 threadId: input.threadId,1011 yield* offerRuntimeEvent({
988 turnId,1012 type: "turn.completed",
989 payload: {1013 ...(yield* makeEventStamp()),
990 state: result.stopReason === "cancelled" ? "cancelled" : "completed",1014 provider: PROVIDER,
991 stopReason: result.stopReason ?? null,1015 threadId: input.threadId,
992 },1016 turnId,
993 });1017 payload: {
1018 state: result.stopReason === "cancelled" ? "cancelled" : "completed",
1019 stopReason: result.stopReason ?? null,
1020 },
1021 });
1022 }
9941023
995 return {1024 return {
996 threadId: input.threadId,1025 threadId: input.threadId,
997 turnId,1026 turnId,
998 resumeCursor: ctx.session.resumeCursor,1027 resumeCursor: ctx.session.resumeCursor,
999 };1028 };
1029 }).pipe(
1030 Effect.ensuring(
1031 Effect.sync(() => {
1032 ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1);
1033 }),
1034 ),
1035 );
1000 });1036 });
10011037
1002 const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId) =>1038 const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId) =>
apps/server/src/provider/Layers/GrokAdapter.ts+188-134
...@@ -107,6 +107,10 @@ interface GrokSessionContext {...@@ -107,6 +107,10 @@ interface GrokSessionContext {
107 turns: Array<{ id: TurnId; items: Array<unknown> }>;107 turns: Array<{ id: TurnId; items: Array<unknown> }>;
108 lastPlanFingerprint: string | undefined;108 lastPlanFingerprint: string | undefined;
109 activeTurnId: TurnId | undefined;109 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;
110 currentModelId: string | undefined;114 currentModelId: string | undefined;
111 stopped: boolean;115 stopped: boolean;
112}116}
...@@ -555,6 +559,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -555,6 +559,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
555 turns: [],559 turns: [],
556 lastPlanFingerprint: undefined,560 lastPlanFingerprint: undefined,
557 activeTurnId: undefined,561 activeTurnId: undefined,
562 promptsInFlight: 0,
558 currentModelId: boundModelId,563 currentModelId: boundModelId,
559 stopped: false,564 stopped: false,
560 };565 };
...@@ -664,150 +669,199 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -664,150 +669,199 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
664 input.threadId,669 input.threadId,
665 Effect.gen(function* () {670 Effect.gen(function* () {
666 const ctx = yield* requireSession(input.threadId);671 const ctx = yield* requireSession(input.threadId);
667 const turnId = TurnId.make(yield* randomUUIDv4);672 // A sendTurn while a prompt is in flight is a steer: the agent
668 const turnModelSelection =673 // folds the new prompt into the ongoing work, so the active turn
669 input.modelSelection?.instanceId === boundInstanceId674 // id is reused instead of opening a new turn.
670 ? input.modelSelection675 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)
671 : undefined;689 : undefined;
672 const requestedTurnModelId = turnModelSelection?.model690 const currentModelId = yield* applyGrokAcpModelSelection({
673 ? resolveGrokAcpBaseModelId(turnModelSelection.model)691 runtime: ctx.acp,
674 : undefined;692 currentModelId: ctx.currentModelId,
675 const currentModelId = yield* applyGrokAcpModelSelection({693 requestedModelId: requestedTurnModelId,
676 runtime: ctx.acp,694 mapError: (cause) =>
677 currentModelId: ctx.currentModelId,695 mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause),
678 requestedModelId: requestedTurnModelId,696 });
679 mapError: (cause) =>
680 mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause),
681 });
682697
683 const text = input.input?.trim();698 const text = input.input?.trim();
684 const imagePromptParts = yield* Effect.forEach(input.attachments ?? [], (attachment) =>699 const imagePromptParts = yield* Effect.forEach(
685 Effect.gen(function* () {700 input.attachments ?? [],
686 const attachmentPath = resolveAttachmentPath({701 (attachment) =>
687 attachmentsDir: serverConfig.attachmentsDir,702 Effect.gen(function* () {
688 attachment,703 const attachmentPath = resolveAttachmentPath({
689 });704 attachmentsDir: serverConfig.attachmentsDir,
690 if (!attachmentPath) {705 attachment,
691 return yield* new ProviderAdapterRequestError({706 });
692 provider: PROVIDER,707 if (!attachmentPath) {
693 method: "session/prompt",708 return yield* new ProviderAdapterRequestError({
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({
701 provider: PROVIDER,709 provider: PROVIDER,
702 method: "session/prompt",710 method: "session/prompt",
703 detail: cause.message,711 detail: `Invalid attachment id '${attachment.id}'.`,
704 cause,712 });
705 }),713 }
706 ),714 const bytes = yield* fileSystem.readFile(attachmentPath).pipe(
707 );715 Effect.mapError(
708 return {716 (cause) =>
709 type: "image",717 new ProviderAdapterRequestError({
710 data: Buffer.from(bytes).toString("base64"),718 provider: PROVIDER,
711 mimeType: attachment.mimeType,719 method: "session/prompt",
712 } satisfies EffectAcpSchema.ContentBlock;720 detail: cause.message,
713 }),721 cause,
714 );722 }),
715 const promptParts: Array<EffectAcpSchema.ContentBlock> = [723 ),
716 ...(text ? [{ type: "text" as const, text }] : []),724 );
717 ...imagePromptParts,725 return {
718 ];726 type: "image",
719727 data: Buffer.from(bytes).toString("base64"),
720 if (promptParts.length === 0) {728 mimeType: attachment.mimeType,
721 return yield* new ProviderAdapterValidationError({729 } satisfies EffectAcpSchema.ContentBlock;
722 provider: PROVIDER,730 }),
723 operation: "sendTurn",731 );
724 issue: "Turn requires non-empty text or attachments.",732 const promptParts: Array<EffectAcpSchema.ContentBlock> = [
725 });733 ...(text ? [{ type: "text" as const, text }] : []),
726 }734 ...imagePromptParts,
727735 ];
728 ctx.currentModelId = currentModelId;736
729 const displayModel = currentModelId737 if (promptParts.length === 0) {
730 ? resolveGrokAcpBaseModelId(currentModelId)738 return yield* new ProviderAdapterValidationError({
731 : undefined;739 provider: PROVIDER,
732 ctx.activeTurnId = turnId;740 operation: "sendTurn",
733 ctx.lastPlanFingerprint = undefined;741 issue: "Turn requires non-empty text or attachments.",
734 ctx.session = {742 });
735 ...ctx.session,743 }
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 });
749744
750 return {745 ctx.currentModelId = currentModelId;
751 acp: ctx.acp,746 const displayModel = currentModelId
752 acpSessionId: ctx.acpSessionId,747 ? resolveGrokAcpBaseModelId(currentModelId)
753 displayModel,748 : undefined;
754 promptParts,749 ctx.activeTurnId = turnId;
755 turnId,750 if (steeringTurnId === undefined) {
756 };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 );
757 }),785 }),
758 );786 );
759787
760 const result = yield* prepared.acp788 return yield* Effect.gen(function* () {
761 .prompt({789 const result = yield* prepared.acp
762 prompt: prepared.promptParts,790 .prompt({
763 })791 prompt: prepared.promptParts,
764 .pipe(792 })
765 Effect.mapError((error) =>793 .pipe(
766 mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error),794 Effect.mapError((error) =>
767 ),795 mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error),
768 );796 ),
797 );
769798
770 return yield* withThreadLock(799 return yield* withThreadLock(
771 input.threadId,800 input.threadId,
772 Effect.gen(function* () {801 Effect.gen(function* () {
773 const ctx = yield* requireSession(input.threadId);802 const ctx = yield* requireSession(input.threadId);
774 if (ctx.acpSessionId !== prepared.acpSessionId) {803 if (ctx.acpSessionId !== prepared.acpSessionId) {
775 return yield* new ProviderAdapterRequestError({804 return yield* new ProviderAdapterRequestError({
776 provider: PROVIDER,805 provider: PROVIDER,
777 method: "session/prompt",806 method: "session/prompt",
778 detail: "Grok session changed before the turn completed.",807 detail: "Grok session changed before the turn completed.",
779 });808 });
780 }809 }
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 });
804810
805 return {811 const existingTurnRecord = ctx.turns.find((turn) => turn.id === prepared.turnId);
806 threadId: input.threadId,812 ctx.turns = existingTurnRecord
807 turnId: prepared.turnId,813 ? ctx.turns.map((turn) =>
808 resumeCursor: ctx.session.resumeCursor,814 turn.id === prepared.turnId
809 };815 ? {
810 }),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 ),
811 );865 );
812 });866 });
813867
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+79
...@@ -395,6 +395,85 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -395,6 +395,85 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
395 }),395 }),
396 );396 );
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
398 it.effect("passes agent and variant options for the adapter's bound custom instance id", () => {477 it.effect("passes agent and variant options for the adapter's bound custom instance id", () => {
399 const instanceId = ProviderInstanceId.make("opencode_zen");478 const instanceId = ProviderInstanceId.make("opencode_zen");
400 const adapterLayer = Layer.effect(479 const adapterLayer = Layer.effect(
apps/server/src/provider/Layers/OpenCodeAdapter.ts+46-37
...@@ -1151,7 +1151,11 @@ export function makeOpenCodeAdapter(...@@ -1151,7 +1151,11 @@ export function makeOpenCodeAdapter(
11511151
1152 const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) {1152 const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) {
1153 const context = ensureSessionContext(sessions, input.threadId);1153 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}`);
1155 const modelSelection =1159 const modelSelection =
1156 input.modelSelection ??1160 input.modelSelection ??
1157 (context.session.model1161 (context.session.model
...@@ -1206,14 +1210,16 @@ export function makeOpenCodeAdapter(...@@ -1206,14 +1210,16 @@ export function makeOpenCodeAdapter(
1206 { clearLastError: true },1210 { clearLastError: true },
1207 );1211 );
12081212
1209 yield* emit({1213 if (steeringTurnId === undefined) {
1210 ...(yield* buildEventBase({ threadId: input.threadId, turnId })),1214 yield* emit({
1211 type: "turn.started",1215 ...(yield* buildEventBase({ threadId: input.threadId, turnId })),
1212 payload: {1216 type: "turn.started",
1213 model: modelSelection?.model ?? context.session.model,1217 payload: {
1214 ...(variant ? { effort: variant } : {}),1218 model: modelSelection?.model ?? context.session.model,
1215 },1219 ...(variant ? { effort: variant } : {}),
1216 });1220 },
1221 });
1222 }
12171223
1218 yield* runOpenCodeSdk("session.promptAsync", () =>1224 yield* runOpenCodeSdk("session.promptAsync", () =>
1219 context.client.session.promptAsync({1225 context.client.session.promptAsync({
...@@ -1225,35 +1231,38 @@ export function makeOpenCodeAdapter(...@@ -1225,35 +1231,38 @@ export function makeOpenCodeAdapter(
1225 }),1231 }),
1226 ).pipe(1232 ).pipe(
1227 Effect.mapError(toRequestError),1233 Effect.mapError(toRequestError),
1228 // On failure: clear active-turn state, flip the session back to ready1234 // On failure of a fresh turn: clear active-turn state, flip the
1229 // with lastError set, emit turn.aborted, then let the typed error1235 // session back to ready with lastError set, emit turn.aborted, then
1230 // propagate. We don't need to rebuild the error here — `toRequestError`1236 // let the typed error propagate. We don't need to rebuild the error
1231 // already produced the right shape.1237 // here — `toRequestError` already produced the right shape. A failed
1238 // steer leaves the still-running original turn untouched.
1232 Effect.tapError((requestError) =>1239 Effect.tapError((requestError) =>
1233 Effect.gen(function* () {1240 steeringTurnId !== undefined
1234 context.activeTurnId = undefined;1241 ? Effect.void
1235 context.activeAgent = undefined;1242 : Effect.gen(function* () {
1236 context.activeVariant = undefined;1243 context.activeTurnId = undefined;
1237 yield* updateProviderSession(1244 context.activeAgent = undefined;
1238 context,1245 context.activeVariant = undefined;
1239 {1246 yield* updateProviderSession(
1240 status: "ready",1247 context,
1241 model: modelSelection?.model ?? context.session.model,1248 {
1242 lastError: requestError.detail,1249 status: "ready",
1243 },1250 model: modelSelection?.model ?? context.session.model,
1244 { clearActiveTurnId: true },1251 lastError: requestError.detail,
1245 );1252 },
1246 yield* emit({1253 { clearActiveTurnId: true },
1247 ...(yield* buildEventBase({1254 );
1248 threadId: input.threadId,1255 yield* emit({
1249 turnId,1256 ...(yield* buildEventBase({
1250 })),1257 threadId: input.threadId,
1251 type: "turn.aborted",1258 turnId,
1252 payload: {1259 })),
1253 reason: requestError.detail,1260 type: "turn.aborted",
1254 },1261 payload: {
1255 });1262 reason: requestError.detail,
1256 }),1263 },
1264 });
1265 }),
1257 ),1266 ),
1258 );1267 );
12591268
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+96-2
...@@ -451,8 +451,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -451,8 +451,8 @@ describe("deriveMessagesTimelineRows", () => {
451 );451 );
452 expect(foldRow?.turnId).toBe("turn-1");452 expect(foldRow?.turnId).toBe("turn-1");
453 expect(foldRow?.expanded).toBe(false);453 expect(foldRow?.expanded).toBe(false);
454 // First fold entry (00:00:05) → terminal message completedAt (00:00:22).454 // User message boundary (00:00:00) → terminal message completedAt (00:00:22).
455 expect(foldRow?.label).toBe("Worked for 17s");455 expect(foldRow?.label).toBe("Worked for 22s");
456 expect(collapsedRows.map((row) => row.id)).toEqual([456 expect(collapsedRows.map((row) => row.id)).toEqual([
457 "user-entry",457 "user-entry",
458 "turn-fold:turn-1",458 "turn-fold:turn-1",
...@@ -480,6 +480,100 @@ describe("deriveMessagesTimelineRows", () => {...@@ -480,6 +480,100 @@ describe("deriveMessagesTimelineRows", () => {
480 ).toBeDefined();480 ).toBeDefined();
481 });481 });
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
483 it("uses latest-turn timings and the stopped label for an interrupted latest turn", () => {577 it("uses latest-turn timings and the stopped label for an interrupted latest turn", () => {
484 const rows = deriveMessagesTimelineRows({578 const rows = deriveMessagesTimelineRows({
485 timelineEntries: [579 timelineEntries: [
apps/web/src/components/chat/MessagesTimeline.logic.ts+45-11
...@@ -12,6 +12,16 @@ function computeElapsedMs(startIso: string, endIso: string): number | null {...@@ -12,6 +12,16 @@ function computeElapsedMs(startIso: string, endIso: string): number | null {
12 return Math.max(0, end - start);12 return Math.max(0, end - start);
13}13}
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
15export interface TimelineDurationMessage {25export interface TimelineDurationMessage {
16 id: string;26 id: string;
17 role: "user" | "assistant" | "system";27 role: "user" | "assistant" | "system";
...@@ -167,10 +177,22 @@ function deriveTurnFolds(input: {...@@ -167,10 +177,22 @@ function deriveTurnFolds(input: {
167 entries: Array<TimelineEntry>;177 entries: Array<TimelineEntry>;
168 terminalEntry: Extract<TimelineEntry, { kind: "message" }> | null;178 terminalEntry: Extract<TimelineEntry, { kind: "message" }> | null;
169 hasStreamingMessage: boolean;179 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;
170 }187 }
171 const groupsByTurnId = new Map<TurnId, TurnGroup>();188 const groupsByTurnId = new Map<TurnId, TurnGroup>();
172189
190 let pendingUserBoundary: string | null = null;
173 for (const entry of input.timelineEntries) {191 for (const entry of input.timelineEntries) {
192 if (entry.kind === "message" && entry.message.role === "user") {
193 pendingUserBoundary = entry.message.createdAt;
194 continue;
195 }
174 const turnId =196 const turnId =
175 entry.kind === "message" && entry.message.role === "assistant"197 entry.kind === "message" && entry.message.role === "assistant"
176 ? (entry.message.turnId ?? null)198 ? (entry.message.turnId ?? null)
...@@ -180,11 +202,20 @@ function deriveTurnFolds(input: {...@@ -180,11 +202,20 @@ function deriveTurnFolds(input: {
180 if (!turnId) {202 if (!turnId) {
181 continue;203 continue;
182 }204 }
183 const group = groupsByTurnId.get(turnId) ?? {205 let group = groupsByTurnId.get(turnId);
184 entries: [],206 if (!group) {
185 terminalEntry: null,207 group = {
186 hasStreamingMessage: false,208 entries: [],
187 };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 }
188 group.entries.push(entry);219 group.entries.push(entry);
189 if (entry.kind === "message") {220 if (entry.kind === "message") {
190 if (input.terminalAssistantMessageIds.has(entry.message.id)) {221 if (input.terminalAssistantMessageIds.has(entry.message.id)) {
...@@ -194,7 +225,6 @@ function deriveTurnFolds(input: {...@@ -194,7 +225,6 @@ function deriveTurnFolds(input: {
194 group.hasStreamingMessage = true;225 group.hasStreamingMessage = true;
195 }226 }
196 }227 }
197 groupsByTurnId.set(turnId, group);
198 }228 }
199229
200 const foldsByAnchorEntryId = new Map<string, TurnFold>();230 const foldsByAnchorEntryId = new Map<string, TurnFold>();
...@@ -223,17 +253,21 @@ function deriveTurnFolds(input: {...@@ -223,17 +253,21 @@ function deriveTurnFolds(input: {
223253
224 const isLatestInterruptedTurn =254 const isLatestInterruptedTurn =
225 input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted";255 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;
226 const elapsedMs =262 const elapsedMs =
227 input.latestTurn?.turnId === turnId &&263 input.latestTurn?.turnId === turnId &&
228 input.latestTurn.startedAt &&264 input.latestTurn.startedAt &&
229 input.latestTurn.completedAt265 input.latestTurn.completedAt
230 ? computeElapsedMs(input.latestTurn.startedAt, input.latestTurn.completedAt)266 ? computeElapsedMs(input.latestTurn.startedAt, input.latestTurn.completedAt)
231 : computeElapsedMs(267 : computeElapsedMs(
232 firstEntry.createdAt,268 group.startBoundary ?? firstEntry.createdAt,
233 group.terminalEntry?.message.completedAt ??269 maxIsoTimestamp(group.terminalEntry?.message.completedAt ?? null, lastEntryEnd) ??
234 (lastEntry.kind === "message"270 lastEntryEnd,
235 ? (lastEntry.message.completedAt ?? lastEntry.createdAt)
236 : lastEntry.createdAt),
237 );271 );
238 const duration = elapsedMs !== null ? formatDuration(elapsedMs) : null;272 const duration = elapsedMs !== null ? formatDuration(elapsedMs) : null;
239 const label = isLatestInterruptedTurn273 const label = isLatestInterruptedTurn
apps/web/src/components/chat/MessagesTimeline.tsx+1-1
...@@ -368,7 +368,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -368,7 +368,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
368 maintainScrollAtEndThreshold={0.1}368 maintainScrollAtEndThreshold={0.1}
369 maintainVisibleContentPosition369 maintainVisibleContentPosition
370 onScroll={handleScroll}370 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"
372 ListHeaderComponent={TIMELINE_LIST_HEADER}372 ListHeaderComponent={TIMELINE_LIST_HEADER}
373 ListFooterComponent={TIMELINE_LIST_FOOTER}373 ListFooterComponent={TIMELINE_LIST_FOOTER}
374 />374 />
apps/web/src/session-logic.ts+5-1
...@@ -264,7 +264,11 @@ export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolea...@@ -264,7 +264,11 @@ export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolea
264export function formatDuration(durationMs: number): string {264export function formatDuration(durationMs: number): string {
265 if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms";265 if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms";
266 if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`;266 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 }
268 if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`;272 if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`;
269 const minutes = Math.floor(durationMs / 60_000);273 const minutes = Math.floor(durationMs / 60_000);
270 const seconds = Math.round((durationMs % 60_000) / 1_000);274 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", () => {...@@ -776,9 +776,40 @@ describe("incremental orchestration updates", () => {
776 localEnvironmentId,776 localEnvironmentId,
777 );777 );
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.
779 expect(threadsOf(next)[0]?.session?.status).toBe("running");782 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();
781 expect(threadsOf(next)[0]?.messages).toHaveLength(1);785 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");
782 });813 });
783814
784 it("does not regress latestTurn when an older turn diff completes late", () => {815 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: {...@@ -885,6 +885,29 @@ function buildLatestTurn(params: {
885 };885 };
886}886}
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
888function rebindTurnDiffSummariesForAssistantMessage(911function rebindTurnDiffSummariesForAssistantMessage(
889 turnDiffSummaries: ReadonlyArray<TurnDiffSummary>,912 turnDiffSummaries: ReadonlyArray<TurnDiffSummary>,
890 turnId: TurnId,913 turnId: TurnId,
...@@ -1419,6 +1442,15 @@ function applyEnvironmentOrchestrationEvent(...@@ -1419,6 +1442,15 @@ function applyEnvironmentOrchestrationEvent(
1419 event.payload.messageId,1442 event.payload.messageId,
1420 )1443 )
1421 : thread.turnDiffSummaries;1444 : 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;
1422 const latestTurn: Thread["latestTurn"] =1454 const latestTurn: Thread["latestTurn"] =
1423 event.payload.role === "assistant" &&1455 event.payload.role === "assistant" &&
1424 event.payload.turnId !== null &&1456 event.payload.turnId !== null &&
...@@ -1426,13 +1458,13 @@ function applyEnvironmentOrchestrationEvent(...@@ -1426,13 +1458,13 @@ function applyEnvironmentOrchestrationEvent(
1426 ? buildLatestTurn({1458 ? buildLatestTurn({
1427 previous: thread.latestTurn,1459 previous: thread.latestTurn,
1428 turnId: event.payload.turnId,1460 turnId: event.payload.turnId,
1429 state: event.payload.streaming1461 state: settlesTurn
1430 ? "running"1462 ? thread.latestTurn?.state === "interrupted"
1431 : thread.latestTurn?.state === "interrupted"
1432 ? "interrupted"1463 ? "interrupted"
1433 : thread.latestTurn?.state === "error"1464 : thread.latestTurn?.state === "error"
1434 ? "error"1465 ? "error"
1435 : "completed",1466 : "completed"
1467 : "running",
1436 requestedAt:1468 requestedAt:
1437 thread.latestTurn?.turnId === event.payload.turnId1469 thread.latestTurn?.turnId === event.payload.turnId
1438 ? thread.latestTurn.requestedAt1470 ? thread.latestTurn.requestedAt
...@@ -1442,11 +1474,11 @@ function applyEnvironmentOrchestrationEvent(...@@ -1442,11 +1474,11 @@ function applyEnvironmentOrchestrationEvent(
1442 ? (thread.latestTurn.startedAt ?? event.payload.createdAt)1474 ? (thread.latestTurn.startedAt ?? event.payload.createdAt)
1443 : event.payload.createdAt,1475 : event.payload.createdAt,
1444 sourceProposedPlan: thread.pendingSourceProposedPlan,1476 sourceProposedPlan: thread.pendingSourceProposedPlan,
1445 completedAt: event.payload.streaming1477 completedAt: settlesTurn
1446 ? thread.latestTurn?.turnId === event.payload.turnId1478 ? event.payload.updatedAt
1479 : thread.latestTurn?.turnId === event.payload.turnId
1447 ? (thread.latestTurn.completedAt ?? null)1480 ? (thread.latestTurn.completedAt ?? null)
1448 : null1481 : null,
1449 : event.payload.updatedAt,
1450 assistantMessageId: event.payload.messageId,1482 assistantMessageId: event.payload.messageId,
1451 })1483 })
1452 : thread.latestTurn;1484 : thread.latestTurn;
...@@ -1460,11 +1492,12 @@ function applyEnvironmentOrchestrationEvent(...@@ -1460,11 +1492,12 @@ function applyEnvironmentOrchestrationEvent(
1460 });1492 });
14611493
1462 case "thread.session-set":1494 case "thread.session-set":
1463 return updateThreadState(state, event.payload.threadId, (thread) => ({1495 return updateThreadState(state, event.payload.threadId, (thread) => {
1464 ...thread,1496 // Leaving the "running" session status is the turn-end signal:
1465 session: mapSession(event.payload.session),1497 // settle a still-running latest turn so its duration reflects the
1466 error: sanitizeThreadErrorMessage(event.payload.session.lastError),1498 // whole turn, not the last assistant message.
1467 latestTurn:1499 const settledTurnState = settledTurnStateForSessionStatus(event.payload.session.status);
1500 const latestTurn: Thread["latestTurn"] =
1468 event.payload.session.status === "running" && event.payload.session.activeTurnId !== null1501 event.payload.session.status === "running" && event.payload.session.activeTurnId !== null
1469 ? buildLatestTurn({1502 ? buildLatestTurn({
1470 previous: thread.latestTurn,1503 previous: thread.latestTurn,
...@@ -1485,9 +1518,30 @@ function applyEnvironmentOrchestrationEvent(...@@ -1485,9 +1518,30 @@ function applyEnvironmentOrchestrationEvent(
1485 : null,1518 : null,
1486 sourceProposedPlan: thread.pendingSourceProposedPlan,1519 sourceProposedPlan: thread.pendingSourceProposedPlan,
1487 })1520 })
1488 : thread.latestTurn,1521 : thread.latestTurn !== null &&
1489 updatedAt: event.occurredAt,1522 thread.latestTurn.state === "running" &&
1490 }));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
1492 case "thread.session-stop-requested":1546 case "thread.session-stop-requested":
1493 return updateThreadState(state, event.payload.threadId, (thread) =>1547 return updateThreadState(state, event.payload.threadId, (thread) =>
...@@ -1552,8 +1606,14 @@ function applyEnvironmentOrchestrationEvent(...@@ -1552,8 +1606,14 @@ function applyEnvironmentOrchestrationEvent(
1552 (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER),1606 (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER),
1553 )1607 )
1554 .slice(-MAX_THREAD_CHECKPOINTS);1608 .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;
1555 const latestTurn =1614 const latestTurn =
1556 thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId1615 !turnStillRunning &&
1616 (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId)
1557 ? buildLatestTurn({1617 ? buildLatestTurn({
1558 previous: thread.latestTurn,1618 previous: thread.latestTurn,
1559 turnId: event.payload.turnId,1619 turnId: event.payload.turnId,
packages/client-runtime/src/threadDetailReducer.test.ts+89
...@@ -288,9 +288,98 @@ describe("applyThreadDetailEvent", () => {...@@ -288,9 +288,98 @@ describe("applyThreadDetailEvent", () => {
288 expect(result.thread.latestTurn?.assistantMessageId).toBe("msg-3");288 expect(result.thread.latestTurn?.assistantMessageId).toBe("msg-3");
289 }289 }
290 });290 });
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 });
291 });339 });
292340
293 describe("thread.session-set", () => {341 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
294 it("updates session and latestTurn for a running session", () => {383 it("updates session and latestTurn for a running session", () => {
295 const result = applyThreadDetailEvent(baseThread, {384 const result = applyThreadDetailEvent(baseThread, {
296 ...baseEventFields,385 ...baseEventFields,
packages/client-runtime/src/threadDetailReducer.ts+64-11
...@@ -7,6 +7,7 @@ import type {...@@ -7,6 +7,7 @@ import type {
7 OrchestrationEvent,7 OrchestrationEvent,
8 OrchestrationLatestTurn,8 OrchestrationLatestTurn,
9 OrchestrationMessage,9 OrchestrationMessage,
10 OrchestrationSession,
10 OrchestrationThread,11 OrchestrationThread,
11 OrchestrationThreadActivity,12 OrchestrationThreadActivity,
12 TurnId,13 TurnId,
...@@ -232,20 +233,29 @@ export function applyThreadDetailEvent(...@@ -232,20 +233,29 @@ export function applyThreadDetailEvent(
232 : Arr.append(thread.messages, message);233 : Arr.append(thread.messages, message);
233 const cappedMessages = Arr.takeRight(messages, limits.maxMessages);234 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;
236 const latestTurn: OrchestrationThread["latestTurn"] =246 const latestTurn: OrchestrationThread["latestTurn"] =
237 event.payload.role === "assistant" &&247 event.payload.role === "assistant" &&
238 event.payload.turnId !== null &&248 event.payload.turnId !== null &&
239 (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId)249 (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId)
240 ? {250 ? {
241 turnId: event.payload.turnId,251 turnId: event.payload.turnId,
242 state: event.payload.streaming252 state: settlesTurn
243 ? "running"253 ? thread.latestTurn?.state === "interrupted"
244 : thread.latestTurn?.state === "interrupted"
245 ? "interrupted"254 ? "interrupted"
246 : thread.latestTurn?.state === "error"255 : thread.latestTurn?.state === "error"
247 ? "error"256 ? "error"
248 : "completed",257 : "completed"
258 : "running",
249 requestedAt:259 requestedAt:
250 thread.latestTurn?.turnId === event.payload.turnId260 thread.latestTurn?.turnId === event.payload.turnId
251 ? thread.latestTurn.requestedAt261 ? thread.latestTurn.requestedAt
...@@ -254,11 +264,11 @@ export function applyThreadDetailEvent(...@@ -254,11 +264,11 @@ export function applyThreadDetailEvent(
254 thread.latestTurn?.turnId === event.payload.turnId264 thread.latestTurn?.turnId === event.payload.turnId
255 ? (thread.latestTurn.startedAt ?? event.payload.createdAt)265 ? (thread.latestTurn.startedAt ?? event.payload.createdAt)
256 : event.payload.createdAt,266 : event.payload.createdAt,
257 completedAt: event.payload.streaming267 completedAt: settlesTurn
258 ? thread.latestTurn?.turnId === event.payload.turnId268 ? event.payload.updatedAt
269 : thread.latestTurn?.turnId === event.payload.turnId
259 ? (thread.latestTurn.completedAt ?? null)270 ? (thread.latestTurn.completedAt ?? null)
260 : null271 : null,
261 : event.payload.updatedAt,
262 assistantMessageId: event.payload.messageId,272 assistantMessageId: event.payload.messageId,
263 }273 }
264 : thread.latestTurn;274 : thread.latestTurn;
...@@ -287,6 +297,9 @@ export function applyThreadDetailEvent(...@@ -287,6 +297,9 @@ export function applyThreadDetailEvent(
287297
288 // ── Session ─────────────────────────────────────────────────────298 // ── Session ─────────────────────────────────────────────────────
289 case "thread.session-set": {299 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);
290 const latestTurn: OrchestrationLatestTurn | null =303 const latestTurn: OrchestrationLatestTurn | null =
291 event.payload.session.status === "running" && event.payload.session.activeTurnId !== null304 event.payload.session.status === "running" && event.payload.session.activeTurnId !== null
292 ? {305 ? {
...@@ -306,7 +319,18 @@ export function applyThreadDetailEvent(...@@ -306,7 +319,18 @@ export function applyThreadDetailEvent(
306 ? thread.latestTurn.assistantMessageId319 ? thread.latestTurn.assistantMessageId
307 : null,320 : null,
308 }321 }
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
311 return {335 return {
312 kind: "updated",336 kind: "updated",
...@@ -380,8 +404,14 @@ export function applyThreadDetailEvent(...@@ -380,8 +404,14 @@ export function applyThreadDetailEvent(
380 Arr.takeRight(limits.maxCheckpoints),404 Arr.takeRight(limits.maxCheckpoints),
381 );405 );
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;
383 const latestTurn =412 const latestTurn =
384 thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId413 !diffTurnStillRunning &&
414 (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId)
385 ? {415 ? {
386 turnId: event.payload.turnId,416 turnId: event.payload.turnId,
387 state: checkpointStatusToTurnState(event.payload.status),417 state: checkpointStatusToTurnState(event.payload.status),
...@@ -482,6 +512,29 @@ export function applyThreadDetailEvent(...@@ -482,6 +512,29 @@ export function applyThreadDetailEvent(
482512
483// ── Helpers ──────────────────────────────────────────────────────────513// ── 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
485function checkpointStatusToTurnState(538function checkpointStatusToTurnState(
486 status: "ready" | "missing" | "error",539 status: "ready" | "missing" | "error",
487): OrchestrationLatestTurn["state"] {540): OrchestrationLatestTurn["state"] {
pnpm-lock.yaml+2-1
...@@ -50,6 +50,7 @@ overrides:...@@ -50,6 +50,7 @@ overrides:
50 '@effect/vitest': 4.0.0-beta.7850 '@effect/vitest': 4.0.0-beta.78
51 '@effect/vitest>@vitest/runner': '-'51 '@effect/vitest>@vitest/runner': '-'
52 '@effect/vitest>vitest': '-'52 '@effect/vitest>vitest': '-'
53 '@expo/metro-config': 56.0.13
53 '@types/node': 24.12.454 '@types/node': 24.12.4
54 effect: 4.0.0-beta.7855 effect: 4.0.0-beta.78
55 vite: npm:@voidzero-dev/vite-plus-core@0.1.2456 vite: npm:@voidzero-dev/vite-plus-core@0.1.24
...@@ -12620,7 +12621,7 @@ snapshots:...@@ -12620,7 +12621,7 @@ snapshots:
12620 '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7)12621 '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7)
12621 '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7)12622 '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7)
12622 '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7)12623 '@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)
12624 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)12625 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
12625 '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7)12626 '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7)
12626 '@babel/plugin-transform-regenerator': 7.29.7(@babel/core@7.29.7)12627 '@babel/plugin-transform-regenerator': 7.29.7(@babel/core@7.29.7)
pnpm-workspace.yaml+4
...@@ -56,6 +56,10 @@ overrides:...@@ -56,6 +56,10 @@ overrides:
56 "@effect/vitest": "catalog:"56 "@effect/vitest": "catalog:"
57 "@effect/vitest>@vitest/runner": "-"57 "@effect/vitest>@vitest/runner": "-"
58 "@effect/vitest>vitest": "-"58 "@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"
59 "@types/node": "catalog:"63 "@types/node": "catalog:"
60 effect: "catalog:"64 effect: "catalog:"
61 vite: "catalog:"65 vite: "catalog:"