| author | |
| committer | |
| log | f68e24fb419c985269cd0acb841e203af1419c43 |
| tree | 0b7e69fcdbbe758e1b8e7619fe9ca8e92f5866ef |
| parent | a5b34b25378fbbf90ce36ee101a11528ccf11c7e |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Theo Browne <me@t3.gg>8 files changed, 204 insertions(+), 36 deletions(-)
apps/mobile/src/features/threads/threadListV2.test.ts+27-4| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { presentThreadShell } from "@t3tools/client-runtime/state/models"; | |
| 2 | import * as DateTime from "effect/DateTime"; | |
| 1 | 3 | import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort"; |
| 2 | 4 | import { |
| 3 | 5 | createPendingThreadOrder, |
| ... | ... | @@ -23,7 +25,7 @@ import { |
| 23 | 25 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 24 | 26 | |
| 25 | 27 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 26 | import { makeThreadShellFixture } from "../../test-fixtures"; | |
| 28 | import { makeRawThreadShell, makeThreadShellFixture } from "../../test-fixtures"; | |
| 27 | 29 | import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts"; |
| 28 | 30 | import { |
| 29 | 31 | buildThreadListV2Items, |
| ... | ... | @@ -36,6 +38,7 @@ import { |
| 36 | 38 | resolveThreadListV2SwipeActions, |
| 37 | 39 | sortThreadsForListV2, |
| 38 | 40 | threadListV2ListItemsAreEqual, |
| 41 | threadHasUnseenCompletion, | |
| 39 | 42 | type ThreadListV2ListItem, |
| 40 | 43 | } from "./threadListV2"; |
| 41 | 44 | |
| ... | ... | @@ -160,9 +163,7 @@ describe("resolveThreadListV2Status", () => { |
| 160 | 163 | makeThread({ |
| 161 | 164 | id: ThreadId.make("t"), |
| 162 | 165 | title: "t", |
| 163 | pendingBackgroundTasks: [ | |
| 164 | { taskId: "bg-1", description: "Run Codex review", kind: "command" }, | |
| 165 | ], | |
| 166 | pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }], | |
| 166 | 167 | runtime: { |
| 167 | 168 | status: "idle", |
| 168 | 169 | activeRunId: null, |
| ... | ... | @@ -176,6 +177,28 @@ describe("resolveThreadListV2Status", () => { |
| 176 | 177 | ).toBe("waiting"); |
| 177 | 178 | }); |
| 178 | 179 | |
| 180 | it.each([ | |
| 181 | { kind: "command", status: "ready" }, | |
| 182 | { kind: "monitor", status: "waiting" }, | |
| 183 | ] as const)( | |
| 184 | "presents an unseen completion with a $kind roster as $status", | |
| 185 | ({ kind, status }) => { | |
| 186 | const thread = presentThreadShell( | |
| 187 | environmentId, | |
| 188 | makeRawThreadShell({ | |
| 189 | latestRunId: RunId.make("run-background-completion"), | |
| 190 | status: "completed", | |
| 191 | latestRunCompletedAt: DateTime.makeUnsafe(NOW), | |
| 192 | lastVisitedAt: DateTime.makeUnsafe("2026-06-01T23:59:00.000Z"), | |
| 193 | pendingBackgroundTasks: [{ taskId: "background-work", kind }], | |
| 194 | }), | |
| 195 | ); | |
| 196 | ||
| 197 | expect(resolveThreadListV2Status(thread)).toBe(status); | |
| 198 | expect(threadHasUnseenCompletion(thread)).toBe(true); | |
| 199 | }, | |
| 200 | ); | |
| 201 | ||
| 179 | 202 | it("resolves ready for quiescent threads", () => { |
| 180 | 203 | expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe( |
| 181 | 204 | "ready", |
apps/mobile/src/features/threads/threadListV2.ts+2-1| ... | ... | @@ -62,7 +62,8 @@ export function resolveThreadListV2ProviderDrivers( |
| 62 | 62 | * failures. Ready is the unlabeled resting state; waiting (runtime status "idle") is the agent |
| 63 | 63 | * parked on open background tasks, grey like working rather than a false Done. |
| 64 | 64 | * The orchestrator v2 presentation bridge parks runtime at idle when the |
| 65 | * post-settlement background roster is nonempty. | |
| 65 | * post-settlement background roster holds the run's completion (subagents, | |
| 66 | * monitors); commands left running, such as a dev server, read as ready. | |
| 66 | 67 | */ |
| 67 | 68 | export type ThreadListV2Status = |
| 68 | 69 | | "approval" |
apps/web/src/components/Sidebar.logic.test.ts+37-2| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { presentThreadShell } from "@t3tools/client-runtime/state/models"; | |
| 2 | import * as DateTime from "effect/DateTime"; | |
| 1 | 3 | import { deriveActiveWorkStartedAt } from "../session-logic.ts"; |
| 2 | 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | 5 | import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; |
| ... | ... | @@ -1254,7 +1256,7 @@ describe("resolveThreadStatusPill", () => { |
| 1254 | 1256 | resolveThreadStatusPill({ |
| 1255 | 1257 | thread: { |
| 1256 | 1258 | ...baseThread, |
| 1257 | pendingBackgroundTasks: [{ taskId: "bg-1", description: "sleep 20", kind: "command" }], | |
| 1259 | pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }], | |
| 1258 | 1260 | runtime: { |
| 1259 | 1261 | ...baseThread.runtime, |
| 1260 | 1262 | status: "idle", |
| ... | ... | @@ -2053,6 +2055,39 @@ describe("navigation after parking a thread", () => { |
| 2053 | 2055 | ); |
| 2054 | 2056 | }); |
| 2055 | 2057 | |
| 2058 | describe("unseen completion with background work", () => { | |
| 2059 | it.each([ | |
| 2060 | { kind: "command", status: "ready", topStatus: "done", receded: false, pill: "Completed" }, | |
| 2061 | { kind: "monitor", status: "waiting", topStatus: "waiting", receded: true, pill: "Waiting" }, | |
| 2062 | ] as const)("presents a completed thread with a $kind roster", (expected) => { | |
| 2063 | const thread = presentThreadShell(localEnvironmentId, { | |
| 2064 | ...makeThreadFixture().source, | |
| 2065 | latestRunId: RunId.make("run-background-completion"), | |
| 2066 | status: "completed", | |
| 2067 | latestRunCompletedAt: DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"), | |
| 2068 | lastVisitedAt: DateTime.makeUnsafe("2026-06-20T00:59:00.000Z"), | |
| 2069 | pendingBackgroundTasks: [{ taskId: "background-work", kind: expected.kind }], | |
| 2070 | }); | |
| 2071 | const status = resolveSidebarThreadStatus(thread); | |
| 2072 | const isUnread = hasUnseenCompletion(thread); | |
| 2073 | ||
| 2074 | expect(isUnread).toBe(true); | |
| 2075 | expect(status).toBe(expected.status); | |
| 2076 | expect(resolveSidebarV2TopStatus({ status, isUnread, isWoke: false })).toBe(expected.topStatus); | |
| 2077 | expect( | |
| 2078 | shouldRecedeSidebarThread({ | |
| 2079 | status, | |
| 2080 | isUnread, | |
| 2081 | isWoke: false, | |
| 2082 | isActive: false, | |
| 2083 | isSelected: false, | |
| 2084 | }), | |
| 2085 | ).toBe(expected.receded); | |
| 2086 | expect(isSidebarThreadWorking(thread)).toBe(expected.receded); | |
| 2087 | expect(resolveThreadStatusPill({ thread })).toMatchObject({ label: expected.pill }); | |
| 2088 | }); | |
| 2089 | }); | |
| 2090 | ||
| 2056 | 2091 | describe("Working shelf (beta)", () => { |
| 2057 | 2092 | const runtime = { |
| 2058 | 2093 | status: "running" as const, |
| ... | ... | @@ -2062,7 +2097,7 @@ describe("Working shelf (beta)", () => { |
| 2062 | 2097 | lastError: null, |
| 2063 | 2098 | updatedAt: "2026-03-09T10:00:00.000Z", |
| 2064 | 2099 | }; |
| 2065 | const backgroundTask = { taskId: "bg-1", description: "sleep 20", kind: "command" as const }; | |
| 2100 | const backgroundTask = { taskId: "bg-1", description: "Watch build", kind: "monitor" as const }; | |
| 2066 | 2101 | const idle = { |
| 2067 | 2102 | hasActionableProposedPlan: false, |
| 2068 | 2103 | hasPendingApprovals: false, |
apps/web/src/components/Sidebar.logic.ts+8-6| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models"; |
| 2 | import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; | |
| 2 | 3 | import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; |
| 3 | 4 | import * as React from "react"; |
| 4 | 5 | import { |
| ... | ... | @@ -910,9 +911,10 @@ export function resolveThreadRowClassName(input: { |
| 910 | 911 | // (approval), "in motion" (working), and "broken" (failed). Ready is the |
| 911 | 912 | // unlabeled resting state — the agent stopped and is waiting on the user, |
| 912 | 913 | // whether it finished, asked a question, or proposed a plan. Waiting |
| 913 | // (runtime status "idle") is the agent stopped with background tasks still | |
| 914 | // open: not the user's turn yet, so it renders grey like working, not as a | |
| 915 | // false Done. | |
| 914 | // (runtime status "idle") is the agent stopped with background work that will | |
| 915 | // wake it (subagents, monitors): not the user's turn yet, so it renders grey | |
| 916 | // like working, not as a false Done. Commands it left running, such as a dev | |
| 917 | // server, do not hold the thread; it reads as ready. | |
| 916 | 918 | // Unread completion is tracked separately: it describes whether a ready |
| 917 | 919 | // thread needs attention, not what the thread is currently doing. |
| 918 | 920 | export type SidebarThreadStatus = |
| ... | ... | @@ -1007,8 +1009,8 @@ export function shouldShowSidebarV2Duration(status: SidebarThreadStatus): boolea |
| 1007 | 1009 | } |
| 1008 | 1010 | |
| 1009 | 1011 | /** Working beta: threads busy with work that does not need the user fold into |
| 1010 | the Working shelf: a running run, or one stopped with background tasks | |
| 1011 | still open. Approvals, questions, plan prompts, and failures stay in the | |
| 1012 | the Working shelf: a running run, or one stopped with background work | |
| 1013 | that will wake it. Approvals, questions, plan prompts, and failures stay in the | |
| 1012 | 1014 | inbox. */ |
| 1013 | 1015 | export function isSidebarThreadWorking(thread: ThreadStatusInput): boolean { |
| 1014 | 1016 | const status = resolveSidebarThreadStatus(thread); |
| ... | ... | @@ -1209,7 +1211,7 @@ export function resolveThreadStatusPill(input: { |
| 1209 | 1211 | }; |
| 1210 | 1212 | } |
| 1211 | 1213 | |
| 1212 | if ((thread.pendingBackgroundTasks?.length ?? 0) > 0) { | |
| 1214 | if (backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])) { | |
| 1213 | 1215 | return { |
| 1214 | 1216 | label: "Waiting", |
| 1215 | 1217 | colorClass: "text-sidebar-muted-foreground", |
apps/web/src/components/ThreadNotificationCoordinator.tsx+4-6| ... | ... | @@ -2,7 +2,6 @@ import { presentThreadShell } from "@t3tools/client-runtime/state/models"; |
| 2 | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | 3 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 4 | 4 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 5 | import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; | |
| 6 | 5 | import * as Option from "effect/Option"; |
| 7 | 6 | import { |
| 8 | 7 | CircleAlertIcon, |
| ... | ... | @@ -126,12 +125,11 @@ function EnvironmentNotifications({ |
| 126 | 125 | ? `${thread.latestRun?.runId ?? ""}:${status}` |
| 127 | 126 | : null; |
| 128 | 127 | const completedAt = Date.parse(thread.latestRun?.completedAt ?? ""); |
| 129 | // Waiting only on commands (a dev server) is done; subagents and monitors wake the agent. | |
| 130 | const settled = | |
| 131 | status === "ready" || | |
| 132 | (status === "waiting" && !backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks)); | |
| 128 | // Commands left running (a dev server) read as ready; subagents and monitors wait. | |
| 133 | 129 | const completion = |
| 134 | settled && thread.latestRun?.status === "completed" && Number.isFinite(completedAt) | |
| 130 | status === "ready" && | |
| 131 | thread.latestRun?.status === "completed" && | |
| 132 | Number.isFinite(completedAt) | |
| 135 | 133 | ? completedAt |
| 136 | 134 | : (prior?.completion ?? null); |
| 137 | 135 | next.set(thread.id, { attention, completion }); |
packages/client-runtime/src/state/entities.test.ts+112-11| ... | ... | @@ -2,8 +2,10 @@ import { |
| 2 | 2 | EnvironmentId, |
| 3 | 3 | MessageId, |
| 4 | 4 | NodeId, |
| 5 | ProviderDriverKind, | |
| 5 | 6 | ProviderInstanceId, |
| 6 | 7 | ProviderSessionId, |
| 8 | ProviderThreadId, | |
| 7 | 9 | RunId, |
| 8 | 10 | RuntimeRequestId, |
| 9 | 11 | TurnItemId, |
| ... | ... | @@ -140,7 +142,7 @@ describe("V2 client presentation", () => { |
| 140 | 142 | latestRunId: runId, |
| 141 | 143 | activeRunId: null, |
| 142 | 144 | status: "completed", |
| 143 | pendingBackgroundTasks: [{ taskId: "bg-1", description: "sleep 20", kind: "command" }], | |
| 145 | pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }], | |
| 144 | 146 | }); |
| 145 | 147 | |
| 146 | 148 | expect(shell.latestRun).toMatchObject({ runId, status: "completed" }); |
| ... | ... | @@ -149,10 +151,50 @@ describe("V2 client presentation", () => { |
| 149 | 151 | activeRunId: null, |
| 150 | 152 | }); |
| 151 | 153 | expect(shell.pendingBackgroundTasks).toEqual([ |
| 152 | { taskId: "bg-1", description: "sleep 20", kind: "command" }, | |
| 154 | { taskId: "bg-1", description: "Watch build", kind: "monitor" }, | |
| 153 | 155 | ]); |
| 154 | 156 | }); |
| 155 | 157 | |
| 158 | it.each([ | |
| 159 | { kinds: ["command"], expected: "completed" }, | |
| 160 | { kinds: ["command", "subagent"], expected: "idle" }, | |
| 161 | { kinds: ["background_task"], expected: "idle" }, | |
| 162 | ] as const)("presents a completed shell with $kinds as $expected", ({ kinds, expected }) => { | |
| 163 | const pendingBackgroundTasks = kinds.map((kind, index) => ({ | |
| 164 | taskId: `bg-${index}`, | |
| 165 | kind, | |
| 166 | })); | |
| 167 | const shell = presentThreadShell(environmentId, { | |
| 168 | ...v2ThreadShell, | |
| 169 | latestRunId: RunId.make("run-completed"), | |
| 170 | activeRunId: null, | |
| 171 | status: "completed", | |
| 172 | pendingBackgroundTasks, | |
| 173 | }); | |
| 174 | ||
| 175 | expect(shell.latestRun?.status).toBe("completed"); | |
| 176 | expect(shell.runtime).toMatchObject({ status: expected, activeRunId: null }); | |
| 177 | expect(shell.pendingBackgroundTasks).toEqual(pendingBackgroundTasks); | |
| 178 | }); | |
| 179 | ||
| 180 | it.each(["running", "waiting"] as const)( | |
| 181 | "preserves shell %s while only commands remain in the roster", | |
| 182 | (status) => { | |
| 183 | const runId = RunId.make("run-command"); | |
| 184 | const shell = presentThreadShell(environmentId, { | |
| 185 | ...v2ThreadShell, | |
| 186 | latestRunId: runId, | |
| 187 | activeRunId: runId, | |
| 188 | status, | |
| 189 | pendingBackgroundTasks: [{ taskId: "dev-server", kind: "command" }], | |
| 190 | }); | |
| 191 | ||
| 192 | expect(shell.latestRun?.status).toBe(status); | |
| 193 | expect(shell.runtime).toMatchObject({ status, activeRunId: runId }); | |
| 194 | expect(shell.pendingBackgroundTasks).toEqual([{ taskId: "dev-server", kind: "command" }]); | |
| 195 | }, | |
| 196 | ); | |
| 197 | ||
| 156 | 198 | it("keeps a failed latest run failed while background tasks are still pending", () => { |
| 157 | 199 | const shell = presentThreadShell(environmentId, { |
| 158 | 200 | ...v2ThreadShell, |
| ... | ... | @@ -160,7 +202,7 @@ describe("V2 client presentation", () => { |
| 160 | 202 | activeRunId: null, |
| 161 | 203 | status: "failed", |
| 162 | 204 | lastError: "Provider turn failed", |
| 163 | pendingBackgroundTasks: [{ taskId: "bg-1", description: "sleep 20", kind: "command" }], | |
| 205 | pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }], | |
| 164 | 206 | }); |
| 165 | 207 | |
| 166 | 208 | // Sidebar and mobile list read runtime "idle" as Waiting before failure. |
| ... | ... | @@ -286,7 +328,7 @@ describe("V2 client presentation", () => { |
| 286 | 328 | // Stale: server already projected a post-settlement roster, but shell |
| 287 | 329 | // status still says running (packaged orchestrator-v2 bug). |
| 288 | 330 | status: "running", |
| 289 | pendingBackgroundTasks: [{ taskId: "bg-1", description: "sleep 20", kind: "command" }], | |
| 331 | pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }], | |
| 290 | 332 | }); |
| 291 | 333 | |
| 292 | 334 | expect(shell.latestRun).toMatchObject({ runId, status: "running" }); |
| ... | ... | @@ -295,7 +337,7 @@ describe("V2 client presentation", () => { |
| 295 | 337 | activeRunId: runId, |
| 296 | 338 | }); |
| 297 | 339 | expect(shell.pendingBackgroundTasks).toEqual([ |
| 298 | { taskId: "bg-1", description: "sleep 20", kind: "command" }, | |
| 340 | { taskId: "bg-1", description: "Watch build", kind: "monitor" }, | |
| 299 | 341 | ]); |
| 300 | 342 | }); |
| 301 | 343 | |
| ... | ... | @@ -307,7 +349,7 @@ describe("V2 client presentation", () => { |
| 307 | 349 | activeRunId: runId, |
| 308 | 350 | // Stale: checkpoint-oriented waiting masks post-settlement background work. |
| 309 | 351 | status: "waiting", |
| 310 | pendingBackgroundTasks: [{ taskId: "bg-2", description: "background bash", kind: "command" }], | |
| 352 | pendingBackgroundTasks: [{ taskId: "bg-2", description: "Watch build", kind: "monitor" }], | |
| 311 | 353 | }); |
| 312 | 354 | |
| 313 | 355 | expect(shell.latestRun).toMatchObject({ runId, status: "waiting" }); |
| ... | ... | @@ -316,7 +358,7 @@ describe("V2 client presentation", () => { |
| 316 | 358 | activeRunId: runId, |
| 317 | 359 | }); |
| 318 | 360 | expect(shell.pendingBackgroundTasks).toEqual([ |
| 319 | { taskId: "bg-2", description: "background bash", kind: "command" }, | |
| 361 | { taskId: "bg-2", description: "Watch build", kind: "monitor" }, | |
| 320 | 362 | ]); |
| 321 | 363 | }); |
| 322 | 364 | |
| ... | ... | @@ -443,7 +485,7 @@ describe("V2 client presentation", () => { |
| 443 | 485 | contextHandoffId: null, |
| 444 | 486 | }; |
| 445 | 487 | const backgroundItem = { |
| 446 | id: TurnItemId.make("item-background-command"), | |
| 488 | id: TurnItemId.make("item-background-subagent"), | |
| 447 | 489 | threadId: v2Projection.thread.id, |
| 448 | 490 | runId, |
| 449 | 491 | nodeId: null, |
| ... | ... | @@ -453,12 +495,18 @@ describe("V2 client presentation", () => { |
| 453 | 495 | parentItemId: null, |
| 454 | 496 | ordinal: 0, |
| 455 | 497 | status: "running" as const, |
| 456 | title: "Background command", | |
| 498 | title: "Background review", | |
| 457 | 499 | startedAt: now, |
| 458 | 500 | completedAt: null, |
| 459 | 501 | updatedAt: now, |
| 460 | type: "command_execution" as const, | |
| 461 | input: "sleep 20", | |
| 502 | type: "subagent" as const, | |
| 503 | subagentId: NodeId.make("subagent-review"), | |
| 504 | origin: "provider_native" as const, | |
| 505 | driver: ProviderDriverKind.make("codex"), | |
| 506 | providerInstanceId: v2Projection.thread.providerInstanceId, | |
| 507 | childThreadId: null, | |
| 508 | prompt: "Review the changes", | |
| 509 | result: null, | |
| 462 | 510 | }; |
| 463 | 511 | |
| 464 | 512 | expect( |
| ... | ... | @@ -488,6 +536,59 @@ describe("V2 client presentation", () => { |
| 488 | 536 | turnItems: [backgroundItem], |
| 489 | 537 | }), |
| 490 | 538 | ).toMatchObject({ status: "failed", activeRunId: null }); |
| 539 | const commandItem = { | |
| 540 | ...backgroundItem, | |
| 541 | id: TurnItemId.make("item-background-command"), | |
| 542 | type: "command_execution" as const, | |
| 543 | input: "npm run dev", | |
| 544 | }; | |
| 545 | expect( | |
| 546 | deriveThreadRuntime({ ...v2Projection, runs: [run], turnItems: [commandItem] }), | |
| 547 | ).toMatchObject({ status: "waiting", activeRunId: null }); | |
| 548 | ||
| 549 | for (const [turnItems, status] of [ | |
| 550 | [[commandItem], "completed"], | |
| 551 | [[backgroundItem], "idle"], | |
| 552 | [[commandItem, backgroundItem], "idle"], | |
| 553 | ] as const) { | |
| 554 | expect( | |
| 555 | deriveThreadRuntime({ | |
| 556 | ...v2Projection, | |
| 557 | runs: [{ ...run, status: "completed", completedAt: now }], | |
| 558 | turnItems, | |
| 559 | }), | |
| 560 | ).toMatchObject({ status, activeRunId: null }); | |
| 561 | } | |
| 562 | ||
| 563 | for (const kind of ["monitor", "background_task"] as const) { | |
| 564 | const providerThread = { | |
| 565 | id: ProviderThreadId.make("provider-thread-background"), | |
| 566 | driver: ProviderDriverKind.make("claudeCode"), | |
| 567 | providerInstanceId: v2Projection.thread.providerInstanceId, | |
| 568 | providerSessionId: null, | |
| 569 | appThreadId: v2Projection.thread.id, | |
| 570 | ownerNodeId: null, | |
| 571 | nativeThreadRef: null, | |
| 572 | nativeConversationHeadRef: null, | |
| 573 | status: "idle" as const, | |
| 574 | firstRunOrdinal: 1, | |
| 575 | lastRunOrdinal: 1, | |
| 576 | handoffIds: [], | |
| 577 | forkedFrom: null, | |
| 578 | pendingBackgroundTasks: [{ taskId: "background-task", kind }], | |
| 579 | createdAt: now, | |
| 580 | updatedAt: now, | |
| 581 | }; | |
| 582 | for (const status of ["completed", "failed"] as const) { | |
| 583 | expect( | |
| 584 | deriveThreadRuntime({ | |
| 585 | ...v2Projection, | |
| 586 | runs: [{ ...run, status, completedAt: now }], | |
| 587 | providerThreads: [providerThread], | |
| 588 | }), | |
| 589 | ).toMatchObject({ status: status === "failed" ? "failed" : "idle", activeRunId: null }); | |
| 590 | } | |
| 591 | } | |
| 491 | 592 | }); |
| 492 | 593 | |
| 493 | 594 | it("joins pending request entities to their native turn-item display data", () => { |
packages/client-runtime/src/state/models.ts+8-3| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; | |
| 1 | 2 | import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; |
| 2 | 3 | import type { |
| 3 | 4 | ThreadLinkedPullRequest, |
| ... | ... | @@ -159,15 +160,19 @@ function terminalRunStatus(status: OrchestrationV2RunStatus): boolean { |
| 159 | 160 | ); |
| 160 | 161 | } |
| 161 | 162 | |
| 162 | // Park runtime at idle when the post-settlement background roster is nonempty | |
| 163 | // so #4415 waiting-presentation Waiting (session.idle) can consume CTM runtime. | |
| 163 | // Park runtime at idle when the post-settlement background roster holds the | |
| 164 | // run's completion, so #4415 waiting-presentation Waiting (session.idle) can | |
| 165 | // consume CTM runtime. Only work that wakes the agent holds it: commands it | |
| 166 | // left running, such as a dev server, present the run's own status (#14872). | |
| 164 | 167 | // The server suppresses the roster while an interruptible activity run exists, |
| 165 | 168 | // so a remaining roster is stronger than checkpoint-oriented waiting. |
| 166 | 169 | // latestRun keeps the latest run's status for history presentation. |
| 167 | 170 | // A failed latest run outranks the roster, so the failure stays visible. |
| 168 | 171 | function shellRuntime(thread: OrchestrationV2ThreadShell): ThreadRuntimeSummary | null { |
| 169 | 172 | if (thread.latestRunId === null && thread.activeProviderThreadId === null) return null; |
| 170 | const parkAtIdle = (thread.pendingBackgroundTasks?.length ?? 0) > 0 && thread.status !== "failed"; | |
| 173 | const parkAtIdle = | |
| 174 | backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) && | |
| 175 | thread.status !== "failed"; | |
| 171 | 176 | const status = parkAtIdle ? "idle" : (thread.activityRunStatus ?? thread.status); |
| 172 | 177 | return { |
| 173 | 178 | status, |
packages/client-runtime/src/state/threadExecution.ts+6-3| ... | ... | @@ -236,18 +236,21 @@ export function deriveThreadRuntime( |
| 236 | 236 | if (latestRun === null && projection.thread.activeProviderThreadId === null) return null; |
| 237 | 237 | const activeRunId = |
| 238 | 238 | latestMatchingRun(projection, (run) => INTERRUPTIBLE_RUN_STATUSES.has(run.status))?.id ?? null; |
| 239 | const hasPendingBackgroundTasks = | |
| 239 | // Same rule as the shell runtime: only background work that holds the | |
| 240 | // completion parks the thread at idle; a dev server left running does not. | |
| 241 | const backgroundWorkHoldsRun = backgroundWorkHoldsCompletion( | |
| 240 | 242 | derivePendingBackgroundWork({ |
| 241 | 243 | latestRun: latestRunProjection, |
| 242 | 244 | providerThreads: projection.providerThreads, |
| 243 | 245 | turnItems: projection.turnItems, |
| 244 | 246 | activeProviderThreadId: projection.thread.activeProviderThreadId, |
| 245 | 247 | runs: projection.runs, |
| 246 | }).length > 0; | |
| 248 | }), | |
| 249 | ); | |
| 247 | 250 | return { |
| 248 | 251 | status: usageLimitedRun |
| 249 | 252 | ? "failed" |
| 250 | : hasPendingBackgroundTasks && latestRunProjection?.status !== "failed" | |
| 253 | : backgroundWorkHoldsRun && latestRunProjection?.status !== "failed" | |
| 251 | 254 | ? "idle" |
| 252 | 255 | : (activityRun?.status ?? "idle"), |
| 253 | 256 | activeRunId, |