Reset snooze metadata when creating forks and provider subagent children, with focused regressions for both inheritance paths.
Showing the changes to apps/server (4 of 4 files). Show all
Unified · Split
| @@ -0,0 +1,99 @@ | |||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | type ModelSelection, | ||
| 4 | NodeId, | ||
| 5 | type OrchestrationV2AppThread, | ||
| 6 | ProjectId, | ||
| 7 | ProviderInstanceId, | ||
| 8 | ProviderThreadId, | ||
| 9 | ThreadId, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import * as DateTime from "effect/DateTime"; | ||
| 12 | |||
| 13 | import { makeSubagentChildThread } from "./SubagentProjection.ts"; | ||
| 14 | |||
| 15 | const parentThreadId = ThreadId.make("thread:subagent-snoozed-parent"); | ||
| 16 | const childThreadId = ThreadId.make("thread:subagent-awake-child"); | ||
| 17 | const parentProviderInstanceId = ProviderInstanceId.make("codex"); | ||
| 18 | const childProviderInstanceId = ProviderInstanceId.make("claude"); | ||
| 19 | const parentModelSelection = { | ||
| 20 | instanceId: parentProviderInstanceId, | ||
| 21 | model: "gpt-5.4", | ||
| 22 | } satisfies ModelSelection; | ||
| 23 | const childModelSelection = { | ||
| 24 | instanceId: childProviderInstanceId, | ||
| 25 | model: "claude-opus-4-1", | ||
| 26 | } satisfies ModelSelection; | ||
| 27 | const parentCreatedAt = DateTime.makeUnsafe("2026-07-24T09:00:00.000Z"); | ||
| 28 | const snoozedAt = DateTime.makeUnsafe("2026-07-24T09:05:00.000Z"); | ||
| 29 | const snoozedUntil = DateTime.makeUnsafe("2026-07-25T09:00:00.000Z"); | ||
| 30 | const childCreatedAt = DateTime.makeUnsafe("2026-07-24T09:10:00.000Z"); | ||
| 31 | |||
| 32 | function makeParentThread(): OrchestrationV2AppThread { | ||
| 33 | return { | ||
| 34 | createdBy: "user", | ||
| 35 | creationSource: "web", | ||
| 36 | id: parentThreadId, | ||
| 37 | projectId: ProjectId.make("project:subagent-snooze"), | ||
| 38 | title: "Snoozed parent", | ||
| 39 | providerInstanceId: parentProviderInstanceId, | ||
| 40 | modelSelection: parentModelSelection, | ||
| 41 | runtimeMode: "full-access", | ||
| 42 | interactionMode: "plan", | ||
| 43 | branch: "feature/source", | ||
| 44 | worktreePath: "/tmp/source-worktree", | ||
| 45 | activeProviderThreadId: ProviderThreadId.make("provider-thread:subagent-snoozed-parent"), | ||
| 46 | lineage: { | ||
| 47 | parentThreadId: null, | ||
| 48 | relationshipToParent: null, | ||
| 49 | rootThreadId: parentThreadId, | ||
| 50 | }, | ||
| 51 | forkedFrom: null, | ||
| 52 | createdAt: parentCreatedAt, | ||
| 53 | updatedAt: snoozedAt, | ||
| 54 | archivedAt: null, | ||
| 55 | settledOverride: null, | ||
| 56 | settledAt: null, | ||
| 57 | snoozedUntil, | ||
| 58 | snoozedAt, | ||
| 59 | deletedAt: null, | ||
| 60 | }; | ||
| 61 | } | ||
| 62 | |||
| 63 | it("keeps a subagent child awake when its parent thread is snoozed", () => { | ||
| 64 | const parentThread = makeParentThread(); | ||
| 65 | const childProviderThreadId = ProviderThreadId.make("provider-thread:subagent-awake-child"); | ||
| 66 | const parentNodeId = NodeId.make("node:subagent-parent"); | ||
| 67 | const childThread = makeSubagentChildThread({ | ||
| 68 | parentThread, | ||
| 69 | childThreadId, | ||
| 70 | parentNodeId, | ||
| 71 | activeProviderThreadId: childProviderThreadId, | ||
| 72 | providerInstanceId: childProviderInstanceId, | ||
| 73 | modelSelection: childModelSelection, | ||
| 74 | title: "Awake child", | ||
| 75 | now: childCreatedAt, | ||
| 76 | createdBy: "agent", | ||
| 77 | creationSource: "provider", | ||
| 78 | }); | ||
| 79 | |||
| 80 | assert.isNull(childThread.snoozedUntil); | ||
| 81 | assert.isNull(childThread.snoozedAt); | ||
| 82 | assert.equal(childThread.projectId, parentThread.projectId); | ||
| 83 | assert.equal(childThread.runtimeMode, parentThread.runtimeMode); | ||
| 84 | assert.equal(childThread.interactionMode, parentThread.interactionMode); | ||
| 85 | assert.equal(childThread.branch, parentThread.branch); | ||
| 86 | assert.equal(childThread.worktreePath, parentThread.worktreePath); | ||
| 87 | assert.equal(childThread.providerInstanceId, childProviderInstanceId); | ||
| 88 | assert.deepEqual(childThread.modelSelection, childModelSelection); | ||
| 89 | assert.equal(childThread.activeProviderThreadId, childProviderThreadId); | ||
| 90 | assert.deepEqual(childThread.lineage, { | ||
| 91 | parentThreadId, | ||
| 92 | relationshipToParent: "subagent", | ||
| 93 | rootThreadId: parentThreadId, | ||
| 94 | }); | ||
| 95 | assert.deepEqual(childThread.forkedFrom, { | ||
| 96 | type: "node", | ||
| 97 | nodeId: parentNodeId, | ||
| 98 | }); | ||
| 99 | }); | ||
| @@ -70,6 +70,10 @@ export function makeSubagentChildThread(input: { · 69 unmodified lines | |||
| 70 | createdAt: input.now, | 70 | createdAt: input.now, |
| 71 | updatedAt: input.now, | 71 | updatedAt: input.now, |
| 72 | archivedAt: null, | 72 | archivedAt: null, |
| 73 | settledOverride: null, | ||
| 74 | settledAt: null, | ||
| 75 | snoozedUntil: null, | ||
| 76 | snoozedAt: null, | ||
| 73 | deletedAt: null, | 77 | deletedAt: null, |
| 74 | }; | 78 | }; |
| 75 | } | 79 | } |
| @@ -0,0 +1,147 @@ | |||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | ContextTransferId, | ||
| 4 | MessageId, | ||
| 5 | type ModelSelection, | ||
| 6 | type OrchestrationV2AppThread, | ||
| 7 | type OrchestrationV2Run, | ||
| 8 | type OrchestrationV2ThreadProjection, | ||
| 9 | ProjectId, | ||
| 10 | ProviderInstanceId, | ||
| 11 | ProviderThreadId, | ||
| 12 | RunId, | ||
| 13 | ThreadId, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | import * as DateTime from "effect/DateTime"; | ||
| 16 | import * as Effect from "effect/Effect"; | ||
| 17 | |||
| 18 | import { layer, ThreadForkServiceV2 } from "./ThreadForkService.ts"; | ||
| 19 | |||
| 20 | const sourceThreadId = ThreadId.make("thread:fork-snoozed-source"); | ||
| 21 | const targetThreadId = ThreadId.make("thread:fork-awake-target"); | ||
| 22 | const sourceRunId = RunId.make("run:fork-snoozed-source"); | ||
| 23 | const providerInstanceId = ProviderInstanceId.make("codex"); | ||
| 24 | const modelSelection = { | ||
| 25 | instanceId: providerInstanceId, | ||
| 26 | model: "gpt-5.4", | ||
| 27 | } satisfies ModelSelection; | ||
| 28 | const sourceCreatedAt = DateTime.makeUnsafe("2026-07-24T09:00:00.000Z"); | ||
| 29 | const snoozedAt = DateTime.makeUnsafe("2026-07-24T09:05:00.000Z"); | ||
| 30 | const snoozedUntil = DateTime.makeUnsafe("2026-07-25T09:00:00.000Z"); | ||
| 31 | const forkCreatedAt = DateTime.makeUnsafe("2026-07-24T09:10:00.000Z"); | ||
| 32 | |||
| 33 | function makeSourceThread(): OrchestrationV2AppThread { | ||
| 34 | return { | ||
| 35 | createdBy: "user", | ||
| 36 | creationSource: "web", | ||
| 37 | id: sourceThreadId, | ||
| 38 | projectId: ProjectId.make("project:fork-snooze"), | ||
| 39 | title: "Snoozed source", | ||
| 40 | providerInstanceId, | ||
| 41 | modelSelection, | ||
| 42 | runtimeMode: "full-access", | ||
| 43 | interactionMode: "plan", | ||
| 44 | branch: "feature/source", | ||
| 45 | worktreePath: "/tmp/source-worktree", | ||
| 46 | activeProviderThreadId: ProviderThreadId.make("provider-thread:fork-snoozed-source"), | ||
| 47 | lineage: { | ||
| 48 | parentThreadId: null, | ||
| 49 | relationshipToParent: null, | ||
| 50 | rootThreadId: sourceThreadId, | ||
| 51 | }, | ||
| 52 | forkedFrom: null, | ||
| 53 | createdAt: sourceCreatedAt, | ||
| 54 | updatedAt: snoozedAt, | ||
| 55 | archivedAt: null, | ||
| 56 | settledOverride: null, | ||
| 57 | settledAt: null, | ||
| 58 | snoozedUntil, | ||
| 59 | snoozedAt, | ||
| 60 | deletedAt: null, | ||
| 61 | }; | ||
| 62 | } | ||
| 63 | |||
| 64 | function makeCompletedSourceRun(): OrchestrationV2Run { | ||
| 65 | return { | ||
| 66 | id: sourceRunId, | ||
| 67 | threadId: sourceThreadId, | ||
| 68 | ordinal: 1, | ||
| 69 | providerInstanceId, | ||
| 70 | modelSelection, | ||
| 71 | providerThreadId: ProviderThreadId.make("provider-thread:fork-snoozed-source"), | ||
| 72 | userMessageId: MessageId.make("message:fork-snoozed-source"), | ||
| 73 | rootNodeId: null, | ||
| 74 | activeAttemptId: null, | ||
| 75 | status: "completed", | ||
| 76 | queuePosition: null, | ||
| 77 | requestedAt: sourceCreatedAt, | ||
| 78 | startedAt: sourceCreatedAt, | ||
| 79 | completedAt: snoozedAt, | ||
| 80 | checkpointId: null, | ||
| 81 | contextHandoffId: null, | ||
| 82 | }; | ||
| 83 | } | ||
| 84 | |||
| 85 | it.effect("keeps a fork awake when its source thread is snoozed", () => | ||
| 86 | Effect.gen(function* () { | ||
| 87 | const sourceThread = makeSourceThread(); | ||
| 88 | const sourceRun = makeCompletedSourceRun(); | ||
| 89 | const sourceProjection: OrchestrationV2ThreadProjection = { | ||
| 90 | thread: sourceThread, | ||
| 91 | runs: [sourceRun], | ||
| 92 | attempts: [], | ||
| 93 | nodes: [], | ||
| 94 | subagents: [], | ||
| 95 | providerSessions: [], | ||
| 96 | providerThreads: [], | ||
| 97 | providerTurns: [], | ||
| 98 | runtimeRequests: [], | ||
| 99 | messages: [], | ||
| 100 | plans: [], | ||
| 101 | turnItems: [], | ||
| 102 | checkpointScopes: [], | ||
| 103 | checkpoints: [], | ||
| 104 | contextHandoffs: [], | ||
| 105 | contextTransfers: [], | ||
| 106 | visibleTurnItems: [], | ||
| 107 | updatedAt: snoozedAt, | ||
| 108 | }; | ||
| 109 | const service = yield* ThreadForkServiceV2; | ||
| 110 | const result = yield* service.plan({ | ||
| 111 | sourceProjection, | ||
| 112 | sourceRun, | ||
| 113 | sourceProviderThread: undefined, | ||
| 114 | canonicalSourcePoint: { | ||
| 115 | threadId: sourceThreadId, | ||
| 116 | runId: sourceRunId, | ||
| 117 | }, | ||
| 118 | transferId: ContextTransferId.make("context-transfer:fork-snoozed-source"), | ||
| 119 | targetThreadId, | ||
| 120 | title: "Awake fork", | ||
| 121 | createdBy: "user", | ||
| 122 | creationSource: "mobile", | ||
| 123 | createdAt: forkCreatedAt, | ||
| 124 | }); | ||
| 125 | |||
| 126 | assert.isNull(result.targetThread.snoozedUntil); | ||
| 127 | assert.isNull(result.targetThread.snoozedAt); | ||
| 128 | assert.equal(result.targetThread.projectId, sourceThread.projectId); | ||
| 129 | assert.equal(result.targetThread.providerInstanceId, sourceThread.providerInstanceId); | ||
| 130 | assert.deepEqual(result.targetThread.modelSelection, sourceThread.modelSelection); | ||
| 131 | assert.equal(result.targetThread.runtimeMode, sourceThread.runtimeMode); | ||
| 132 | assert.equal(result.targetThread.interactionMode, sourceThread.interactionMode); | ||
| 133 | assert.equal(result.targetThread.branch, sourceThread.branch); | ||
| 134 | assert.equal(result.targetThread.worktreePath, sourceThread.worktreePath); | ||
| 135 | assert.isNull(result.targetThread.activeProviderThreadId); | ||
| 136 | assert.deepEqual(result.targetThread.lineage, { | ||
| 137 | parentThreadId: sourceThreadId, | ||
| 138 | relationshipToParent: "fork", | ||
| 139 | rootThreadId: sourceThreadId, | ||
| 140 | }); | ||
| 141 | assert.deepEqual(result.targetThread.forkedFrom, { | ||
| 142 | type: "run", | ||
| 143 | threadId: sourceThreadId, | ||
| 144 | runId: sourceRunId, | ||
| 145 | }); | ||
| 146 | }).pipe(Effect.provide(layer)), | ||
| 147 | ); | ||
| @@ -82,6 +82,10 @@ export const layer: Layer.Layer<ThreadForkServiceV2> = Layer.succeed( · 81 unmodified lines | |||
| 82 | createdAt: input.createdAt, | 82 | createdAt: input.createdAt, |
| 83 | updatedAt: input.createdAt, | 83 | updatedAt: input.createdAt, |
| 84 | archivedAt: null, | 84 | archivedAt: null, |
| 85 | settledOverride: null, | ||
| 86 | settledAt: null, | ||
| 87 | snoozedUntil: null, | ||
| 88 | snoozedAt: null, | ||
| 85 | deletedAt: null, | 89 | deletedAt: null, |
| 86 | }; | 90 | }; |
| 87 | const transfer: OrchestrationV2ContextTransfer = { | 91 | const transfer: OrchestrationV2ContextTransfer = { |