| author | |
| committer | |
| log | 5bf19d12b0fd54fc9999579869bcf264bc555874 |
| tree | c7a054a280de3a14c39f5356cca677629b31ce65 |
| parent | d1034d62b28e19c19240085566a39a92c5f9b466 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>2 files changed, 88 insertions(+), 7 deletions(-)
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+64| ... | ... | @@ -2004,6 +2004,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2004 | 2004 | uuid: "00000000-0000-4000-8000-000000000107", |
| 2005 | 2005 | text: WAKE_ASSISTANT_TEXT, |
| 2006 | 2006 | }); |
| 2007 | // The CLI opens the wake turn with `init`, seconds before its first output. | |
| 2008 | const wakeTurnInit = claudeSdkFrame({ | |
| 2009 | type: "system", | |
| 2010 | subtype: "init", | |
| 2011 | uuid: "00000000-0000-4000-8000-000000000110", | |
| 2012 | session_id: WAKE_NATIVE_SESSION, | |
| 2013 | }); | |
| 2007 | 2014 | const wakeResult = makeResultFrame({ |
| 2008 | 2015 | uuid: "00000000-0000-4000-8000-000000000104", |
| 2009 | 2016 | result: WAKE_RESULT_TEXT, |
| ... | ... | @@ -4641,6 +4648,63 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 4641 | 4648 | ), |
| 4642 | 4649 | ); |
| 4643 | 4650 | |
| 4651 | it.effect("starts the wake run when Claude opens the wake turn, before its output", () => | |
| 4652 | Effect.scoped( | |
| 4653 | Effect.gen(function* () { | |
| 4654 | const harness = yield* makeWakeHarness; | |
| 4655 | const now = yield* DateTime.now; | |
| 4656 | ||
| 4657 | yield* harness.runtime.startTurn( | |
| 4658 | makeClaudeTestTurnInput({ | |
| 4659 | threadId: harness.threadId, | |
| 4660 | providerThread: harness.providerThread, | |
| 4661 | now, | |
| 4662 | attemptId: RunAttemptId.make("attempt-claude-wake-init-1"), | |
| 4663 | text: "Run the build in the background.", | |
| 4664 | attachments: [], | |
| 4665 | }), | |
| 4666 | ); | |
| 4667 | yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); | |
| 4668 | yield* Queue.offer(harness.sdkMessages, turnOneResult); | |
| 4669 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); | |
| 4670 | ||
| 4671 | yield* harness.offerAndWait(wakeNotification); | |
| 4672 | assert.lengthOf(harness.continuationRequests, 0); | |
| 4673 | yield* harness.offerAndWait(wakeTurnInit); | |
| 4674 | assert.lengthOf(harness.continuationRequests, 1); | |
| 4675 | assert.equal(harness.continuationRequests[0]?.detail, WAKE_SUMMARY); | |
| 4676 | ||
| 4677 | // The run attaches while Claude still thinks: only the notification | |
| 4678 | // and `init` are buffered, so the run waits for the turn's output. | |
| 4679 | yield* harness.runtime.startTurn( | |
| 4680 | makeClaudeTestTurnInput({ | |
| 4681 | threadId: harness.threadId, | |
| 4682 | providerThread: harness.providerThread, | |
| 4683 | now, | |
| 4684 | attemptId: RunAttemptId.make("attempt-claude-wake-init-2"), | |
| 4685 | text: "Background task completed.", | |
| 4686 | attachments: [], | |
| 4687 | providerTurnOrdinal: 2, | |
| 4688 | messageCreatedBy: "agent", | |
| 4689 | messageCreationSource: "provider", | |
| 4690 | }), | |
| 4691 | ); | |
| 4692 | assert.lengthOf(harness.terminalEvents(), 1); | |
| 4693 | ||
| 4694 | yield* harness.offerAndWait(wakeAssistant); | |
| 4695 | yield* harness.offerAndWait(wakeResult); | |
| 4696 | yield* awaitUntil(() => harness.terminalEvents().length === 2, "wake run terminal"); | |
| 4697 | assert.equal(harness.terminalEvents()[1]?.status, "completed"); | |
| 4698 | assert.lengthOf(harness.continuationRequests, 1); | |
| 4699 | assert.isTrue( | |
| 4700 | harness.events.some( | |
| 4701 | (event) => event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT, | |
| 4702 | ), | |
| 4703 | ); | |
| 4704 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), | |
| 4705 | ), | |
| 4706 | ); | |
| 4707 | ||
| 4644 | 4708 | it.effect("does not offer a continuation for notification-only opaque work", () => |
| 4645 | 4709 | Effect.scoped( |
| 4646 | 4710 | Effect.gen(function* () { |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+24-7| ... | ... | @@ -1739,6 +1739,13 @@ function isClaudeBackgroundTasksChangedMessage(message: SDKMessage): boolean { |
| 1739 | 1739 | ); |
| 1740 | 1740 | } |
| 1741 | 1741 | |
| 1742 | // Claude opens every turn it runs with a root `init` frame. Outside a T3 turn | |
| 1743 | // that turn is a wake, and `init` comes 20-110 ms after the notification that | |
| 1744 | // caused it but seconds before its first output (model thinking time). | |
| 1745 | function isClaudeTurnStartMessage(message: SDKMessage): boolean { | |
| 1746 | return message.type === "system" && message.subtype === "init"; | |
| 1747 | } | |
| 1748 | ||
| 1742 | 1749 | function claudePendingBackgroundTasksFromRoster( |
| 1743 | 1750 | roster: ReadonlyMap<string, OrchestrationV2PendingBackgroundTask>, |
| 1744 | 1751 | ): ReadonlyArray<OrchestrationV2PendingBackgroundTask> { |
| ... | ... | @@ -5095,12 +5102,14 @@ export function makeClaudeAdapterV2( |
| 5095 | 5102 | // replay them to the turn that was still starting when they |
| 5096 | 5103 | // arrived; the offer gate below keeps them from requesting a |
| 5097 | 5104 | // continuation on their own. |
| 5105 | const isWakeTurnStart = isClaudeTurnStartMessage(message); | |
| 5098 | 5106 | const isWakeEvidence = |
| 5099 | 5107 | isPendingTaskNotification || |
| 5100 | 5108 | isPendingSubagentNotification || |
| 5101 | 5109 | isNestedSubagentNotification || |
| 5102 | 5110 | isKnownSubagentTaskStarted || |
| 5103 | 5111 | isNewSubagentTaskStarted || |
| 5112 | isWakeTurnStart || | |
| 5104 | 5113 | message.type === "assistant" || |
| 5105 | 5114 | message.type === "user" || |
| 5106 | 5115 | message.type === "result" || |
| ... | ... | @@ -5157,12 +5166,14 @@ export function makeClaudeAdapterV2( |
| 5157 | 5166 | } |
| 5158 | 5167 | // A terminal task notification can clear the Waiting roster without |
| 5159 | 5168 | // Claude dequeuing it into a native model turn. Buffer it for replay, |
| 5160 | // but do not open an opaque-task continuation until native user, | |
| 5161 | // assistant, or result output proves that Claude actually began the | |
| 5162 | // wake turn. Subagent notifications retain their existing immediate | |
| 5163 | // offer because their projected lifecycle owns the continuation. | |
| 5164 | // Only root output proves it: a background subagent keeps streaming | |
| 5165 | // its own frames while the root is idle. | |
| 5169 | // but do not open an opaque-task continuation until the turn's `init`, | |
| 5170 | // or native user, assistant, or result output, proves that Claude | |
| 5171 | // actually began the wake turn. `init` comes first, so the thread | |
| 5172 | // shows working while Claude thinks instead of looking finished. | |
| 5173 | // Subagent notifications retain their existing immediate offer | |
| 5174 | // because their projected lifecycle owns the continuation. Only | |
| 5175 | // root frames prove it: a background subagent keeps streaming its | |
| 5176 | // own frames while the root is idle. | |
| 5166 | 5177 | const buffered = (yield* Ref.get(wakeBuffers)).get(wakeInput.nativeThreadId); |
| 5167 | 5178 | const hasBufferedNotification = |
| 5168 | 5179 | buffered?.messages.some( |
| ... | ... | @@ -5175,6 +5186,7 @@ export function makeClaudeAdapterV2( |
| 5175 | 5186 | if ( |
| 5176 | 5187 | !isPendingSubagentNotification && |
| 5177 | 5188 | !isNativeOpaqueWakeFrame && |
| 5189 | !isWakeTurnStart && | |
| 5178 | 5190 | message.type !== "result" |
| 5179 | 5191 | ) { |
| 5180 | 5192 | return; |
| ... | ... | @@ -7135,8 +7147,13 @@ export function makeClaudeAdapterV2( |
| 7135 | 7147 | yield* handleSdkMessage({ query: querySession.query, message: lastResult }); |
| 7136 | 7148 | return; |
| 7137 | 7149 | } |
| 7150 | // A drained `init` means Claude began the wake turn, so its output | |
| 7151 | // may still be on the way: stay open for it. | |
| 7138 | 7152 | const hasNativeWakeFrame = drained.some( |
| 7139 | (entry) => entry.type === "user" || entry.type === "assistant", | |
| 7153 | (entry) => | |
| 7154 | entry.type === "user" || | |
| 7155 | entry.type === "assistant" || | |
| 7156 | isClaudeTurnStartMessage(entry), | |
| 7140 | 7157 | ); |
| 7141 | 7158 | if (hasOpaqueTaskNotification && !hasNativeWakeFrame) { |
| 7142 | 7159 | const completedAt = yield* DateTime.now; |