authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-30 17:30:52-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-30 17:30:52-07:00
log9da066dbe906310671b86290b09df0b56a88ffa5
treef1c4b36a05509b4f7c91755de3bf4dac323f3207
parent6b286ae8a20ef2e18fa70b09e92f66edd859128b
signature Signed by PGP key B5690EEEBB952194

fix(server): Claude /compact no longer ends early and leaves the thread busy (#14497)

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", () => {
15571557 );
15581558 });
15591559
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
15601627 it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () => {
15611628 const harness = makeHarness();
15621629 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+33
......@@ -1692,6 +1692,27 @@ function isOverloadedResult(result: SDKResultMessage): boolean {
16921692 return result.subtype === "success" && result.api_error_status === 529;
16931693}
16941694
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 */
1705function 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
16951716/** Derives turn status and its error from the same provider result. */
16961717function resultOutcome(
16971718 result: SDKResultMessage,
......@@ -3486,6 +3507,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
34863507 }
34873508
34883509 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 }
34893522 const failureHint =
34903523 turn?.authenticationFailureMessage ??
34913524 (turn && (turn.rejectedRateLimitTypes.size > 0 || turn.latestAssistantRateLimited)