| author | |
| committer | |
| log | 57f6bf7edbaec3a652245c15b65b320d58571deb |
| tree | 1d5dac346020853363f7cc390e2d7a2df5c2c8f3 |
| parent | 0baf1986e8261c62035cbebc326d0da4ffd2defb |
| signature | Signed by PGP key B5690EEEBB952194 |
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 === |
| 22 | const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; | 22 | const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; |
| 23 | const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; | 23 | const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; |
| 24 | const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; | 24 | const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; |
| 25 | const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); | ||
| 25 | const permissionOptionIds = { | 26 | const 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); |
| 366 | 367 | ||
| 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"; |
| 369 | 374 |
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 | ); |
| 1234 | 1234 | ||
| 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"; |
| 7 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| ... | @@ -69,6 +70,29 @@ export const ORCHESTRATION_PROJECTOR_NAMES = { | ... | @@ -69,6 +70,29 @@ export const ORCHESTRATION_PROJECTOR_NAMES = { |
| 69 | type ProjectorName = | 70 | type ProjectorName = |
| 70 | (typeof ORCHESTRATION_PROJECTOR_NAMES)[keyof typeof ORCHESTRATION_PROJECTOR_NAMES]; | 71 | (typeof ORCHESTRATION_PROJECTOR_NAMES)[keyof typeof ORCHESTRATION_PROJECTOR_NAMES]; |
| 71 | 72 | ||
| 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 | */ | ||
| 78 | function 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 | |||
| 72 | interface ProjectorDefinition { | 96 | interface 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 | } |
| 1004 | 1054 | ||
| 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.streaming | 1176 | state: settlesTurn |
| 1092 | ? existingTurn.value.state | 1177 | ? 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.streaming | 1182 | : existingTurn.value.state, |
| 1099 | ? existingTurn.value.completedAt | 1183 | 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 | } |
| 1161 | 1246 | ||
| 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"; |
| 1118 | 1118 | ||
| 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 | }); |
| 1262 | 1262 | ||
| 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; |
| 1228 | 1228 | ||
| 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 | ); |
| 269 | 269 | ||
| 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 | ); |
| 297 | 329 | ||
| 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 | }); |
| 302 | 341 | ||
| 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 | } |
| 40 | 40 | ||
| 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 | */ | ||
| 46 | function 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 | |||
| 41 | function updateThread( | 64 | function 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 | ); |
| 440 | 463 | ||
| 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.assistantMessageId | 487 | ? 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); |
| 546 | 583 | ||
| 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.turnId | 597 | state: checkpointStatusToLatestTurnState(payload.status), |
| 556 | ? thread.latestTurn.requestedAt | 598 | requestedAt: |
| 557 | : payload.completedAt, | 599 | thread.latestTurn?.turnId === payload.turnId |
| 558 | startedAt: | 600 | ? thread.latestTurn.requestedAt |
| 559 | thread.latestTurn?.turnId === payload.turnId | 601 | : 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 | }); |
| 892 | 892 | ||
| 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 { |
| 122 | interface ClaudeTurnState { | 122 | interface 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.modelSelection | 3637 | ? input.modelSelection |
| 3630 | : undefined; | 3638 | : undefined; |
| 3631 | 3639 | ||
| 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 responses | 3641 | // 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 | } |
| 3637 | 3650 | ||
| ... | @@ -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 | } |
| 3668 | 3681 | ||
| 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 | }; | ||
| 3679 | 3693 | ||
| 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 | }; |
| 3688 | 3702 | ||
| 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 | } | ||
| 3700 | 3715 | ||
| 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"; |
| 13 | import * as Layer from "effect/Layer"; | 13 | import * as Layer from "effect/Layer"; |
| 14 | import * as Schema from "effect/Schema"; | 14 | import * as Schema from "effect/Schema"; |
| 15 | import * as Stream from "effect/Stream"; | 15 | import * as Stream from "effect/Stream"; |
| 16 | import * as TestClock from "effect/testing/TestClock"; | ||
| 16 | import { createModelSelection } from "@t3tools/shared/model"; | 17 | import { createModelSelection } from "@t3tools/shared/model"; |
| 17 | 18 | ||
| 18 | import { | 19 | import { |
| ... | @@ -232,6 +233,82 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -232,6 +233,82 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 232 | }), | 233 | }), |
| 233 | ); | 234 | ); |
| 234 | 235 | ||
| 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 | } |
| 137 | 141 | ||
| ... | @@ -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 | }; |
| 759 | 764 | ||
| ... | @@ -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 === undefined | 900 | return yield* Effect.gen(function* () { |
| 896 | ? undefined | 901 | 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 | }, |
| 911 | 916 | 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 | }; | ||
| 920 | 928 | ||
| 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 | } | ||
| 956 | 975 | ||
| 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 | } |
| 964 | 983 | ||
| 965 | const result = yield* ctx.acp | 984 | 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 | ); |
| 974 | 993 | ||
| 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 | }; | ||
| 982 | 1006 | ||
| 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 | } | ||
| 994 | 1023 | ||
| 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 | }); |
| 1001 | 1037 | ||
| 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 === boundInstanceId | 674 | // id is reused instead of opening a new turn. |
| 670 | ? input.modelSelection | 675 | const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; |
| 676 | const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); | ||
| 677 | // Count this prompt immediately so a superseded in-flight prompt | ||
| 678 | // resolving from here on does not settle the turn; decremented on | ||
| 679 | // preparation failure here, and after the prompt below otherwise. | ||
| 680 | ctx.promptsInFlight += 1; | ||
| 681 | |||
| 682 | return yield* Effect.gen(function* () { | ||
| 683 | const turnModelSelection = | ||
| 684 | input.modelSelection?.instanceId === boundInstanceId | ||
| 685 | ? input.modelSelection | ||
| 686 | : undefined; | ||
| 687 | const requestedTurnModelId = turnModelSelection?.model | ||
| 688 | ? resolveGrokAcpBaseModelId(turnModelSelection.model) | ||
| 671 | : undefined; | 689 | : undefined; |
| 672 | const requestedTurnModelId = turnModelSelection?.model | 690 | 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 | }); | ||
| 682 | 697 | ||
| 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", |
| 719 | 727 | 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, |
| 727 | 735 | ]; | |
| 728 | ctx.currentModelId = currentModelId; | 736 | |
| 729 | const displayModel = currentModelId | 737 | 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 | }); | ||
| 749 | 744 | ||
| 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 | ); |
| 759 | 787 | ||
| 760 | const result = yield* prepared.acp | 788 | 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 | ); | ||
| 769 | 798 | ||
| 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 | }); | ||
| 804 | 810 | ||
| 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 | }); |
| 813 | 867 |
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 | ); |
| 397 | 397 | ||
| 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( |
| 1151 | 1151 | ||
| 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.model | 1161 | (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 | ); |
| 1208 | 1212 | ||
| 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 | } | ||
| 1217 | 1223 | ||
| 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 ready | 1234 | // On failure of a fresh turn: clear active-turn state, flip the |
| 1229 | // with lastError set, emit turn.aborted, then let the typed error | 1235 | // 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 | ); |
| 1259 | 1268 |
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 | }); |
| 482 | 482 | ||
| 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 | } |
| 14 | 14 | ||
| 15 | function 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 | |||
| 15 | export interface TimelineDurationMessage { | 25 | export 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>(); |
| 172 | 189 | ||
| 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 | } |
| 199 | 229 | ||
| 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: { |
| 223 | 253 | ||
| 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.completedAt | 265 | 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 = isLatestInterruptedTurn | 273 | 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 | maintainVisibleContentPosition | 369 | 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 |
| 264 | export function formatDuration(durationMs: number): string { | 264 | export 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 | ); |
| 778 | 778 | ||
| 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 | }); |
| 783 | 814 | ||
| 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 | } |
| 887 | 887 | ||
| 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 | */ | ||
| 893 | function 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 | |||
| 888 | function rebindTurnDiffSummariesForAssistantMessage( | 911 | function 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.streaming | 1461 | 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.turnId | 1469 | thread.latestTurn?.turnId === event.payload.turnId |
| 1438 | ? thread.latestTurn.requestedAt | 1470 | ? 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.streaming | 1477 | completedAt: settlesTurn |
| 1446 | ? thread.latestTurn?.turnId === event.payload.turnId | 1478 | ? event.payload.updatedAt |
| 1479 | : thread.latestTurn?.turnId === event.payload.turnId | ||
| 1447 | ? (thread.latestTurn.completedAt ?? null) | 1480 | ? (thread.latestTurn.completedAt ?? null) |
| 1448 | : null | 1481 | : 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 | }); |
| 1461 | 1493 | ||
| 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 !== null | 1501 | 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 | }); | ||
| 1491 | 1545 | ||
| 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.turnId | 1615 | !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 | }); |
| 292 | 340 | ||
| 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); |
| 234 | 235 | ||
| 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.streaming | 252 | 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.turnId | 260 | thread.latestTurn?.turnId === event.payload.turnId |
| 251 | ? thread.latestTurn.requestedAt | 261 | ? thread.latestTurn.requestedAt |
| ... | @@ -254,11 +264,11 @@ export function applyThreadDetailEvent( | ... | @@ -254,11 +264,11 @@ export function applyThreadDetailEvent( |
| 254 | thread.latestTurn?.turnId === event.payload.turnId | 264 | 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.streaming | 267 | completedAt: settlesTurn |
| 258 | ? thread.latestTurn?.turnId === event.payload.turnId | 268 | ? event.payload.updatedAt |
| 269 | : thread.latestTurn?.turnId === event.payload.turnId | ||
| 259 | ? (thread.latestTurn.completedAt ?? null) | 270 | ? (thread.latestTurn.completedAt ?? null) |
| 260 | : null | 271 | : 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( |
| 287 | 297 | ||
| 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 !== null | 304 | 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.assistantMessageId | 319 | ? 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; | ||
| 310 | 334 | ||
| 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 | ); |
| 382 | 406 | ||
| 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.turnId | 413 | !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( |
| 482 | 512 | ||
| 483 | // ── Helpers ────────────────────────────────────────────────────────── | 513 | // ── Helpers ────────────────────────────────────────────────────────── |
| 484 | 514 | ||
| 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 | */ | ||
| 520 | function 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 | |||
| 485 | function checkpointStatusToTurnState( | 538 | function 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.78 | 50 | '@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.4 | 54 | '@types/node': 24.12.4 |
| 54 | effect: 4.0.0-beta.78 | 55 | effect: 4.0.0-beta.78 |
| 55 | vite: npm:@voidzero-dev/vite-plus-core@0.1.24 | 56 | 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:" |