1import { assert, it } from "@effect/vitest";
2import {
3 CommandId,
4 type ModelSelection,
5 NodeId,
6 RunId,
7 RunAttemptId,
8 MessageId,
9 EventId,
10 TurnItemId,
11 type OrchestrationV2Run,
12 type OrchestrationV2AppThread,
13 ProjectId,
14 ProviderInstanceId,
15 ProviderThreadId,
16 ThreadId,
17} from "@t3tools/contracts";
18import * as DateTime from "effect/DateTime";
19import * as Effect from "effect/Effect";
20import * as Layer from "effect/Layer";
21
22import {
23 makeSubagentChildThread,
24 delegatedTaskProgress,
25 subagentResultForRun,
26 makeSubagentConversationArtifacts,
27 endOrphanedNativeSubagent,
28 endRunlessRootTurns,
29} from "@t3tools/provider-core/server/subagentProjection";
30
31import * as SqlitePersistence from "../persistence/Sqlite.ts";
32import * as ProjectionStore from "./ProjectionStore.ts";
33import { resolveAutoSettlementAt } from "./ThreadSettlementService.ts";
34
35const parentThreadId = ThreadId.make("thread:subagent-snoozed-parent");
36const childThreadId = ThreadId.make("thread:subagent-awake-child");
37const parentProviderInstanceId = ProviderInstanceId.make("codex");
38const childProviderInstanceId = ProviderInstanceId.make("claude");
39const parentModelSelection = {
40 instanceId: parentProviderInstanceId,
41 model: "gpt-5.4",
42} satisfies ModelSelection;
43const childModelSelection = {
44 instanceId: childProviderInstanceId,
45 model: "claude-opus-4-1",
46} satisfies ModelSelection;
47const parentCreatedAt = DateTime.makeUnsafe("2026-07-24T09:00:00.000Z");
48const snoozedAt = DateTime.makeUnsafe("2026-07-24T09:05:00.000Z");
49const snoozedUntil = DateTime.makeUnsafe("2026-07-25T09:00:00.000Z");
50const childCreatedAt = DateTime.makeUnsafe("2026-07-24T09:10:00.000Z");
51
52function makeParentThread(): OrchestrationV2AppThread {
53 return {
54 createdBy: "user",
55 creationSource: "web",
56 id: parentThreadId,
57 projectId: ProjectId.make("project:subagent-snooze"),
58 title: "Snoozed parent",
59 providerInstanceId: parentProviderInstanceId,
60 modelSelection: parentModelSelection,
61 runtimeMode: "full-access",
62 interactionMode: "plan",
63 branch: "feature/source",
64 worktreePath: "/tmp/source-worktree",
65 activeProviderThreadId: ProviderThreadId.make("provider-thread:subagent-snoozed-parent"),
66 lineage: {
67 parentThreadId: null,
68 relationshipToParent: null,
69 rootThreadId: parentThreadId,
70 },
71 forkedFrom: null,
72 createdAt: parentCreatedAt,
73 updatedAt: snoozedAt,
74 archivedAt: null,
75 settledOverride: null,
76 settledAt: null,
77 lastVisitedAt: null,
78 snoozedUntil,
79 snoozedAt,
80 deletedAt: null,
81 historyOrigin: "v1_import",
82 };
83}
84
85it("keeps a subagent child awake when its parent thread is snoozed", () => {
86 const parentThread: OrchestrationV2AppThread = {
87 ...makeParentThread(),
88 titleRegeneration: { requestId: CommandId.make("command:parent-title"), startedAt: snoozedAt },
89 };
90 const childProviderThreadId = ProviderThreadId.make("provider-thread:subagent-awake-child");
91 const parentNodeId = NodeId.make("node:subagent-parent");
92 const childThread = makeSubagentChildThread({
93 parentThread,
94 childThreadId,
95 parentNodeId,
96 activeProviderThreadId: childProviderThreadId,
97 providerInstanceId: childProviderInstanceId,
98 modelSelection: childModelSelection,
99 title: "Awake child",
100 now: childCreatedAt,
101 createdBy: "agent",
102 creationSource: "provider",
103 });
104
105 assert.isNull(childThread.snoozedUntil);
106 assert.isNull(childThread.snoozedAt);
107 assert.isNull(childThread.titleRegeneration);
108 assert.equal(childThread.projectId, parentThread.projectId);
109 assert.equal(childThread.runtimeMode, parentThread.runtimeMode);
110 assert.equal(childThread.interactionMode, parentThread.interactionMode);
111 assert.equal(childThread.branch, parentThread.branch);
112 assert.equal(childThread.worktreePath, parentThread.worktreePath);
113 assert.equal(childThread.providerInstanceId, childProviderInstanceId);
114 assert.deepEqual(childThread.modelSelection, childModelSelection);
115 assert.equal(childThread.activeProviderThreadId, childProviderThreadId);
116 assert.isUndefined(childThread.historyOrigin);
117 assert.deepEqual(childThread.lineage, {
118 parentThreadId,
119 relationshipToParent: "subagent",
120 rootThreadId: parentThreadId,
121 });
122 assert.deepEqual(childThread.forkedFrom, {
123 type: "node",
124 nodeId: parentNodeId,
125 });
126});
127
128it.effect.each(["provider", "mcp"] as const)(
129 "a %s child does not inherit parent settlement protections and settles once its parent settles",
130 (creationSource) =>
131 Effect.gen(function* () {
132 const store = yield* ProjectionStore.ProjectionStoreV2;
133 const parentThread = {
134 ...makeParentThread(),
135 pinnedAt: parentCreatedAt,
136 pinOrderKey: "a0",
137 autoSettleDisabledAt: parentCreatedAt,
138 };
139 const childThread = makeSubagentChildThread({
140 parentThread,
141 childThreadId,
142 parentNodeId: NodeId.make("node:subagent-parent"),
143 activeProviderThreadId: null,
144 providerInstanceId: childProviderInstanceId,
145 modelSelection: childModelSelection,
146 title: "Child",
147 now: childCreatedAt,
148 createdBy: "agent",
149 creationSource,
150 });
151
152 assert.equal(parentThread.pinnedAt, parentCreatedAt);
153 assert.equal(parentThread.pinOrderKey, "a0");
154 assert.isNull(childThread.pinnedAt);
155 assert.isNull(childThread.pinOrderKey);
156 assert.isNull(childThread.autoSettleDisabledAt);
157
158 for (const thread of [parentThread, childThread]) {
159 yield* store.apply({
160 id: EventId.make(`event:${thread.id}`),
161 type: "thread.created",
162 threadId: thread.id,
163 occurredAt: thread.createdAt,
164 payload: thread,
165 });
166 }
167 const savedParent = yield* store.getThread(parentThreadId);
168 assert.deepEqual(savedParent.pinnedAt, parentCreatedAt);
169 assert.equal(savedParent.pinOrderKey, "a0");
170 assert.deepEqual(savedParent.autoSettleDisabledAt, parentCreatedAt);
171 // A subagent child waits for its parent, so settle the pinned parent first.
172 assert.deepEqual(yield* store.getSettlementCandidates(), []);
173 yield* store.apply({
174 id: EventId.make(`event:${parentThreadId}:settled`),
175 type: "thread.settled",
176 threadId: parentThreadId,
177 occurredAt: childCreatedAt,
178 payload: {
179 ...savedParent,
180 settledOverride: "settled",
181 settledAt: childCreatedAt,
182 pinnedAt: null,
183 pinOrderKey: null,
184 },
185 });
186 const candidates = yield* store.getSettlementCandidates();
187 assert.deepEqual(
188 candidates.map((candidate) => candidate.id),
189 [childThreadId],
190 );
191 const settledAt = resolveAutoSettlementAt({
192 thread: candidates[0]!,
193 pullRequest: null,
194 nowMs: DateTime.toEpochMillis(DateTime.add(childCreatedAt, { days: 4 })),
195 autoSettleAfterDays: 3,
196 autoSettleOnMerge: false,
197 });
198 assert.equal(settledAt && DateTime.formatIso(settledAt), DateTime.formatIso(childCreatedAt));
199 }).pipe(
200 Effect.provide(ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory))),
201 ),
202);
203
204function taskFixture() {
205 const projection = ProjectionStore.emptyProjection({
206 type: "thread.created",
207 id: EventId.make("task-fixture"),
208 threadId: parentThreadId,
209 occurredAt: parentCreatedAt,
210 payload: makeParentThread(),
211 });
212 const run: OrchestrationV2Run = {
213 id: RunId.make("initial"),
214 threadId: parentThreadId,
215 ordinal: 1,
216 providerInstanceId: parentProviderInstanceId,
217 modelSelection: parentModelSelection,
218 providerThreadId: null,
219 userMessageId: MessageId.make("prompt"),
220 rootNodeId: NodeId.make("root"),
221 activeAttemptId: RunAttemptId.make("attempt"),
222 status: "completed",
223 requestedAt: parentCreatedAt,
224 startedAt: parentCreatedAt,
225 completedAt: childCreatedAt,
226 checkpointId: null,
227 contextHandoffId: null,
228 };
229 return { projection: { ...projection, runs: [run] }, run };
230}
231
232it("reports the run that ended last, not the highest ordinal", () => {
233 const { projection, run } = taskFixture();
234 const ended = (ordinal: number, completedAt: string): OrchestrationV2Run => ({
235 ...run,
236 id: RunId.make(`run:${ordinal}`),
237 ordinal,
238 completedAt: DateTime.makeUnsafe(completedAt),
239 });
240 const progress = delegatedTaskProgress({
241 ...projection,
242 runs: [
243 { ...run, status: "cancelled" },
244 ended(4, "2026-07-24T10:00:00.000Z"),
245 ended(2, "2026-07-24T10:05:00.000Z"),
246 ended(3, "2026-07-24T10:10:00.000Z"),
247 ],
248 });
249 assert.equal(progress.state, "result_available");
250 assert.equal(progress.resultRun?.ordinal, 3);
251});
252
253it("waits for nested work and retains the report across monitor acknowledgements", () => {
254 const { projection, run } = taskFixture();
255 assert.equal(
256 delegatedTaskProgress({ ...projection, subagents: [{ status: "running" }] }).state,
257 "waiting_for_children",
258 );
259 assert.equal(
260 delegatedTaskProgress({ ...projection, subagents: [{ status: "completed" }] }).state,
261 "result_available",
262 );
263 assert.equal(
264 delegatedTaskProgress({ ...projection, subagents: [{ status: "idle" }] }).state,
265 "result_available",
266 );
267 for (const state of ["pending", "claimed"] as const) {
268 assert.equal(
269 delegatedTaskProgress({
270 ...projection,
271 subagents: [{ status: "idle", completionDelivery: { state, observedByRunId: null } }],
272 }).state,
273 "result_available",
274 );
275 assert.equal(
276 delegatedTaskProgress({
277 ...projection,
278 subagents: [{ status: "completed", completionDelivery: { state, observedByRunId: null } }],
279 }).state,
280 "waiting_for_children",
281 );
282 }
283 for (const state of ["acknowledged", "delivered", "disposed"] as const) {
284 assert.equal(
285 delegatedTaskProgress({
286 ...projection,
287 subagents: [{ status: "completed", completionDelivery: { state, observedByRunId: null } }],
288 }).state,
289 "result_available",
290 );
291 }
292 for (const [awaited, state] of [
293 [true, "waiting_for_children"],
294 [false, "result_available"],
295 ] as const) {
296 assert.equal(
297 delegatedTaskProgress({
298 ...projection,
299 providerThreads: [
300 {
301 pendingBackgroundTasks: [
302 { taskId: "background-audit", kind: "command", ...(awaited ? { awaited } : {}) },
303 { taskId: "log-watcher", kind: "monitor" },
304 ],
305 },
306 ],
307 }).state,
308 state,
309 );
310 }
311 const pending = {
312 ...run,
313 id: RunId.make("followup"),
314 ordinal: 2,
315 status: "queued" as const,
316 startedAt: null,
317 };
318 assert.equal(delegatedTaskProgress({ ...projection, runs: [run, pending] }).state, "working");
319 const cancelled = { ...run, status: "cancelled" as const };
320 const held = delegatedTaskProgress({
321 ...projection,
322 runs: [cancelled, { ...pending, queueHeld: true }],
323 });
324 assert.equal(held.state, "result_available");
325 assert.equal(held.resultRun?.id, cancelled.id);
326 const report = { ...pending, status: "completed" as const, startedAt: parentCreatedAt };
327 const monitor = { ...report, id: RunId.make("monitor"), ordinal: 3 };
328 const artifacts = makeSubagentConversationArtifacts({
329 messageId: MessageId.make("monitor-message"),
330 turnItemId: TurnItemId.make("monitor-item"),
331 threadId: parentThreadId,
332 rootNodeId: NodeId.make("root"),
333 providerThreadId: null,
334 providerTurnId: null,
335 nativeItemRef: null,
336 role: "user",
337 text: "Monitor update",
338 ordinal: 3,
339 now: childCreatedAt,
340 });
341 const progress = delegatedTaskProgress({
342 ...projection,
343 runs: [run, report, monitor],
344 messages: [
345 {
346 ...artifacts.message,
347 runId: monitor.id,
348 notification: {
349 source: { kind: "monitor" },
350 outcome: "updated",
351 summary: "Monitor update",
352 },
353 },
354 ],
355 });
356 assert.equal(progress.state, "result_available");
357 assert.equal(progress.resultRun?.id, report.id);
358});
359
360it("exposes the provider failure rather than a progress message from the failed run", () => {
361 const { projection, run } = taskFixture();
362 const failedRun = { ...run, status: "failed" as const };
363 const artifacts = makeSubagentConversationArtifacts({
364 messageId: MessageId.make("progress"),
365 turnItemId: TurnItemId.make("error"),
366 threadId: parentThreadId,
367 rootNodeId: NodeId.make("root"),
368 providerThreadId: null,
369 providerTurnId: null,
370 nativeItemRef: null,
371 role: "assistant",
372 text: "Starting the audit.",
373 ordinal: 1,
374 now: childCreatedAt,
375 });
376 if (artifacts.turnItem.type !== "assistant_message") throw new Error("Expected assistant item");
377 const failure = {
378 class: "unknown" as const,
379 message: "You've hit your usage limit. Try again Sep 14 at 7:20 PM.",
380 code: null,
381 retryable: false,
382 };
383 const result = subagentResultForRun(
384 {
385 ...projection,
386 messages: [{ ...artifacts.message, runId: run.id }],
387 turnItems: [{ ...artifacts.turnItem, type: "error", runId: run.id, failure }],
388 },
389 failedRun,
390 );
391 assert.equal(result.text, failure.message);
392 assert.equal(result.turnItemId, artifacts.turnItem.id);
393 assert.isNull(result.messageId);
394});
395
396it("keeps the final report of a run that failed after its turn ended", () => {
397 const { projection, run } = taskFixture();
398 const failedRun = { ...run, status: "failed" as const };
399 const artifacts = makeSubagentConversationArtifacts({
400 messageId: MessageId.make("report"),
401 turnItemId: TurnItemId.make("report"),
402 threadId: parentThreadId,
403 rootNodeId: NodeId.make("root"),
404 providerThreadId: null,
405 providerTurnId: null,
406 nativeItemRef: null,
407 role: "assistant",
408 text: "Review complete: no blocking issues.",
409 ordinal: 1,
410 now: childCreatedAt,
411 });
412 if (artifacts.turnItem.type !== "assistant_message") throw new Error("Expected assistant item");
413 const failure = {
414 class: "unknown" as const,
415 message: "Connection failed repeatedly",
416 code: null,
417 retryable: false,
418 };
419 const result = subagentResultForRun(
420 {
421 ...projection,
422 messages: [{ ...artifacts.message, runId: run.id }],
423 turnItems: [
424 { ...artifacts.turnItem, runId: run.id },
425 {
426 ...artifacts.turnItem,
427 id: TurnItemId.make("error"),
428 ordinal: 2,
429 type: "error",
430 runId: run.id,
431 failure,
432 },
433 ],
434 },
435 failedRun,
436 );
437 assert.include(result.text, failure.message);
438 assert.include(result.text, "Review complete: no blocking issues.");
439});
440
441it("orders a task's assistant messages by turn item, not by timestamps", () => {
442 const { projection, run } = taskFixture();
443 const at = DateTime.makeUnsafe("2026-07-24T10:05:00.000Z");
444 const message = (id: string, text: string, ordinal: number) => {
445 const artifacts = makeSubagentConversationArtifacts({
446 messageId: MessageId.make(id),
447 turnItemId: TurnItemId.make(id),
448 threadId: parentThreadId,
449 rootNodeId: NodeId.make("root"),
450 providerThreadId: null,
451 providerTurnId: null,
452 nativeItemRef: null,
453 role: "assistant",
454 text,
455 ordinal,
456 now: at,
457 });
458 return {
459 message: { ...artifacts.message, runId: run.id },
460 turnItem: { ...artifacts.turnItem, runId: run.id },
461 };
462 };
463 const progress = message("progress", "Starting step 0.", 1);
464 const report = message("report", "All steps passed.", 2);
465 const result = subagentResultForRun(
466 {
467 ...projection,
468 messages: [progress.message, report.message],
469 turnItems: [report.turnItem, progress.turnItem],
470 },
471 run,
472 );
473 assert.equal(result.text, "All steps passed.");
474 assert.equal(result.messageId, report.message.id);
475 assert.equal(result.turnItemId, report.turnItem.id);
476 const reported = { runAttemptId: run.activeAttemptId, ordinal: 1, model: "gpt-5.4-mini" };
477 assert.equal(
478 subagentResultForRun(
479 { ...projection, messages: [report.message], providerTurns: [reported as never] },
480 run,
481 ).text,
482 "Requested model gpt-5.4; the provider reported running gpt-5.4-mini.\n\nAll steps passed.",
483 );
484});
485
486it.effect("ends an orphaned native subagent's node when only its record already ended", () =>
487 Effect.gen(function* () {
488 const id = NodeId.make("node:orphaned-native-subagent");
489 const subagent = { id, origin: "provider_native", status: "running", runId: null };
490 const events = yield* endOrphanedNativeSubagent({
491 projection: {
492 subagents: [subagent],
493 nodes: [{ id, status: "running", runId: null }],
494 turnItems: [],
495 } as never,
496 subagentId: id,
497 status: "cancelled",
498 now: yield* DateTime.now,
499 emitted: [
500 { type: "subagent.updated", payload: { ...subagent, status: "cancelled" } },
501 ] as never,
502 allocateEventId: () => Effect.succeed(EventId.make("event:orphaned-native-subagent")),
503 });
504 assert.deepEqual(
505 events.map((event) => [event.type, event.type === "node.updated" && event.payload.status]),
506 [["node.updated", "cancelled"]],
507 );
508 }),
509);
510
511it.effect("ends a runless root turn's items when its node already ended", () =>
512 Effect.gen(function* () {
513 const root = {
514 id: NodeId.make("node:runless-root"),
515 kind: "root_turn",
516 status: "running",
517 runId: null,
518 };
519 const item = (id: string) => ({ id, nodeId: root.id, runId: null, status: "running" });
520 const events = yield* endRunlessRootTurns({
521 threadId: childThreadId,
522 providerInstanceId: parentProviderInstanceId,
523 projection: {
524 nodes: [root],
525 providerTurns: [],
526 turnItems: [item("turn-item:command"), item("turn-item:reasoning")],
527 } as never,
528 status: "cancelled",
529 now: yield* DateTime.now,
530 emitted: [
531 {
532 type: "turn-item.updated",
533 payload: { ...item("turn-item:command"), status: "cancelled" },
534 },
535 { type: "node.updated", payload: { ...root, status: "cancelled" } },
536 ] as never,
537 allocateEventId: () => Effect.succeed(EventId.make("event:runless-root")),
538 });
539 assert.deepEqual(
540 events.map(
541 (event) => `${event.type} ${event.type === "turn-item.updated" && event.payload.id}`,
542 ),
543 ["turn-item.updated turn-item:reasoning"],
544 );
545 }),
546);