| author | |
| committer | |
| log | 9da066dbe906310671b86290b09df0b56a88ffa5 |
| tree | f1c4b36a05509b4f7c91755de3bf4dac323f3207 |
| parent | 6b286ae8a20ef2e18fa70b09e92f66edd859128b |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>2 files changed, 100 insertions(+), 0 deletions(-)
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+67| ... | @@ -1557,6 +1557,73 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -1557,6 +1557,73 @@ describe("ClaudeAdapterLive", () => { |
| 1557 | ); | 1557 | ); |
| 1558 | }); | 1558 | }); |
| 1559 | 1559 | ||
| 1560 | it.effect("keeps a turn open past the result of a Claude-initiated turn", () => { | ||
| 1561 | const harness = makeHarness(); | ||
| 1562 | return Effect.gen(function* () { | ||
| 1563 | const adapter = yield* ClaudeAdapter; | ||
| 1564 | const runtimeEventsFiber = yield* adapter.streamEvents.pipe( | ||
| 1565 | Stream.takeUntil((event) => event.type === "session.exited"), | ||
| 1566 | Stream.runCollect, | ||
| 1567 | Effect.forkChild, | ||
| 1568 | ); | ||
| 1569 | |||
| 1570 | const session = yield* adapter.startSession({ | ||
| 1571 | threadId: THREAD_ID, | ||
| 1572 | provider: ProviderDriverKind.make("claudeAgent"), | ||
| 1573 | runtimeMode: "full-access", | ||
| 1574 | }); | ||
| 1575 | const turn = yield* adapter.sendTurn({ | ||
| 1576 | threadId: session.threadId, | ||
| 1577 | input: "/compact", | ||
| 1578 | attachments: [], | ||
| 1579 | }); | ||
| 1580 | |||
| 1581 | // Recorded order after a resume: Claude first reports a background task | ||
| 1582 | // the previous process left behind, then runs the queued `/compact`. | ||
| 1583 | harness.query.emit({ | ||
| 1584 | type: "result", | ||
| 1585 | subtype: "success", | ||
| 1586 | is_error: false, | ||
| 1587 | num_turns: 0, | ||
| 1588 | origin: { kind: "task-notification" }, | ||
| 1589 | session_id: "sdk-session-1", | ||
| 1590 | uuid: "result-task-notification", | ||
| 1591 | } as unknown as SDKMessage); | ||
| 1592 | harness.query.emit({ | ||
| 1593 | type: "system", | ||
| 1594 | subtype: "compact_boundary", | ||
| 1595 | compact_metadata: { trigger: "manual", pre_tokens: 959489, post_tokens: 10107 }, | ||
| 1596 | session_id: "sdk-session-1", | ||
| 1597 | uuid: "compact-boundary", | ||
| 1598 | } as unknown as SDKMessage); | ||
| 1599 | harness.query.emit({ | ||
| 1600 | type: "result", | ||
| 1601 | subtype: "success", | ||
| 1602 | is_error: false, | ||
| 1603 | num_turns: 0, | ||
| 1604 | user_message_uuid: turn.turnId, | ||
| 1605 | user_message_uuids: [turn.turnId], | ||
| 1606 | local_command: "compact", | ||
| 1607 | session_id: "sdk-session-1", | ||
| 1608 | uuid: "result-compact", | ||
| 1609 | } as unknown as SDKMessage); | ||
| 1610 | harness.query.finish(); | ||
| 1611 | |||
| 1612 | const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); | ||
| 1613 | const compactedIndex = runtimeEvents.findIndex( | ||
| 1614 | (event) => event.type === "thread.state.changed" && event.payload.state === "compacted", | ||
| 1615 | ); | ||
| 1616 | const completedIndex = runtimeEvents.findIndex((event) => event.type === "turn.completed"); | ||
| 1617 | assert.equal(runtimeEvents.filter((event) => event.type === "turn.completed").length, 1); | ||
| 1618 | assert.equal(String(runtimeEvents[completedIndex]?.turnId), String(turn.turnId)); | ||
| 1619 | assert.equal(String(runtimeEvents[compactedIndex]?.turnId), String(turn.turnId)); | ||
| 1620 | assert.isAbove(completedIndex, compactedIndex); | ||
| 1621 | }).pipe( | ||
| 1622 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | ||
| 1623 | Effect.provide(harness.layer), | ||
| 1624 | ); | ||
| 1625 | }); | ||
| 1626 | |||
| 1560 | it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () => { | 1627 | it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () => { |
| 1561 | const harness = makeHarness(); | 1628 | const harness = makeHarness(); |
| 1562 | return Effect.gen(function* () { | 1629 | return Effect.gen(function* () { |
apps/server/src/provider/Layers/ClaudeAdapter.ts+33| ... | @@ -1692,6 +1692,27 @@ function isOverloadedResult(result: SDKResultMessage): boolean { | ... | @@ -1692,6 +1692,27 @@ function isOverloadedResult(result: SDKResultMessage): boolean { |
| 1692 | return result.subtype === "success" && result.api_error_status === 529; | 1692 | return result.subtype === "success" && result.api_error_status === 529; |
| 1693 | } | 1693 | } |
| 1694 | 1694 | ||
| 1695 | /** | ||
| 1696 | * True when a result answers a different Claude turn than the active real | ||
| 1697 | * turn. Claude runs turns of its own between user prompts (a resumed session | ||
| 1698 | * first reports background tasks the previous process left behind; peer | ||
| 1699 | * messages wake the agent), and a queued prompt waits behind them. Real turns | ||
| 1700 | * send their turn id as the prompt uuid, which newer CLIs echo in | ||
| 1701 | * `user_message_uuids`. Claude-initiated turns echo nothing and carry a | ||
| 1702 | * non-human `origin`. Results with neither field (older CLIs) still complete | ||
| 1703 | * the active turn. | ||
| 1704 | */ | ||
| 1705 | function isResultForOtherTurn(result: SDKResultMessage, turn: ClaudeTurnState): boolean { | ||
| 1706 | // A synthetic turn mirrors a Claude-initiated turn, so any result is its own. | ||
| 1707 | if (turn.synthetic) return false; | ||
| 1708 | const echoed = [ | ||
| 1709 | ...(result.user_message_uuids ?? []), | ||
| 1710 | ...(result.user_message_uuid ? [result.user_message_uuid] : []), | ||
| 1711 | ]; | ||
| 1712 | if (echoed.length > 0) return !echoed.includes(turn.turnId); | ||
| 1713 | return result.origin !== undefined && result.origin.kind !== "human"; | ||
| 1714 | } | ||
| 1715 | |||
| 1695 | /** Derives turn status and its error from the same provider result. */ | 1716 | /** Derives turn status and its error from the same provider result. */ |
| 1696 | function resultOutcome( | 1717 | function resultOutcome( |
| 1697 | result: SDKResultMessage, | 1718 | result: SDKResultMessage, |
| ... | @@ -3486,6 +3507,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -3486,6 +3507,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3486 | } | 3507 | } |
| 3487 | 3508 | ||
| 3488 | const turn = context.turnState; | 3509 | const turn = context.turnState; |
| 3510 | if (turn && isResultForOtherTurn(message, turn)) { | ||
| 3511 | // Completing here would end the user's turn before its prompt runs. A | ||
| 3512 | // `/compact` would then compact with no turn open and leave the thread | ||
| 3513 | // looking busy. | ||
| 3514 | yield* Effect.logInfo("claude.turn.result-for-other-turn", { | ||
| 3515 | threadId: context.session.threadId, | ||
| 3516 | turnId: turn.turnId, | ||
| 3517 | origin: message.origin?.kind, | ||
| 3518 | numTurns: message.num_turns, | ||
| 3519 | }); | ||
| 3520 | return; | ||
| 3521 | } | ||
| 3489 | const failureHint = | 3522 | const failureHint = |
| 3490 | turn?.authenticationFailureMessage ?? | 3523 | turn?.authenticationFailureMessage ?? |
| 3491 | (turn && (turn.rejectedRateLimitTypes.size > 0 || turn.latestAssistantRateLimited) | 3524 | (turn && (turn.rejectedRateLimitTypes.size > 0 || turn.latestAssistantRateLimited) |