| ... | @@ -315,6 +315,57 @@ async function readPromptMessages( | ... | @@ -315,6 +315,57 @@ async function readPromptMessages( |
| 315 | const THREAD_ID = ThreadId.make("thread-claude-1"); | 315 | const THREAD_ID = ThreadId.make("thread-claude-1"); |
| 316 | const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume"); | 316 | const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume"); |
| 317 | const SYNTHETIC_SUBAGENT_MODEL = "claude-synthetic-subagent[expanded]"; | 317 | const SYNTHETIC_SUBAGENT_MODEL = "claude-synthetic-subagent[expanded]"; |
| | 318 | const CLAUDE_ORIGINAL_SESSION_ID = "550e8400-e29b-41d4-a716-446655440010"; |
| | 319 | const CLAUDE_FORK_SESSION_ID = "550e8400-e29b-41d4-a716-446655440020"; |
| | 320 | |
| | 321 | function claudeHistoryMessage(input: { |
| | 322 | readonly type: "user" | "assistant" | "system"; |
| | 323 | readonly uuid: string; |
| | 324 | readonly sessionId?: string; |
| | 325 | readonly content?: unknown; |
| | 326 | readonly parentToolUseId?: string | null; |
| | 327 | }) { |
| | 328 | const content = |
| | 329 | input.content ?? |
| | 330 | (input.type === "user" ? "prompt" : input.type === "assistant" ? [] : { subtype: "init" }); |
| | 331 | return { |
| | 332 | type: input.type, |
| | 333 | uuid: input.uuid, |
| | 334 | session_id: input.sessionId ?? CLAUDE_ORIGINAL_SESSION_ID, |
| | 335 | parent_tool_use_id: input.parentToolUseId ?? null, |
| | 336 | parent_agent_id: null, |
| | 337 | message: input.type === "system" ? content : { content }, |
| | 338 | }; |
| | 339 | } |
| | 340 | |
| | 341 | const sendCompletedClaudeTurn = ( |
| | 342 | adapter: ClaudeAdapterShape, |
| | 343 | harness: ReturnType<typeof makeHarness>, |
| | 344 | threadId: ThreadId, |
| | 345 | input: string, |
| | 346 | ) => |
| | 347 | Effect.gen(function* () { |
| | 348 | const turn = yield* adapter.sendTurn({ |
| | 349 | threadId, |
| | 350 | input, |
| | 351 | attachments: [], |
| | 352 | }); |
| | 353 | const completedFiber = yield* Stream.filter( |
| | 354 | adapter.streamEvents, |
| | 355 | (event) => event.type === "turn.completed", |
| | 356 | ).pipe(Stream.runHead, Effect.forkChild); |
| | 357 | harness.queries.at(-1)!.emit({ |
| | 358 | type: "result", |
| | 359 | subtype: "success", |
| | 360 | is_error: false, |
| | 361 | errors: [], |
| | 362 | session_id: CLAUDE_ORIGINAL_SESSION_ID, |
| | 363 | uuid: `result-${turn.turnId}`, |
| | 364 | } as unknown as SDKMessage); |
| | 365 | const completed = yield* Fiber.join(completedFiber); |
| | 366 | assert.equal(completed._tag, "Some"); |
| | 367 | return turn; |
| | 368 | }); |
| 318 | | 369 | |
| 319 | describe("ClaudeAdapterLive", () => { | 370 | describe("ClaudeAdapterLive", () => { |
| 320 | it.effect("returns validation error for non-claude provider on startSession", () => { | 371 | it.effect("returns validation error for non-claude provider on startSession", () => { |
| ... | @@ -6484,6 +6535,589 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -6484,6 +6535,589 @@ describe("ClaudeAdapterLive", () => { |
| 6484 | ); | 6535 | ); |
| 6485 | }); | 6536 | }); |
| 6486 | | 6537 | |
| | 6538 | it.effect("rewinds Claude history when the fork omits retained system messages", () => { |
| | 6539 | const forkCalls: Array<Parameters<NonNullable<ClaudeAdapterLiveOptions["forkSession"]>>> = []; |
| | 6540 | let firstTurnId = ""; |
| | 6541 | let secondTurnId = ""; |
| | 6542 | const harness = makeHarness({ |
| | 6543 | forkSession: async (...args) => { |
| | 6544 | forkCalls.push(args); |
| | 6545 | return { sessionId: CLAUDE_FORK_SESSION_ID }; |
| | 6546 | }, |
| | 6547 | getSessionMessages: async (sessionId) => { |
| | 6548 | if (sessionId === CLAUDE_FORK_SESSION_ID) { |
| | 6549 | return [ |
| | 6550 | claudeHistoryMessage({ |
| | 6551 | type: "user", |
| | 6552 | uuid: `fork-${firstTurnId}`, |
| | 6553 | sessionId, |
| | 6554 | content: "first", |
| | 6555 | }), |
| | 6556 | claudeHistoryMessage({ |
| | 6557 | type: "assistant", |
| | 6558 | uuid: "fork-assistant-1", |
| | 6559 | sessionId, |
| | 6560 | }), |
| | 6561 | ]; |
| | 6562 | } |
| | 6563 | return [ |
| | 6564 | claudeHistoryMessage({ type: "system", uuid: "system-init" }), |
| | 6565 | claudeHistoryMessage({ type: "user", uuid: firstTurnId, content: "first" }), |
| | 6566 | claudeHistoryMessage({ type: "assistant", uuid: "assistant-1" }), |
| | 6567 | claudeHistoryMessage({ |
| | 6568 | type: "system", |
| | 6569 | uuid: "compact-boundary", |
| | 6570 | content: { subtype: "compact_boundary" }, |
| | 6571 | }), |
| | 6572 | claudeHistoryMessage({ type: "user", uuid: secondTurnId, content: "second" }), |
| | 6573 | claudeHistoryMessage({ type: "assistant", uuid: "assistant-2" }), |
| | 6574 | ]; |
| | 6575 | }, |
| | 6576 | }); |
| | 6577 | return Effect.gen(function* () { |
| | 6578 | const adapter = yield* ClaudeAdapter; |
| | 6579 | const session = yield* adapter.startSession({ |
| | 6580 | threadId: THREAD_ID, |
| | 6581 | provider: ProviderDriverKind.make("claudeAgent"), |
| | 6582 | runtimeMode: "full-access", |
| | 6583 | }); |
| | 6584 | firstTurnId = (yield* sendCompletedClaudeTurn(adapter, harness, session.threadId, "first")) |
| | 6585 | .turnId; |
| | 6586 | secondTurnId = (yield* sendCompletedClaudeTurn(adapter, harness, session.threadId, "second")) |
| | 6587 | .turnId; |
| | 6588 | |
| | 6589 | const snapshot = yield* adapter.rollbackThread(session.threadId, 1); |
| | 6590 | assert.equal(snapshot.turns.length, 1); |
| | 6591 | assert.deepEqual(forkCalls, [ |
| | 6592 | [CLAUDE_ORIGINAL_SESSION_ID, { upToMessageId: "compact-boundary" }], |
| | 6593 | ]); |
| | 6594 | assert.deepEqual((yield* adapter.listSessions())[0]?.resumeCursor, { |
| | 6595 | threadId: session.threadId, |
| | 6596 | resume: CLAUDE_FORK_SESSION_ID, |
| | 6597 | turnCount: 1, |
| | 6598 | turnStartMessageIds: [`fork-${firstTurnId}`], |
| | 6599 | }); |
| | 6600 | }).pipe( |
| | 6601 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| | 6602 | Effect.provide(harness.layer), |
| | 6603 | ); |
| | 6604 | }); |
| | 6605 | |
| | 6606 | it.effect("rewinds Claude history when the fork inserts extra system messages", () => { |
| | 6607 | let firstTurnId = ""; |
| | 6608 | let secondTurnId = ""; |
| | 6609 | const harness = makeHarness({ |
| | 6610 | forkSession: async () => ({ sessionId: CLAUDE_FORK_SESSION_ID }), |
| | 6611 | getSessionMessages: async (sessionId) => { |
| | 6612 | if (sessionId === CLAUDE_FORK_SESSION_ID) { |
| | 6613 | return [ |
| | 6614 | claudeHistoryMessage({ |
| | 6615 | type: "system", |
| | 6616 | uuid: "fork-system-init", |
| | 6617 | sessionId, |
| | 6618 | }), |
| | 6619 | claudeHistoryMessage({ |
| | 6620 | type: "user", |
| | 6621 | uuid: `fork-${firstTurnId}`, |
| | 6622 | sessionId, |
| | 6623 | content: "first", |
| | 6624 | }), |
| | 6625 | claudeHistoryMessage({ |
| | 6626 | type: "assistant", |
| | 6627 | uuid: "fork-assistant-1", |
| | 6628 | sessionId, |
| | 6629 | }), |
| | 6630 | ]; |
| | 6631 | } |
| | 6632 | return [ |
| | 6633 | claudeHistoryMessage({ type: "user", uuid: firstTurnId, content: "first" }), |
| | 6634 | claudeHistoryMessage({ type: "assistant", uuid: "assistant-1" }), |
| | 6635 | claudeHistoryMessage({ type: "user", uuid: secondTurnId, content: "second" }), |
| | 6636 | claudeHistoryMessage({ type: "assistant", uuid: "assistant-2" }), |
| | 6637 | ]; |
| | 6638 | }, |
| | 6639 | }); |
| | 6640 | return Effect.gen(function* () { |
| | 6641 | const adapter = yield* ClaudeAdapter; |
| | 6642 | const session = yield* adapter.startSession({ |
| | 6643 | threadId: THREAD_ID, |
| | 6644 | provider: ProviderDriverKind.make("claudeAgent"), |
| | 6645 | runtimeMode: "full-access", |
| | 6646 | }); |
| | 6647 | firstTurnId = (yield* sendCompletedClaudeTurn(adapter, harness, session.threadId, "first")) |
| | 6648 | .turnId; |
| | 6649 | secondTurnId = (yield* sendCompletedClaudeTurn(adapter, harness, session.threadId, "second")) |
| | 6650 | .turnId; |
| | 6651 | |
| | 6652 | yield* adapter.rollbackThread(session.threadId, 1); |
| | 6653 | assert.deepEqual((yield* adapter.listSessions())[0]?.resumeCursor, { |
| | 6654 | threadId: session.threadId, |
| | 6655 | resume: CLAUDE_FORK_SESSION_ID, |
| | 6656 | turnCount: 1, |
| | 6657 | turnStartMessageIds: [`fork-${firstTurnId}`], |
| | 6658 | }); |
| | 6659 | }).pipe( |
| | 6660 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| | 6661 | Effect.provide(harness.layer), |
| | 6662 | ); |
| | 6663 | }); |
| | 6664 | |
| | 6665 | it.effect("rewinds Claude history when the fork keeps a compact conversation prefix", () => { |
| | 6666 | let firstTurnId = ""; |
| | 6667 | let secondTurnId = ""; |
| | 6668 | const harness = makeHarness({ |
| | 6669 | forkSession: async () => ({ sessionId: CLAUDE_FORK_SESSION_ID }), |
| | 6670 | getSessionMessages: async (sessionId) => { |
| | 6671 | if (sessionId === CLAUDE_FORK_SESSION_ID) { |
| | 6672 | return [ |
| | 6673 | claudeHistoryMessage({ |
| | 6674 | type: "user", |
| | 6675 | uuid: "fork-compacted-user", |
| | 6676 | sessionId, |
| | 6677 | content: "earlier compacted turn", |
| | 6678 | }), |
| | 6679 | claudeHistoryMessage({ |
| | 6680 | type: "assistant", |
| | 6681 | uuid: "fork-compacted-assistant", |
| | 6682 | sessionId, |
| | 6683 | }), |
| | 6684 | claudeHistoryMessage({ |
| | 6685 | type: "user", |
| | 6686 | uuid: `fork-${firstTurnId}`, |
| | 6687 | sessionId, |
| | 6688 | content: "first", |
| | 6689 | }), |
| | 6690 | claudeHistoryMessage({ |
| | 6691 | type: "assistant", |
| | 6692 | uuid: "fork-assistant-1", |
| | 6693 | sessionId, |
| | 6694 | }), |
| | 6695 | ]; |
| | 6696 | } |
| | 6697 | return [ |
| | 6698 | claudeHistoryMessage({ type: "user", uuid: firstTurnId, content: "first" }), |
| | 6699 | claudeHistoryMessage({ type: "assistant", uuid: "assistant-1" }), |
| | 6700 | claudeHistoryMessage({ type: "user", uuid: secondTurnId, content: "second" }), |
| | 6701 | claudeHistoryMessage({ type: "assistant", uuid: "assistant-2" }), |
| | 6702 | ]; |
| | 6703 | }, |
| | 6704 | }); |
| | 6705 | return Effect.gen(function* () { |
| | 6706 | const adapter = yield* ClaudeAdapter; |
| | 6707 | const session = yield* adapter.startSession({ |
| | 6708 | threadId: THREAD_ID, |
| | 6709 | provider: ProviderDriverKind.make("claudeAgent"), |
| | 6710 | runtimeMode: "full-access", |
| | 6711 | }); |
| | 6712 | firstTurnId = (yield* sendCompletedClaudeTurn(adapter, harness, session.threadId, "first")) |
| | 6713 | .turnId; |
| | 6714 | secondTurnId = (yield* sendCompletedClaudeTurn(adapter, harness, session.threadId, "second")) |
| | 6715 | .turnId; |
| | 6716 | |
| | 6717 | yield* adapter.rollbackThread(session.threadId, 1); |
| | 6718 | assert.deepEqual((yield* adapter.listSessions())[0]?.resumeCursor, { |
| | 6719 | threadId: session.threadId, |
| | 6720 | resume: CLAUDE_FORK_SESSION_ID, |
| | 6721 | turnCount: 1, |
| | 6722 | turnStartMessageIds: [`fork-${firstTurnId}`], |
| | 6723 | }); |
| | 6724 | }).pipe( |
| | 6725 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| | 6726 | Effect.provide(harness.layer), |
| | 6727 | ); |
| | 6728 | }); |
| | 6729 | |
| | 6730 | it.effect("rewinds a Claude turn that includes tool results and a later steer", () => { |
| | 6731 | const forkCalls: Array<Parameters<NonNullable<ClaudeAdapterLiveOptions["forkSession"]>>> = []; |
| | 6732 | let firstTurnId = ""; |
| | 6733 | let secondTurnId = ""; |
| | 6734 | const harness = makeHarness({ |
| | 6735 | forkSession: async (...args) => { |
| | 6736 | forkCalls.push(args); |
| | 6737 | return { sessionId: CLAUDE_FORK_SESSION_ID }; |
| | 6738 | }, |
| | 6739 | getSessionMessages: async (sessionId) => { |
| | 6740 | const history = [ |
| | 6741 | claudeHistoryMessage({ type: "user", uuid: firstTurnId, content: "first" }), |
| | 6742 | claudeHistoryMessage({ type: "assistant", uuid: "assistant-1-tool" }), |
| | 6743 | claudeHistoryMessage({ |
| | 6744 | type: "user", |
| | 6745 | uuid: "tool-result-1", |
| | 6746 | content: [{ type: "tool_result" }], |
| | 6747 | }), |
| | 6748 | claudeHistoryMessage({ type: "assistant", uuid: "assistant-1-final" }), |
| | 6749 | claudeHistoryMessage({ type: "user", uuid: secondTurnId, content: "second" }), |
| | 6750 | claudeHistoryMessage({ type: "assistant", uuid: "assistant-2" }), |
| | 6751 | claudeHistoryMessage({ |
| | 6752 | type: "user", |
| | 6753 | uuid: "steer", |
| | 6754 | sessionId, |
| | 6755 | content: "steer the second turn", |
| | 6756 | }), |
| | 6757 | claudeHistoryMessage({ type: "assistant", uuid: "assistant-steer", sessionId }), |
| | 6758 | ]; |
| | 6759 | return sessionId === CLAUDE_FORK_SESSION_ID |
| | 6760 | ? history.slice(0, 4).map((message) => ({ |
| | 6761 | ...message, |
| | 6762 | uuid: `fork-${message.uuid}`, |
| | 6763 | session_id: sessionId, |
| | 6764 | })) |
| | 6765 | : history; |
| | 6766 | }, |
| | 6767 | }); |
| | 6768 | return Effect.gen(function* () { |
| | 6769 | const adapter = yield* ClaudeAdapter; |
| | 6770 | const session = yield* adapter.startSession({ |
| | 6771 | threadId: THREAD_ID, |
| | 6772 | provider: ProviderDriverKind.make("claudeAgent"), |
| | 6773 | runtimeMode: "full-access", |
| | 6774 | }); |
| | 6775 | firstTurnId = (yield* sendCompletedClaudeTurn(adapter, harness, session.threadId, "first")) |
| | 6776 | .turnId; |
| | 6777 | const secondTurn = yield* adapter.sendTurn({ |
| | 6778 | threadId: session.threadId, |
| | 6779 | input: "second", |
| | 6780 | attachments: [], |
| | 6781 | }); |
| | 6782 | secondTurnId = secondTurn.turnId; |
| | 6783 | const steer = yield* adapter.sendTurn({ |
| | 6784 | threadId: session.threadId, |
| | 6785 | input: "steer the second turn", |
| | 6786 | attachments: [], |
| | 6787 | }); |
| | 6788 | assert.equal(steer.turnId, secondTurn.turnId); |
| | 6789 | const secondCompletedFiber = yield* Stream.filter( |
| | 6790 | adapter.streamEvents, |
| | 6791 | (event) => event.type === "turn.completed", |
| | 6792 | ).pipe(Stream.runHead, Effect.forkChild); |
| | 6793 | harness.queries.at(-1)!.emit({ |
| | 6794 | type: "result", |
| | 6795 | subtype: "success", |
| | 6796 | is_error: false, |
| | 6797 | errors: [], |
| | 6798 | session_id: CLAUDE_ORIGINAL_SESSION_ID, |
| | 6799 | uuid: "result-second", |
| | 6800 | } as unknown as SDKMessage); |
| | 6801 | yield* Fiber.join(secondCompletedFiber); |
| | 6802 | |
| | 6803 | const snapshot = yield* adapter.rollbackThread(session.threadId, 1); |
| | 6804 | assert.equal(snapshot.turns.length, 1); |
| | 6805 | assert.deepEqual(forkCalls, [ |
| | 6806 | [CLAUDE_ORIGINAL_SESSION_ID, { upToMessageId: "assistant-1-final" }], |
| | 6807 | ]); |
| | 6808 | assert.deepEqual((yield* adapter.listSessions())[0]?.resumeCursor, { |
| | 6809 | threadId: session.threadId, |
| | 6810 | resume: CLAUDE_FORK_SESSION_ID, |
| | 6811 | turnCount: 1, |
| | 6812 | turnStartMessageIds: [`fork-${firstTurnId}`], |
| | 6813 | }); |
| | 6814 | }).pipe( |
| | 6815 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| | 6816 | Effect.provide(harness.layer), |
| | 6817 | ); |
| | 6818 | }); |
| | 6819 | |
| | 6820 | it.effect("rewinds the last two turns of a three-turn Claude thread", () => { |
| | 6821 | let firstTurnId = ""; |
| | 6822 | let secondTurnId = ""; |
| | 6823 | let thirdTurnId = ""; |
| | 6824 | const harness = makeHarness({ |
| | 6825 | forkSession: async () => ({ sessionId: CLAUDE_FORK_SESSION_ID }), |
| | 6826 | getSessionMessages: async (sessionId) => { |
| | 6827 | const history = [ |
| | 6828 | claudeHistoryMessage({ type: "system", uuid: "system-init" }), |
| | 6829 | claudeHistoryMessage({ type: "user", uuid: firstTurnId, content: "first" }), |
| | 6830 | claudeHistoryMessage({ type: "assistant", uuid: "assistant-1" }), |
| | 6831 | claudeHistoryMessage({ type: "user", uuid: secondTurnId, content: "second" }), |
| | 6832 | claudeHistoryMessage({ type: "assistant", uuid: "assistant-2" }), |
| | 6833 | claudeHistoryMessage({ type: "user", uuid: thirdTurnId, content: "third" }), |
| | 6834 | claudeHistoryMessage({ type: "assistant", uuid: "assistant-3" }), |
| | 6835 | ]; |
| | 6836 | return sessionId === CLAUDE_FORK_SESSION_ID |
| | 6837 | ? [ |
| | 6838 | claudeHistoryMessage({ |
| | 6839 | type: "user", |
| | 6840 | uuid: `fork-${firstTurnId}`, |
| | 6841 | sessionId, |
| | 6842 | content: "first", |
| | 6843 | }), |
| | 6844 | claudeHistoryMessage({ |
| | 6845 | type: "assistant", |
| | 6846 | uuid: "fork-assistant-1", |
| | 6847 | sessionId, |
| | 6848 | }), |
| | 6849 | ] |
| | 6850 | : history; |
| | 6851 | }, |
| | 6852 | }); |
| | 6853 | return Effect.gen(function* () { |
| | 6854 | const adapter = yield* ClaudeAdapter; |
| | 6855 | const session = yield* adapter.startSession({ |
| | 6856 | threadId: THREAD_ID, |
| | 6857 | provider: ProviderDriverKind.make("claudeAgent"), |
| | 6858 | runtimeMode: "full-access", |
| | 6859 | }); |
| | 6860 | firstTurnId = (yield* sendCompletedClaudeTurn(adapter, harness, session.threadId, "first")) |
| | 6861 | .turnId; |
| | 6862 | secondTurnId = (yield* sendCompletedClaudeTurn(adapter, harness, session.threadId, "second")) |
| | 6863 | .turnId; |
| | 6864 | thirdTurnId = (yield* sendCompletedClaudeTurn(adapter, harness, session.threadId, "third")) |
| | 6865 | .turnId; |
| | 6866 | |
| | 6867 | const snapshot = yield* adapter.rollbackThread(session.threadId, 2); |
| | 6868 | assert.equal(snapshot.turns.length, 1); |
| | 6869 | assert.deepEqual((yield* adapter.listSessions())[0]?.resumeCursor, { |
| | 6870 | threadId: session.threadId, |
| | 6871 | resume: CLAUDE_FORK_SESSION_ID, |
| | 6872 | turnCount: 1, |
| | 6873 | turnStartMessageIds: [`fork-${firstTurnId}`], |
| | 6874 | }); |
| | 6875 | }).pipe( |
| | 6876 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| | 6877 | Effect.provide(harness.layer), |
| | 6878 | ); |
| | 6879 | }); |
| | 6880 | |
| | 6881 | it.effect("rewinds only the latest turn of a three-turn Claude thread", () => { |
| | 6882 | let firstTurnId = ""; |
| | 6883 | let secondTurnId = ""; |
| | 6884 | let thirdTurnId = ""; |
| | 6885 | const harness = makeHarness({ |
| | 6886 | forkSession: async () => ({ sessionId: CLAUDE_FORK_SESSION_ID }), |
| | 6887 | getSessionMessages: async (sessionId) => { |
| | 6888 | const history = [ |
| | 6889 | claudeHistoryMessage({ type: "system", uuid: "system-init" }), |
| | 6890 | claudeHistoryMessage({ type: "user", uuid: firstTurnId, content: "first" }), |
| | 6891 | claudeHistoryMessage({ type: "assistant", uuid: "assistant-1" }), |
| | 6892 | claudeHistoryMessage({ type: "user", uuid: secondTurnId, content: "second" }), |
| | 6893 | claudeHistoryMessage({ type: "assistant", uuid: "assistant-2" }), |
| | 6894 | claudeHistoryMessage({ type: "user", uuid: thirdTurnId, content: "third" }), |
| | 6895 | claudeHistoryMessage({ type: "assistant", uuid: "assistant-3" }), |
| | 6896 | ]; |
| | 6897 | return sessionId === CLAUDE_FORK_SESSION_ID |
| | 6898 | ? [ |
| | 6899 | claudeHistoryMessage({ |
| | 6900 | type: "user", |
| | 6901 | uuid: `fork-${firstTurnId}`, |
| | 6902 | sessionId, |
| | 6903 | content: "first", |
| | 6904 | }), |
| | 6905 | claudeHistoryMessage({ |
| | 6906 | type: "assistant", |
| | 6907 | uuid: "fork-assistant-1", |
| | 6908 | sessionId, |
| | 6909 | }), |
| | 6910 | claudeHistoryMessage({ |
| | 6911 | type: "system", |
| | 6912 | uuid: "fork-notice", |
| | 6913 | sessionId, |
| | 6914 | }), |
| | 6915 | claudeHistoryMessage({ |
| | 6916 | type: "user", |
| | 6917 | uuid: `fork-${secondTurnId}`, |
| | 6918 | sessionId, |
| | 6919 | content: "second", |
| | 6920 | }), |
| | 6921 | claudeHistoryMessage({ |
| | 6922 | type: "assistant", |
| | 6923 | uuid: "fork-assistant-2", |
| | 6924 | sessionId, |
| | 6925 | }), |
| | 6926 | ] |
| | 6927 | : history; |
| | 6928 | }, |
| | 6929 | }); |
| | 6930 | return Effect.gen(function* () { |
| | 6931 | const adapter = yield* ClaudeAdapter; |
| | 6932 | const session = yield* adapter.startSession({ |
| | 6933 | threadId: THREAD_ID, |
| | 6934 | provider: ProviderDriverKind.make("claudeAgent"), |
| | 6935 | runtimeMode: "full-access", |
| | 6936 | }); |
| | 6937 | firstTurnId = (yield* sendCompletedClaudeTurn(adapter, harness, session.threadId, "first")) |
| | 6938 | .turnId; |
| | 6939 | secondTurnId = (yield* sendCompletedClaudeTurn(adapter, harness, session.threadId, "second")) |
| | 6940 | .turnId; |
| | 6941 | thirdTurnId = (yield* sendCompletedClaudeTurn(adapter, harness, session.threadId, "third")) |
| | 6942 | .turnId; |
| | 6943 | |
| | 6944 | const snapshot = yield* adapter.rollbackThread(session.threadId, 1); |
| | 6945 | assert.equal(snapshot.turns.length, 2); |
| | 6946 | assert.deepEqual((yield* adapter.listSessions())[0]?.resumeCursor, { |
| | 6947 | threadId: session.threadId, |
| | 6948 | resume: CLAUDE_FORK_SESSION_ID, |
| | 6949 | turnCount: 2, |
| | 6950 | turnStartMessageIds: [`fork-${firstTurnId}`, `fork-${secondTurnId}`], |
| | 6951 | }); |
| | 6952 | }).pipe( |
| | 6953 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| | 6954 | Effect.provide(harness.layer), |
| | 6955 | ); |
| | 6956 | }); |
| | 6957 | |
| | 6958 | it.effect("resets a Claude thread when rewind removes every recorded turn", () => { |
| | 6959 | const forkCalls: Array<Parameters<NonNullable<ClaudeAdapterLiveOptions["forkSession"]>>> = []; |
| | 6960 | const harness = makeHarness({ |
| | 6961 | forkSession: async (...args) => { |
| | 6962 | forkCalls.push(args); |
| | 6963 | return { sessionId: CLAUDE_FORK_SESSION_ID }; |
| | 6964 | }, |
| | 6965 | getSessionMessages: async () => [ |
| | 6966 | claudeHistoryMessage({ type: "user", uuid: "unused", content: "first" }), |
| | 6967 | ], |
| | 6968 | }); |
| | 6969 | return Effect.gen(function* () { |
| | 6970 | const adapter = yield* ClaudeAdapter; |
| | 6971 | const session = yield* adapter.startSession({ |
| | 6972 | threadId: THREAD_ID, |
| | 6973 | provider: ProviderDriverKind.make("claudeAgent"), |
| | 6974 | runtimeMode: "full-access", |
| | 6975 | }); |
| | 6976 | yield* sendCompletedClaudeTurn(adapter, harness, session.threadId, "first"); |
| | 6977 | yield* sendCompletedClaudeTurn(adapter, harness, session.threadId, "second"); |
| | 6978 | |
| | 6979 | const snapshot = yield* adapter.rollbackThread(session.threadId, 2); |
| | 6980 | assert.equal(snapshot.turns.length, 0); |
| | 6981 | assert.equal(forkCalls.length, 0); |
| | 6982 | const resetOptions = harness.getLastCreateQueryInput()?.options; |
| | 6983 | assert.equal(resetOptions?.resume, undefined); |
| | 6984 | assert.ok(resetOptions?.sessionId); |
| | 6985 | }).pipe( |
| | 6986 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| | 6987 | Effect.provide(harness.layer), |
| | 6988 | ); |
| | 6989 | }); |
| | 6990 | |
| | 6991 | it.effect("rejects a Claude fork that drops a retained user turn", () => { |
| | 6992 | let firstTurnId = ""; |
| | 6993 | let secondTurnId = ""; |
| | 6994 | const harness = makeHarness({ |
| | 6995 | forkSession: async () => ({ sessionId: CLAUDE_FORK_SESSION_ID }), |
| | 6996 | getSessionMessages: async (sessionId) => { |
| | 6997 | if (sessionId === CLAUDE_FORK_SESSION_ID) { |
| | 6998 | return [ |
| | 6999 | claudeHistoryMessage({ |
| | 7000 | type: "assistant", |
| | 7001 | uuid: "fork-assistant-1", |
| | 7002 | sessionId, |
| | 7003 | }), |
| | 7004 | ]; |
| | 7005 | } |
| | 7006 | return [ |
| | 7007 | claudeHistoryMessage({ type: "user", uuid: firstTurnId, content: "first" }), |
| | 7008 | claudeHistoryMessage({ type: "assistant", uuid: "assistant-1" }), |
| | 7009 | claudeHistoryMessage({ type: "user", uuid: secondTurnId, content: "second" }), |
| | 7010 | claudeHistoryMessage({ type: "assistant", uuid: "assistant-2" }), |
| | 7011 | ]; |
| | 7012 | }, |
| | 7013 | }); |
| | 7014 | return Effect.gen(function* () { |
| | 7015 | const adapter = yield* ClaudeAdapter; |
| | 7016 | const session = yield* adapter.startSession({ |
| | 7017 | threadId: THREAD_ID, |
| | 7018 | provider: ProviderDriverKind.make("claudeAgent"), |
| | 7019 | runtimeMode: "full-access", |
| | 7020 | }); |
| | 7021 | firstTurnId = (yield* sendCompletedClaudeTurn(adapter, harness, session.threadId, "first")) |
| | 7022 | .turnId; |
| | 7023 | secondTurnId = (yield* sendCompletedClaudeTurn(adapter, harness, session.threadId, "second")) |
| | 7024 | .turnId; |
| | 7025 | |
| | 7026 | const error = yield* adapter.rollbackThread(session.threadId, 1).pipe(Effect.flip); |
| | 7027 | assert.match(error.message, /did not preserve the retained turn boundaries/); |
| | 7028 | assert.equal(harness.queries.at(-1)?.closeCalls, 0); |
| | 7029 | }).pipe( |
| | 7030 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| | 7031 | Effect.provide(harness.layer), |
| | 7032 | ); |
| | 7033 | }); |
| | 7034 | |
| | 7035 | for (const scenario of [ |
| | 7036 | "restores conversation messages between retained turns", |
| | 7037 | "changes retained assistant content without changing its role", |
| | 7038 | ] as const) { |
| | 7039 | it.effect(`rejects a Claude fork that ${scenario}`, () => { |
| | 7040 | const turnIds: Array<string> = []; |
| | 7041 | const harness = makeHarness({ |
| | 7042 | forkSession: async () => ({ sessionId: CLAUDE_FORK_SESSION_ID }), |
| | 7043 | getSessionMessages: async (sessionId) => { |
| | 7044 | const history = turnIds.flatMap((turnId, index) => [ |
| | 7045 | claudeHistoryMessage({ |
| | 7046 | type: "user", |
| | 7047 | uuid: turnId, |
| | 7048 | content: `prompt ${index + 1}`, |
| | 7049 | }), |
| | 7050 | claudeHistoryMessage({ |
| | 7051 | type: "assistant", |
| | 7052 | uuid: `assistant-${index + 1}`, |
| | 7053 | content: [{ type: "text", text: `reply ${index + 1}` }], |
| | 7054 | }), |
| | 7055 | ]); |
| | 7056 | if (sessionId !== CLAUDE_FORK_SESSION_ID) return history; |
| | 7057 | const forkHistory = history.slice(0, 4).map((message) => ({ |
| | 7058 | ...message, |
| | 7059 | uuid: `fork-${message.uuid}`, |
| | 7060 | session_id: sessionId, |
| | 7061 | })); |
| | 7062 | if (scenario === "restores conversation messages between retained turns") { |
| | 7063 | forkHistory.splice( |
| | 7064 | 2, |
| | 7065 | 0, |
| | 7066 | claudeHistoryMessage({ |
| | 7067 | type: "user", |
| | 7068 | uuid: "fork-restored-steer", |
| | 7069 | sessionId, |
| | 7070 | content: "earlier steer omitted by compaction", |
| | 7071 | }), |
| | 7072 | claudeHistoryMessage({ |
| | 7073 | type: "assistant", |
| | 7074 | uuid: "fork-restored-reply", |
| | 7075 | sessionId, |
| | 7076 | content: [{ type: "text", text: "earlier steering reply" }], |
| | 7077 | }), |
| | 7078 | ); |
| | 7079 | } else { |
| | 7080 | forkHistory[1] = claudeHistoryMessage({ |
| | 7081 | type: "assistant", |
| | 7082 | uuid: "fork-assistant-1", |
| | 7083 | sessionId, |
| | 7084 | content: [{ type: "text", text: "different retained reply" }], |
| | 7085 | }); |
| | 7086 | } |
| | 7087 | return forkHistory; |
| | 7088 | }, |
| | 7089 | }); |
| | 7090 | return Effect.gen(function* () { |
| | 7091 | const adapter = yield* ClaudeAdapter; |
| | 7092 | const session = yield* adapter.startSession({ |
| | 7093 | threadId: THREAD_ID, |
| | 7094 | provider: ProviderDriverKind.make("claudeAgent"), |
| | 7095 | runtimeMode: "full-access", |
| | 7096 | }); |
| | 7097 | for (let index = 0; index < 3; index++) { |
| | 7098 | const turn = yield* sendCompletedClaudeTurn( |
| | 7099 | adapter, |
| | 7100 | harness, |
| | 7101 | session.threadId, |
| | 7102 | `prompt ${index + 1}`, |
| | 7103 | ); |
| | 7104 | turnIds.push(turn.turnId); |
| | 7105 | } |
| | 7106 | const cursorBeforeRollback = (yield* adapter.listSessions())[0]?.resumeCursor; |
| | 7107 | const queryCountBeforeRollback = harness.queries.length; |
| | 7108 | |
| | 7109 | const error = yield* adapter.rollbackThread(session.threadId, 1).pipe(Effect.flip); |
| | 7110 | assert.match(error.message, /did not preserve the retained turn boundaries/); |
| | 7111 | assert.equal(harness.queries.length, queryCountBeforeRollback); |
| | 7112 | assert.equal(harness.queries.at(-1)?.closeCalls, 0); |
| | 7113 | assert.deepEqual((yield* adapter.listSessions())[0]?.resumeCursor, cursorBeforeRollback); |
| | 7114 | }).pipe( |
| | 7115 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| | 7116 | Effect.provide(harness.layer), |
| | 7117 | ); |
| | 7118 | }); |
| | 7119 | } |
| | 7120 | |
| 6487 | it.effect("updates model on sendTurn when model override is provided", () => { | 7121 | it.effect("updates model on sendTurn when model override is provided", () => { |
| 6488 | const harness = makeHarness(); | 7122 | const harness = makeHarness(); |
| 6489 | return Effect.gen(function* () { | 7123 | return Effect.gen(function* () { |