From 16cf34b517855288ed6c9b82ef47edb5fe154ef5 Mon Sep 17 00:00:00 2001 From: takecchi Date: Fri, 21 Aug 2026 17:31:21 +0900 Subject: [PATCH 1/3] feat: hand conversation history across agents --- README.ja.md | 4 +- README.md | 4 +- src/core/agent-handoff.spec.ts | 42 +++++++++++++++++++- src/core/agent-handoff.ts | 72 +++++++++++++++++++++++++++++++--- src/core/session.spec.ts | 15 +++++-- src/core/session.ts | 29 ++++++++++---- 6 files changed, 145 insertions(+), 21 deletions(-) diff --git a/README.ja.md b/README.ja.md index 47c9cfe..ce52da8 100644 --- a/README.ja.md +++ b/README.ja.md @@ -317,11 +317,11 @@ Codiva v0.3.1 3 セッション | 引き継がれる | 引き継がれない | |---|---| -| worktree・ブランチ・作業ツリーの内容、codiva 上のログ・タイトル・PR | **会話の文脈**(各 CLI がそれぞれ自分の記録を持つため) | +| worktree・ブランチ・作業ツリーの内容、codiva 上のユーザー/アシスタント双方の会話ログ・タイトル・PR | provider 固有のセッションそのもの(各 CLI がそれぞれ自分の記録を持つため) | 一度使ったエージェントの会話 id はセッションごとに保存されるので、Claude → Codex → Claude と戻したときは**元の会話の続き**から再開します(codiva を再起動しても同じです)。 -会話の文脈は渡せませんが、**切替先には「引き継ぎの覚書」を 1 回だけ渡します** — ブランチ名・そのセッションの最初の指示・直前の指示と、「続ける前に `git status` / `git diff` で作業ツリーの状態を自分で確かめること」を伝えるので、済んだ作業をやり直したり直前の指示を無視したりしにくくなります(切替直後に余分なターンは走りません。次にあなたが指示を送ったときに一緒に渡ります)。 +provider 固有の会話文脈を直接移すことはできないため、codiva が保持している**ユーザーとアシスタント双方の会話ログを、切替先への 1 回限りの引き継ぎ情報としてコピーします**。ブランチ名・そのセッションの最初と直前の指示・「続ける前に `git status` / `git diff` で作業ツリーを確認すること」も一緒に渡します。引き継ぎが安全上限の 80,000 文字に達した場合は新しい会話を優先し、省略したことを明記します(切替直後に余分なターンは走らず、次に入力した指示と一緒に渡ります)。 **どのセッションが何で走っているかは画面で分かります。** diff --git a/README.md b/README.md index 4df5fcb..8096b2c 100644 --- a/README.md +++ b/README.md @@ -306,11 +306,11 @@ Here's what does and doesn't carry over when you switch with `/agent`: | Carries over | Does not carry over | |---|---| -| The worktree, branch and working tree contents; codiva's log, title and PRs | **The conversation context** (each CLI keeps its own transcript) | +| The worktree, branch and working tree contents; codiva's user/assistant conversation log, title and PRs | The provider's native session itself (each CLI keeps its own transcript) | The conversation id of each agent you've used is stored per session, so going Claude → Codex → Claude resumes **the original conversation** where it left off (this survives restarting codiva too). -The context can't be transferred, but **the incoming agent gets a one-time handoff note** — the branch name, the session's first instruction, the most recent instruction, and a reminder to "verify the state of the working tree yourself with `git status` / `git diff` before continuing" — which makes it much less likely to redo finished work or ignore your last instruction. (No extra turn runs at switch time; the note rides along with the next instruction you send.) +The provider-native context can't be transferred directly, so **codiva copies the retained user and assistant conversation into a one-time handoff** for the incoming agent. It also includes the branch name, the first and most recent instructions, and a reminder to verify the working tree with `git status` / `git diff`. The newest conversation is prioritized and an omission marker is shown if the handoff reaches its 80,000-character safety limit. No extra turn runs at switch time; the handoff rides along with the next instruction you send. **You can always see what each session is running on.** diff --git a/src/core/agent-handoff.spec.ts b/src/core/agent-handoff.spec.ts index a25a10e..e562026 100644 --- a/src/core/agent-handoff.spec.ts +++ b/src/core/agent-handoff.spec.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { handoffInstruction, lastUserInstruction, MAX_HANDOFF_FIELD_CHARS } from './agent-handoff'; +import { + handoffInstruction, + handoffTranscript, + lastUserInstruction, + MAX_HANDOFF_FIELD_CHARS, + MAX_HANDOFF_TRANSCRIPT_CHARS, +} from './agent-handoff'; import type { LogEntry } from './types'; const entry = (seq: number, kind: LogEntry['kind'], text: string): LogEntry => ({ @@ -37,11 +43,21 @@ describe('handoffInstruction', () => { branch: 'codiva/add-login', task: 'ログイン画面を作る', lastInstruction: 'テストも書いて', + messages: [ + entry(1, 'user', 'HogeHoge'), + entry(2, 'assistant_text', 'HogeHoge への回答'), + entry(3, 'user', 'FugaFuga'), + entry(4, 'assistant_text', 'FugaFuga への回答'), + ], }); expect(text).toContain('taking over this session from Claude'); expect(text).toContain('- Branch: codiva/add-login'); expect(text).toContain('- Original task: ログイン画面を作る'); expect(text).toContain('- Most recent instruction: テストも書いて'); + expect(text).toContain('User:\nHogeHoge'); + expect(text).toContain('Assistant:\nHogeHoge への回答'); + expect(text).toContain('User:\nFugaFuga'); + expect(text).toContain('Assistant:\nFugaFuga への回答'); // 作業ツリーを自分で確かめてから続けさせる(要約を信じさせない)。 expect(text).toContain('git status'); }); @@ -66,3 +82,27 @@ describe('handoffInstruction', () => { expect(text).toContain('- Most recent instruction: 複数 行の 指示'); }); }); + +describe('handoffTranscript', () => { + it('会話以外のログ行を含めない', () => { + const text = handoffTranscript([ + entry(1, 'user', '依頼'), + entry(2, 'tool_use', 'Bash(git status)'), + entry(3, 'tool_result', 'large output'), + entry(4, 'assistant_text', '回答'), + entry(5, 'system', 'completed'), + ]); + expect(text).toBe('User:\n依頼\n\nAssistant:\n回答'); + }); + + it('上限を超えたら新しい会話を優先して省略を明示する', () => { + const text = handoffTranscript([ + entry(1, 'user', 'old'.repeat(MAX_HANDOFF_TRANSCRIPT_CHARS / 3)), + entry(2, 'assistant_text', 'middle'.repeat(MAX_HANDOFF_TRANSCRIPT_CHARS / 3)), + entry(3, 'user', 'latest'), + ]); + expect(text).toContain('Earlier conversation omitted'); + expect(text).toContain('User:\nlatest'); + expect(text).not.toContain('oldoldold'); + }); +}); diff --git a/src/core/agent-handoff.ts b/src/core/agent-handoff.ts index 11e706d..6e1b93c 100644 --- a/src/core/agent-handoff.ts +++ b/src/core/agent-handoff.ts @@ -10,8 +10,9 @@ import type { LogEntry } from './types'; * * **AI 向けの文字列なので i18n カタログには置かない**(`core/system-prompt.ts` の * `SHARED_IGNORED_FILES_NOTICE` / `utils/title.ts` の `TITLE_INSTRUCTION` と同じ扱いで - * 英語固定)。渡す先は `AgentRunOptions.systemPrompt` で、`composeSystemPrompt` の - * 最後の節として 1 回だけ載る(次のターン以降には持ち越さない)。 + * 英語固定)。切替後の最初のユーザープロンプトにだけ内部的に前置される + * (次のターン以降には持ち越さない)。resume 時に system prompt を再適用しない + * provider にも確実に渡すため、この形にしている。 */ /** @@ -21,6 +22,13 @@ import type { LogEntry } from './types'; */ export const MAX_HANDOFF_FIELD_CHARS = 600; +/** + * 会話履歴を引き継ぐ最大文字数。codiva の表示ログ自体は 400k 文字まで保持するが、 + * それを丸ごと system prompt にすると切替だけで巨大なコンテキストを消費する。 + * 新しい会話から優先して収め、切れたことは明示する。 + */ +export const MAX_HANDOFF_TRANSCRIPT_CHARS = 80_000; + export interface HandoffInput { /** 引き継ぐ側の表示名(切替前のエージェント)。 */ from: string; @@ -30,6 +38,8 @@ export interface HandoffInput { task?: string; /** 直前にユーザーが送った指示(最初の指示と同じなら省く)。 */ lastInstruction?: string; + /** codiva が保持している会話ログ。user / assistant_text の双方を引き継ぐ。 */ + messages?: readonly LogEntry[]; } /** 1 行に畳んで長すぎるものを切る(systemPrompt の箇条書きに収めるため)。 */ @@ -57,6 +67,44 @@ export function lastUserInstruction(messages: readonly LogEntry[]): string | und return undefined; } +/** + * codiva の表示ログから、切替先へ渡す会話 transcript を作る。 + * ツール実行・system/error 行は作業ツリーを見れば確認でき、量も大きいため除外する。 + */ +export function handoffTranscript(messages: readonly LogEntry[]): string | undefined { + const turns = messages + .filter((entry) => entry.kind === 'user' || entry.kind === 'assistant_text') + .map((entry) => { + const role = entry.kind === 'user' ? 'User' : 'Assistant'; + const agent = entry.agent ? ` (${entry.agent})` : ''; + return `${role}${agent}:\n${entry.text.trim()}`; + }) + .filter((turn) => !turn.endsWith(':\n')); + if (turns.length === 0) { + return undefined; + } + + const kept: string[] = []; + let chars = 0; + for (let i = turns.length - 1; i >= 0; i -= 1) { + const turn = turns[i]; + if (turn === undefined) { + continue; + } + const separator = kept.length === 0 ? 0 : 2; + if (chars + separator + turn.length > MAX_HANDOFF_TRANSCRIPT_CHARS) { + break; + } + kept.unshift(turn); + chars += separator + turn.length; + } + const omitted = kept.length < turns.length; + return [ + ...(omitted ? ['[Earlier conversation omitted because the handover reached its size limit.]'] : []), + ...kept, + ].join('\n\n'); +} + /** * 引き継ぎの指示文。渡せる材料が何も無ければ `undefined`(`composeSystemPrompt` と * 同じで、無いものは足さない)。 @@ -69,14 +117,15 @@ export function handoffInstruction(input: HandoffInput): string | undefined { const task = field(input.task); const last = field(input.lastInstruction); const branch = field(input.branch); - if (!task && !last && !branch) { + const transcript = handoffTranscript(input.messages ?? []); + if (!task && !last && !branch && !transcript) { return undefined; } const lines = [ '# Session handover (codiva)', '', - `You are taking over this session from ${input.from}. The previous agent's conversation`, - 'history is NOT available to you — only the working tree it left behind is shared.', + `You are taking over this session from ${input.from}. The providers cannot share their`, + 'native session, so codiva has copied the user/assistant conversation below.', '', ]; if (branch) { @@ -88,6 +137,19 @@ export function handoffInstruction(input: HandoffInput): string | undefined { if (last && last !== task) { lines.push(`- Most recent instruction: ${last}`); } + if (transcript) { + lines.push( + '', + '## Conversation before the switch', + '', + 'Treat this as the prior conversation in the same task. Continue from it; do not ask the', + 'user to repeat information already present here.', + '', + '', + transcript, + '', + ); + } lines.push( '', 'Before doing anything, inspect the working tree yourself (`git status`, `git diff`,', diff --git a/src/core/session.spec.ts b/src/core/session.spec.ts index c6ab130..b4a7b0f 100644 --- a/src/core/session.spec.ts +++ b/src/core/session.spec.ts @@ -952,6 +952,7 @@ describe('Session.setAgent', () => { seen.push(text); // provider が会話 id を発行したことにする(切替の往復で resume される)。 yield { kind: 'session_started', sessionId: `${id}-thread` } as const; + yield { kind: 'assistant_text', text: `${id} answered: ${text}` } as const; } }, }; @@ -973,7 +974,8 @@ describe('Session.setAgent', () => { await tick(); // 切替前のストリームは畳まれているので、古いエージェントには届かない。 - expect(b.seen).toEqual(['now you']); + expect(b.seen).toHaveLength(1); + expect(b.seen[0]).toContain('# Current instruction after the switch\n\nnow you'); expect(a.seen).toEqual(['do the thing']); expect(session.getState().agent).toBe('codex'); }); @@ -993,15 +995,17 @@ describe('Session.setAgent', () => { session.send('now you'); await tick(); - const briefing = b.systemPrompts[0]; + const briefing = b.seen[0]; expect(briefing).toContain('taking over this session from claude'); expect(briefing).toContain('- Branch: codiva/t'); expect(briefing).toContain('- Original task: do the thing'); + expect(briefing).toContain('User:\ndo the thing'); + expect(briefing).toContain('Assistant:\nclaude answered: do the thing'); // 2 回目のターン(同じエージェント)には持ち越さない — 引き継ぎは済んでいる。 session.send('and this'); await tick(); - expect(b.systemPrompts.slice(1).every((p) => p === undefined)).toBe(true); + expect(b.seen[1]).toBe('and this'); }); it('resumes the previous conversation when switching back', async () => { @@ -1021,7 +1025,10 @@ describe('Session.setAgent', () => { // 2 回目の Claude は自分が発行した id で resume する(別 provider の id は渡さない)。 expect(a.resumes).toEqual([undefined, 'claude-thread']); expect(b.resumes).toEqual([undefined]); - expect(a.seen).toEqual(['do the thing', 'back to claude']); + expect(a.seen[0]).toBe('do the thing'); + expect(a.seen[1]).toContain('User (codex):\nto codex'); + expect(a.seen[1]).toContain('Assistant (codex):\ncodex answered: to codex'); + expect(a.seen[1]).toContain('# Current instruction after the switch\n\nback to claude'); }); it('stops the in-flight turn and hands queued follow-ups to the NEW agent', async () => { diff --git a/src/core/session.ts b/src/core/session.ts index 581d771..99bccac 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -43,6 +43,22 @@ export type PermissionPolicy = ( const defaultPolicy: PermissionPolicy = (toolName) => toolName === 'AskUserQuestion' ? 'ask' : 'allow'; +/** Attach a provider-switch handoff to exactly the first prompt the new run consumes. */ +async function* withHandoff( + prompt: AsyncIterable, + handoff: string | undefined, +): AsyncIterable { + let pending = handoff; + for await (const text of prompt) { + if (pending) { + yield `${pending}\n\n# Current instruction after the switch\n\n${text}`; + pending = undefined; + } else { + yield text; + } + } +} + /** Per-session knobs forwarded to the SDK query (sourced from the config file). */ export interface SessionOptions { model?: string; @@ -247,6 +263,7 @@ export class Session { branch: this.state.branch, task: this.state.prompt, lastInstruction: lastUserInstruction(this.state.messages), + messages: this.state.messages, }); // 走っているターンを畳んでからでないと、2 本のストリームが同じ worktree を // 触ることになる。保留中の許可も解決しておく(未応答の tool_use で終わる @@ -556,21 +573,19 @@ export class Session { const resume = this.attribution ? this.state.sdkSessionId : (this.deps.resume ?? this.state.sdkSessionId); - // worktree の環境説明(symlink 共有の注意書き)とリポジトリ追加指示をまとめた - // systemPrompt。どちらも無ければ undefined で、その場合は渡さない。 - // 引き継ぎの説明は**この 1 回だけ**載せる(切替直後の最初の run)。ここで - // 落としておかないと、通信断からの再起動でも「前任者から引き継いだ」と - // 言い続けることになる。 + // 引き継ぎは切替後の最初のユーザープロンプトにだけ添える。systemPrompt では + // ないのは、resume 済み Codex thread など provider によっては再開時の + // systemPrompt を読まないため。画面のログには元の入力だけを積んであるので、 + // この内部添付がユーザー発言として二重表示されることはない。 const handoff = this.handoff; this.handoff = undefined; const systemPrompt = composeSystemPrompt({ ignoredFiles: opts?.ignoredFiles, repoPrompt: opts?.appendSystemPrompt, - handoff, }); this.run = this.adapter.open({ cwd: this.state.worktreePath, - prompt: this.inputQueue, + prompt: withHandoff(this.inputQueue, handoff), resume, options: { model, From 06819e7f5a29183ec50de5eafadc5f01701c7818 Mon Sep 17 00:00:00 2001 From: takecchi Date: Fri, 21 Aug 2026 17:39:45 +0900 Subject: [PATCH 2/3] fix: deliver agent handoff through adapters --- src/core/agent-handoff.ts | 9 ++++++++- src/core/agent-ports.ts | 2 ++ src/core/claude-adapter.ts | 12 +++++++++--- src/core/codex-adapter.ts | 8 +++++++- src/core/grok-adapter.ts | 6 +++++- src/core/session.spec.ts | 19 ++++++++----------- src/core/session.ts | 19 ++----------------- 7 files changed, 41 insertions(+), 34 deletions(-) diff --git a/src/core/agent-handoff.ts b/src/core/agent-handoff.ts index 6e1b93c..cf46005 100644 --- a/src/core/agent-handoff.ts +++ b/src/core/agent-handoff.ts @@ -42,6 +42,11 @@ export interface HandoffInput { messages?: readonly LogEntry[]; } +/** Provider に送る最初の指示へ、内部の引き継ぎ情報を前置する。 */ +export function attachHandoff(text: string, handoff: string | undefined): string { + return handoff ? `${handoff}\n\n# Current instruction after the switch\n\n${text}` : text; +} + /** 1 行に畳んで長すぎるものを切る(systemPrompt の箇条書きに収めるため)。 */ function field(text: string | undefined): string | undefined { const flat = text?.replace(/\s+/g, ' ').trim(); @@ -100,7 +105,9 @@ export function handoffTranscript(messages: readonly LogEntry[]): string | undef } const omitted = kept.length < turns.length; return [ - ...(omitted ? ['[Earlier conversation omitted because the handover reached its size limit.]'] : []), + ...(omitted + ? ['[Earlier conversation omitted because the handover reached its size limit.]'] + : []), ...kept, ].join('\n\n'); } diff --git a/src/core/agent-ports.ts b/src/core/agent-ports.ts index e8434ef..eb41bdb 100644 --- a/src/core/agent-ports.ts +++ b/src/core/agent-ports.ts @@ -96,6 +96,8 @@ export interface AgentRunOptions { maxBudgetUsd?: number; /** worktree の環境説明 + リポジトリ追加指示(`core/system-prompt.ts`)。 */ systemPrompt?: string; + /** `/agent` 切替後の最初の provider 向け指示にだけ添える会話の引き継ぎ。 */ + handoff?: string; } /** `AgentAdapter.open` への入力。 */ diff --git a/src/core/claude-adapter.ts b/src/core/claude-adapter.ts index 7add57c..9963280 100644 --- a/src/core/claude-adapter.ts +++ b/src/core/claude-adapter.ts @@ -7,6 +7,7 @@ import type { SettingSource, } from '@anthropic-ai/claude-agent-sdk'; import type { AgentEvent } from './agent-events'; +import { attachHandoff } from './agent-handoff'; import type { AgentAdapter, AgentAvailability, @@ -64,9 +65,14 @@ function toUserMessage(text: string): SDKUserMessage { return { type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null }; } -async function* toSdkPrompt(prompt: AsyncIterable): AsyncIterable { +async function* toSdkPrompt( + prompt: AsyncIterable, + handoff?: string, +): AsyncIterable { + let pending = handoff; for await (const text of prompt) { - yield toUserMessage(text); + yield toUserMessage(attachHandoff(text, pending)); + pending = undefined; } } @@ -129,7 +135,7 @@ export function createClaudeAdapter(deps: { const opts = request.options; const handle = deps.queryFn({ - prompt: toSdkPrompt(request.prompt), + prompt: toSdkPrompt(request.prompt, request.options.handoff), options: { cwd: request.cwd, permissionMode: opts.permissionMode ?? 'acceptEdits', diff --git a/src/core/codex-adapter.ts b/src/core/codex-adapter.ts index cffb8af..cdff1c5 100644 --- a/src/core/codex-adapter.ts +++ b/src/core/codex-adapter.ts @@ -1,4 +1,5 @@ import type { AgentEvent } from './agent-events'; +import { attachHandoff } from './agent-handoff'; import type { AgentAdapter, AgentAvailability, @@ -147,6 +148,7 @@ export function createCodexAdapter(deps: { // ターンをまたいで持ち回るもの。`threadId` は resume の鍵で、`thread.started` を // 見るたびに更新する(初回は request.resume = 復元されたセッションの id)。 let threadId = request.resume; + let handoff = request.options.handoff; let model = request.options.model; // `Ctrl+C` で殺したターンは失敗ではない(Session が先に `interrupted` を確定させる)。 let interrupted = false; @@ -240,7 +242,10 @@ export function createCodexAdapter(deps: { // 付かず、次のターンは**新しいスレッド**として始まる。latch していると // そこで前置されず、systemPrompt を一度も渡せないセッションになる // (symlink 共有の注意書きが落ちると、リンク越しに元リポジトリを壊しうる)。 - const prompt = threadId ? text : withSystemPrompt(text, request.options.systemPrompt); + const userPrompt = attachHandoff(text, handoff); + const prompt = threadId + ? userPrompt + : withSystemPrompt(userPrompt, request.options.systemPrompt); const proc = deps.spawn({ cwd: request.cwd, @@ -265,6 +270,7 @@ export function createCodexAdapter(deps: { } if (event.type === 'thread.started') { threadId = event.thread_id; + handoff = undefined; probeDuringTurn(event.thread_id); } else if (event.type === 'turn.completed' || event.type === 'turn.failed') { sawTerminal = true; diff --git a/src/core/grok-adapter.ts b/src/core/grok-adapter.ts index 13b391d..468afb3 100644 --- a/src/core/grok-adapter.ts +++ b/src/core/grok-adapter.ts @@ -1,4 +1,5 @@ import type { AgentEvent } from './agent-events'; +import { attachHandoff } from './agent-handoff'; import type { AgentAdapter, AgentAvailability, @@ -525,6 +526,7 @@ export function createGrokAdapter(deps: { /** プロンプトの流れを 1 本のターン列として回す。 */ const drive = async (): Promise => { + let handoff = request.options.handoff; try { for await (const text of request.prompt) { if (request.abortController.signal.aborted) { @@ -546,7 +548,9 @@ export function createGrokAdapter(deps: { continue; } } - await runTurn(text); + const prompt = attachHandoff(text, handoff); + handoff = undefined; + await runTurn(prompt); } } catch (error: unknown) { // **`finally` で閉じる前に積む**。閉じたキューへの push は黙って捨てられる diff --git a/src/core/session.spec.ts b/src/core/session.spec.ts index b4a7b0f..ab59bee 100644 --- a/src/core/session.spec.ts +++ b/src/core/session.spec.ts @@ -938,6 +938,7 @@ describe('Session.setAgent', () => { const seen: string[] = []; const resumes: (string | undefined)[] = []; const systemPrompts: (string | undefined)[] = []; + const handoffs: (string | undefined)[] = []; const adapter: AgentAdapter = { id, displayName: id, @@ -946,6 +947,7 @@ describe('Session.setAgent', () => { open(request: AgentRunRequest) { resumes.push(request.resume); systemPrompts.push(request.options.systemPrompt); + handoffs.push(request.options.handoff); return { async *[Symbol.asyncIterator]() { for await (const text of request.prompt) { @@ -958,7 +960,7 @@ describe('Session.setAgent', () => { }; }, }; - return { adapter, seen, resumes, systemPrompts }; + return { adapter, seen, resumes, systemPrompts, handoffs }; } it('routes the next instruction to the new agent, not the old one', async () => { @@ -974,8 +976,7 @@ describe('Session.setAgent', () => { await tick(); // 切替前のストリームは畳まれているので、古いエージェントには届かない。 - expect(b.seen).toHaveLength(1); - expect(b.seen[0]).toContain('# Current instruction after the switch\n\nnow you'); + expect(b.seen).toEqual(['now you']); expect(a.seen).toEqual(['do the thing']); expect(session.getState().agent).toBe('codex'); }); @@ -995,17 +996,16 @@ describe('Session.setAgent', () => { session.send('now you'); await tick(); - const briefing = b.seen[0]; + const briefing = b.handoffs[0]; expect(briefing).toContain('taking over this session from claude'); expect(briefing).toContain('- Branch: codiva/t'); expect(briefing).toContain('- Original task: do the thing'); - expect(briefing).toContain('User:\ndo the thing'); - expect(briefing).toContain('Assistant:\nclaude answered: do the thing'); + expect(briefing).not.toBeUndefined(); // 2 回目のターン(同じエージェント)には持ち越さない — 引き継ぎは済んでいる。 session.send('and this'); await tick(); - expect(b.seen[1]).toBe('and this'); + expect(b.handoffs.slice(1).every((p) => p === undefined)).toBe(true); }); it('resumes the previous conversation when switching back', async () => { @@ -1025,10 +1025,7 @@ describe('Session.setAgent', () => { // 2 回目の Claude は自分が発行した id で resume する(別 provider の id は渡さない)。 expect(a.resumes).toEqual([undefined, 'claude-thread']); expect(b.resumes).toEqual([undefined]); - expect(a.seen[0]).toBe('do the thing'); - expect(a.seen[1]).toContain('User (codex):\nto codex'); - expect(a.seen[1]).toContain('Assistant (codex):\ncodex answered: to codex'); - expect(a.seen[1]).toContain('# Current instruction after the switch\n\nback to claude'); + expect(a.seen).toEqual(['do the thing', 'back to claude']); }); it('stops the in-flight turn and hands queued follow-ups to the NEW agent', async () => { diff --git a/src/core/session.ts b/src/core/session.ts index 99bccac..3b2ae9e 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -43,22 +43,6 @@ export type PermissionPolicy = ( const defaultPolicy: PermissionPolicy = (toolName) => toolName === 'AskUserQuestion' ? 'ask' : 'allow'; -/** Attach a provider-switch handoff to exactly the first prompt the new run consumes. */ -async function* withHandoff( - prompt: AsyncIterable, - handoff: string | undefined, -): AsyncIterable { - let pending = handoff; - for await (const text of prompt) { - if (pending) { - yield `${pending}\n\n# Current instruction after the switch\n\n${text}`; - pending = undefined; - } else { - yield text; - } - } -} - /** Per-session knobs forwarded to the SDK query (sourced from the config file). */ export interface SessionOptions { model?: string; @@ -585,7 +569,7 @@ export class Session { }); this.run = this.adapter.open({ cwd: this.state.worktreePath, - prompt: withHandoff(this.inputQueue, handoff), + prompt: this.inputQueue, resume, options: { model, @@ -593,6 +577,7 @@ export class Session { permissionMode: opts?.permissionMode, maxBudgetUsd: opts?.maxBudgetUsd, systemPrompt, + handoff, }, requestPermission: this.requestPermission, abortController: this.abortController, From ac1b1560448d17c08dfa6a20a3ef7d0728945a33 Mon Sep 17 00:00:00 2001 From: takecchi Date: Fri, 21 Aug 2026 19:36:09 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E4=BC=9A=E8=A9=B1=E5=BC=95=E3=81=8D?= =?UTF-8?q?=E7=B6=99=E3=81=8E=E3=81=AE=E5=8F=96=E3=82=8A=E3=81=93=E3=81=BC?= =?UTF-8?q?=E3=81=97=E3=81=A8=20Codex=20=E3=81=AE=20argv=20=E4=B8=8A?= =?UTF-8?q?=E9=99=90=E3=81=AB=E5=AF=BE=E5=87=A6=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit エージェント切替の引き継ぎ(#133)をレビューして見つかった問題を直す。 - 引き継ぎの予算を**文字数から UTF-8 バイト**へ(`MAX_HANDOFF_TRANSCRIPT_BYTES`)。 Codex は指示文を argv で渡すので、日本語(1 文字 3 バイト)の会話が 80,000 文字 まで載ると最大 240 KiB になり、Linux の `MAX_ARG_STRLEN`(131,072 バイト)に 当たって `codex exec` の起動そのものが `E2BIG` で落ちる。しかも解除点が `thread.started` なので、以後どのターンも同じ理由で落ち続けてセッションが詰む。 macOS には per-arg 上限が無いため手元では再現しない。 - Grok: 立ち上げ中に `Ctrl+C` されたターンは `runTurn` が丸ごと捨てるのに、引き継ぎだけ 無条件に消費していた(1 回きりなので切替の文脈が黙って失われる)。`runTurn` が 「実際に投げたか」を返し、投げたときだけ落とす。 - 引き継ぎは provider にはユーザーメッセージとして届くので CLI のトランスクリプトにも そう残る。復元で `stripHandoff` を通し、詳細ビューと `lastUserInstruction` に 漏れないようにする(漏れると次の切替で引き継ぎが入れ子に写る)。 - 会話を載せられなかったときに「会話を下に写した」と名乗らないようにする (ログの無い復元セッションで嘘になる)。 - `/agent` ダイアログの注意書きが「会話の文脈は引き継がれません」のままだった (ja/en とも更新)。ユーザーが実際に読む唯一の説明。 - `composeSystemPrompt` の `handoff` 引数が呼ばれなくなっていたので削除。 - 実測を追記: `codex exec resume` も `thread.started` を出す(0.148.0)。 - アダプタ 3 本の引き継ぎ受け渡しにテストが 1 件も無かったので追加 (Claude / Codex / Grok + 中断・スレッド未開始の経路)。 - docs / rules / CLAUDE.md を実装に合わせて更新。 --- .claude/rules/sdk-integration.md | 15 +++- CLAUDE.md | 2 +- README.ja.md | 2 +- README.md | 2 +- docs/ARCHITECTURE.md | 43 ++++++--- docs/TASKS.md | 22 +++-- docs/TECH_NOTES.md | 9 ++ src/core/agent-handoff.spec.ts | 115 ++++++++++++++++++++++-- src/core/agent-handoff.ts | 148 ++++++++++++++++++++++++------- src/core/claude-adapter.spec.ts | 53 +++++++++-- src/core/codex-adapter.spec.ts | 51 +++++++++++ src/core/codex-adapter.ts | 6 ++ src/core/grok-adapter.spec.ts | 25 ++++++ src/core/grok-adapter.ts | 23 +++-- src/core/i18n.ts | 9 +- src/core/session.spec.ts | 37 +++++++- src/core/session.ts | 18 ++-- src/core/system-prompt.ts | 17 ++-- src/core/transcript.ts | 5 +- tests/app.test.tsx | 4 +- 20 files changed, 504 insertions(+), 102 deletions(-) diff --git a/.claude/rules/sdk-integration.md b/.claude/rules/sdk-integration.md index 00028be..093b0e5 100644 --- a/.claude/rules/sdk-integration.md +++ b/.claude/rules/sdk-integration.md @@ -181,7 +181,20 @@ provider のメッセージ ──[アダプタの parse]──▶ AgentEvent[] 載る。**`'project'` は必ず含める**)、 `includePartialMessages: true`(ストリーミングプレビュー用)。**この既定を組み立てるのはアダプタ**で、 `Session` は provider 非依存の `AgentRunOptions`(model / effort / permissionMode / maxBudgetUsd / - systemPrompt)しか渡さない。各項目をどう解釈するか(無視も可)はアダプタの裁量。 + systemPrompt / handoff)しか渡さない。各項目をどう解釈するか(無視も可)はアダプタの裁量。 +- **`handoff`(`/agent` の引き継ぎ)だけは「無視も可」ではない。** これは `core/agent-handoff.ts` + が組み立てた**1 回きり**の文字列で、アダプタが `attachHandoff(text, handoff)` で + **切替後の最初のユーザープロンプトに前置する**のが契約。`systemPrompt` に混ぜてはいけない + (`codex exec resume` のように再開時に systemPrompt を読み直さない provider があり、 + 往復切替でだけ引き継ぎが消える)。守ること 2 つ: + - **落とすのは「provider へ実際に渡った」と確認できたときだけ**。立ち上げ中に中断された + ターン(Grok の `runTurn` が捨てる経路)や `thread.started` の前に落ちたターン(Codex)で + 無条件に落とすと、1 回きりの引き継ぎを空振りで使い切って**切替の文脈が黙って消える** + (`Session` 側の使い捨ては `open()` の時点で済んでいるので二度と来ない)。 + - **大きさは UTF-8 バイトで見積もる**。Codex は指示文を argv で渡すので Linux の + `MAX_ARG_STRLEN`(131,072 バイト)に当たると起動そのものが `E2BIG` で落ちる + (日本語は 1 文字 3 バイト = 文字数の 3 倍。macOS では再現しない)。 + 予算は `MAX_HANDOFF_TRANSCRIPT_BYTES`。 - `systemPrompt` は**純粋な `core/system-prompt.ts` の `composeSystemPrompt()` で組み立てる** (`session.ts` に文言や結合順を書かない)。要素は「worktree の環境説明(`ignoredFiles: 'symlink'` のときだけ載る共有 symlink の注意書き)」→「`/.codiva/prompt.md` の内容」の順で、 diff --git a/CLAUDE.md b/CLAUDE.md index 1377f50..f328922 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,7 @@ CI(`.github/workflows/ci.yml`)は `lint → typecheck → test → build`。 | SDK メッセージの解釈 | `core/claude-parse.ts` **のみ**(`parseClaudeMessage`: SDKMessage → `AgentEvent[]`)+ `core/__fixtures__/*.jsonl`。Codex は `core/codex-parse.ts`(`parseCodexEvent`: `codex exec --json` の JSONL → `AgentEvent[]`)+ `core/__fixtures__/codex-*.jsonl`。Grok は `core/grok-parse.ts`(`createGrokParser`: ACP = JSON-RPC over stdio の通知 → `AgentEvent[]`)+ `core/__fixtures__/grok-*.jsonl` | | capability による UI 縮退(コスト・プラン/使用状況・確認モード・ログ復元) | `core/agent-capabilities.ts`(`supportsCapability` = **不明なら縮退しない** / `capabilityLookup` / `agentSupports` / `showsAccountInfo` = プラン + 使用状況は**既定エージェント**で出し分け・純粋)/ `core/cost.ts` の `totalCostUsd(states, reportsCost)` / `bootstrap/usage-poller.ts` の `enabled` / `bootstrap/restore-sessions.ts` / `ui/status-footer.tsx` の `confirmSupported` | | どのセッションが何で走っているかの表示 | `core/agent-display.ts`(`sessionAgentId` / `usesMultipleAgents`)/ `core/layout.ts` の `showsAgentColumn`(混在時だけ列を出す)/ `core/banner-lines.ts` の `agent`(ヘッダ = 既定。エージェント名・プラン・モデル・使用状況は `ui/hooks.ts` の `useDefaultAgent` / `useDefaultModel` を購読して**揃って**切り替わる)/ `core/scroll.ts` の `logLines(…, dividerFor)`(ログの切替区切り)/ `m.detail.followupPlaceholder(agent)`(詳細の入力欄) | -| エージェント切替時の引き継ぎ | `core/agent-handoff.ts`(`handoffInstruction` / `lastUserInstruction`・英語固定 = AI 向け文字列)/ `core/system-prompt.ts` の `handoff` 節 / `core/session.ts` の `setAgent`(**使い捨て**で次の `open()` が消費) | +| エージェント切替時の引き継ぎ | `core/agent-handoff.ts`(`handoffInstruction` / `handoffTranscript` = 会話ログの写し・`attachHandoff` / `lastUserInstruction`・英語固定 = AI 向け文字列)/ `core/agent-ports.ts` の `AgentRunOptions.handoff`(**systemPrompt ではない**。各アダプタが切替後の最初のユーザープロンプトに `attachHandoff` で前置する)/ `core/session.ts` の `setAgent`(**使い捨て**で次の `open()` が消費) | | エージェントの切替(`/agent`)| `core/session-manager.ts`(一覧=既定: `getDefaultAgentId` / `setDefaultAgent`・詳細=切替: `listAgents` / `getSessionAgent` / `setSessionAgent`)/ `ui/agent-select.tsx`(`mode:'default'`=一覧 / `'session'`=詳細)/ `core/status-reducer.ts` の `agent_switched` | | エージェントの導入・ログイン検出 | `core/agent-ports.ts` の `AgentAdapter.checkAvailability` / `AgentAvailability` / `core/agent-availability.ts`(`resolveDefaultAgentId` / `noAgentInstalled`・純粋)/ `utils/claude.ts` の `detectClaudeAvailability`・`utils/codex.ts` の `detectCodexAvailability`・`utils/grok.ts` の `detectGrokAvailability`(実 I/O)/ `SessionManager.checkAgents`(集約・キャッシュ)/ `ui/hooks.ts` の `useAgentAvailability` | | エージェントに codiva 内でサインイン(`/login` / `/agent` の `l`)| `core/agent-login.ts`(URL/コード抽出・ANSI 除去・純粋)/ `utils/agent-login.ts`(`spawnLogin` = プロセス起動)/ `ui/login-dialog.tsx` / `core/agent-ports.ts` の `AgentAdapter.login` + `AgentLoginProcess` / `SessionManager.startLogin` / `canLogin` / `refreshAgents` | diff --git a/README.ja.md b/README.ja.md index ce52da8..a39f51a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -321,7 +321,7 @@ Codiva v0.3.1 3 セッション 一度使ったエージェントの会話 id はセッションごとに保存されるので、Claude → Codex → Claude と戻したときは**元の会話の続き**から再開します(codiva を再起動しても同じです)。 -provider 固有の会話文脈を直接移すことはできないため、codiva が保持している**ユーザーとアシスタント双方の会話ログを、切替先への 1 回限りの引き継ぎ情報としてコピーします**。ブランチ名・そのセッションの最初と直前の指示・「続ける前に `git status` / `git diff` で作業ツリーを確認すること」も一緒に渡します。引き継ぎが安全上限の 80,000 文字に達した場合は新しい会話を優先し、省略したことを明記します(切替直後に余分なターンは走らず、次に入力した指示と一緒に渡ります)。 +provider 固有の会話文脈を直接移すことはできないため、codiva が保持している**ユーザーとアシスタント双方の会話ログを、切替先への 1 回限りの引き継ぎ情報としてコピーします**。ブランチ名・そのセッションの最初と直前の指示・「続ける前に `git status` / `git diff` で作業ツリーを確認すること」も一緒に渡します。ツールの実行ログは含めません(量が大きく、作業ツリーを見れば分かるため)。引き継ぎが安全上限に達した場合は新しい会話を優先し、省略したことを明記します(切替直後に余分なターンは走らず、次に入力した指示と一緒に渡ります)。 **どのセッションが何で走っているかは画面で分かります。** diff --git a/README.md b/README.md index 8096b2c..a9438be 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,7 @@ Here's what does and doesn't carry over when you switch with `/agent`: The conversation id of each agent you've used is stored per session, so going Claude → Codex → Claude resumes **the original conversation** where it left off (this survives restarting codiva too). -The provider-native context can't be transferred directly, so **codiva copies the retained user and assistant conversation into a one-time handoff** for the incoming agent. It also includes the branch name, the first and most recent instructions, and a reminder to verify the working tree with `git status` / `git diff`. The newest conversation is prioritized and an omission marker is shown if the handoff reaches its 80,000-character safety limit. No extra turn runs at switch time; the handoff rides along with the next instruction you send. +The provider-native context can't be transferred directly, so **codiva copies the retained user and assistant conversation into a one-time handoff** for the incoming agent. It also includes the branch name, the first and most recent instructions, and a reminder to verify the working tree with `git status` / `git diff`. Tool-execution logs are left out (they're bulky, and the working tree tells the same story). The newest conversation is prioritized, and an omission marker is shown if the handoff reaches its safety limit. No extra turn runs at switch time; the handoff rides along with the next instruction you send. **You can always see what each session is running on.** diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b3abdd2..1fc44bc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -65,7 +65,7 @@ codiva/ │ │ ├── agent-events.ts # AgentEvent の語彙 + applyAgentEvent()(全 provider 共通の畳み込み・純粋) │ │ ├── agent-capabilities.ts # capability による UI 縮退の判定(不明なら縮退しない・showsAccountInfo) │ │ ├── agent-display.ts # 「どのセッションが何で走っているか」の判定(sessionAgentId / usesMultipleAgents) -│ │ ├── agent-handoff.ts # 切替先へ渡す状況説明(英語固定・systemPrompt に 1 回だけ載る) +│ │ ├── agent-handoff.ts # 切替先へ渡す状況説明 + 会話ログの写し(英語固定・切替後の最初の指示に 1 回だけ載る) │ │ ├── claude-adapter.ts # Claude 用 AgentAdapter(query() の組み立て・canUseTool の写像) │ │ ├── claude-parse.ts # parseClaudeMessage()(SDK メッセージ形状の解釈を集約・純粋) │ │ ├── claude-errors.ts # Claude CLI の失敗分類(文言/typed kind/HTTP status → AgentStopCause) @@ -243,9 +243,10 @@ Claude のログインは env / 資格情報ファイルで分かるときだけ | 引き継がれるもの | 引き継がれないもの | |---|---| -| worktree・ブランチ・作業ツリーの内容 | モデル側の会話文脈(provider ごとに別のトランスクリプト) | +| worktree・ブランチ・作業ツリーの内容 | provider 固有のセッションそのもの(各 CLI が別のトランスクリプトを持つ) | | codiva 側のログ(`messages`)・タイトル・PR・稼働時間 | `sdkSessionId`(切替先の `agentSessions` に無ければ undefined =新しい会話) | -| `agentSessions`(provider ごとの resume id) | `streamingText`(前のエージェントの途中表示) | +| 会話の中身(`messages` の user / assistant_text を写して渡す ⇒ 下記の引き継ぎ) | `streamingText`(前のエージェントの途中表示) | +| `agentSessions`(provider ごとの resume id) | ツール実行・system / error のログ行(量が大きく、作業ツリーを見れば足りる) | | セッションの状態(`SessionStatus`)| `model`(解決済みモデルは provider ごとに別物。次のターンが埋める) | 戻ってきたときに続きから再開できるよう、`agentSessions: Partial>` に @@ -267,18 +268,38 @@ provider ごとの resume id を控え、**これは永続化する**(`state.j セッションのログ行の形を変えないため(切替を使っていないユーザーには何も増えない)。 詳細ビューはこの帰属が変わる境界に区切り行(`── ここから Codex ──`)を 1 本挿む (行の挿入は `core/scroll.ts` の `logLines(…, dividerFor)`、文言はカタログ + アダプタの表示名)。 -- **引き継ぎの状況説明を 1 回だけ渡す**(`core/agent-handoff.ts` の `handoffInstruction`)。 - 切替先は前の会話を持たないので、何も渡さないと「途中まで作業された作業ツリー」を白紙から - 見ることになり、済んだ作業をやり直したり直前の指示を無視したりする。ブランチ・最初の指示・ - 直前の指示を並べ、**続ける前に自分で `git status` / `git diff` を読む**よう促す文を - `AgentRunOptions.systemPrompt`(`composeSystemPrompt` の最後の節)に載せる。 +- **引き継ぎを 1 回だけ渡す**(`core/agent-handoff.ts` の `handoffInstruction`)。切替先は + provider 固有の会話を持てないので、何も渡さないと「途中まで作業された作業ツリー」を白紙から + 見ることになり、済んだ作業をやり直したり直前の指示を無視したりする。渡すのは + ブランチ・最初の指示・直前の指示に加えて、**codiva 側のログから写した会話そのもの** + (`handoffTranscript` = `user` / `assistant_text` だけ。ツール実行・system 行は落とす)と、 + **続ける前に自分で `git status` / `git diff` を読む**よう促す文。 + - **`AgentRunOptions.handoff` で渡し、アダプタが切替後の最初のユーザープロンプトに前置する** + (`attachHandoff`)。**systemPrompt には載せない** — `codex exec resume` のように再開時に + systemPrompt を読み直さない provider があり、往復切替でだけ引き継ぎが消える。 + アダプタを増やすときは `request.options.handoff` の扱いを必ず実装する(番人は 3 つの + `*-adapter.spec.ts`)。 - **使い捨て**にする(`Session` が次の `open()` で消費する)。常設にすると、引き継ぎが済んだ あとのターンや通信断からの再起動でも「前任者から引き継いだ」と言い続けることになる。 + ただし**アダプタ側では「実際に provider へ渡るまで」持つ** — 立ち上げ前に中断された + ターン(Grok)や `thread.started` 前に落ちたターン(Codex)で捨てると、1 回きりの + 引き継ぎを空振りで使い切ってしまう。 - **キューへ指示として積まない**。積むと切替直後に「状況を読むだけのターン」が 1 本走り、 provider のプロセスを無駄に立てる(ユーザーが次の指示を出すまで何も起こらないのが正しい)。 - - 各項目は 1 行に畳んで `MAX_HANDOFF_FIELD_CHARS` で切る(指示文はファイルを丸ごと貼った - ものになりうるので、systemPrompt が本文より大きくなるのを防ぐ)。AI 向けの文字列なので - i18n カタログには置かない(英語固定。`SHARED_IGNORED_FILES_NOTICE` と同じ扱い)。 + - 各項目は 1 行に畳んで `MAX_HANDOFF_FIELD_CHARS` で切り、会話は + **`MAX_HANDOFF_TRANSCRIPT_BYTES` = UTF-8 バイトの予算**で新しい方から詰める(切ったことは + 1 行で明示する)。**文字数ではなくバイト数**なのは、Codex が指示文を argv で渡すため + (Linux の `MAX_ARG_STRLEN` = 131,072 バイト。日本語なら文字数の 3 倍になる。 + docs/TECH_NOTES.md 参照)。 + - **往復切替では重複を許す**。切替先が自分のスレッドを resume できるときはそのぶん文脈が + 重なるが、resume が失敗した・圧縮で落ちた場合に「足りない」方が害が大きいので全部渡し、 + 重複が新しい指示ではないことは引き継ぎ文の中で断る。 + - 引き継ぎは provider には**ユーザーメッセージ**として届くので CLI のトランスクリプトにも + そう残る。ログ復元(`core/transcript.ts`)は `stripHandoff` を通し、ユーザーが実際に + 打った指示だけを積む(通さないと詳細ビューに巨大な引き継ぎが「ユーザー発言」として並び、 + `lastUserInstruction` もそれを拾って次の引き継ぎが入れ子になる)。 + - AI 向けの文字列なので i18n カタログには置かない(英語固定。`SHARED_IGNORED_FILES_NOTICE` + と同じ扱い)。 ### 5. Claude 専用機能は capability で optional 化する diff --git a/docs/TASKS.md b/docs/TASKS.md index 9d8aeed..b698f67 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -1296,7 +1296,7 @@ zsh: abort codiva - [x] `Ctrl+C` の縮退: `interrupt` を持たない provider では中断のヒント行を出さない - [x] `/agent` コマンド(`add-slash-command` skill の手順で追加): **詳細ビュー**から駆動エージェントを 切り替える(`ui/agent-select.tsx`。`ModelSelect` と同じ単一選択モーダル)。確認は挟まず、 - ダイアログ内に「会話の文脈は引き継がれません」の注意書きを常時出す + ダイアログ内に「何が引き継がれるか」の注意書きを常時出す(`m.agent.warning`) - [x] 設定 `~/.codiva/config.json` に既定エージェント `agent`(`add-config-option` skill の手順。 Codex 用の `codexSandbox` / `codexNetworkAccess` も同時に追加) - [x] **一覧ビューの `/agent`** = 新規セッションの既定を選ぶ(`AgentSelect` の `mode:'default'`)。 @@ -1335,14 +1335,24 @@ zsh: abort codiva **色付き出力の ANSI を剥がしてから URL/コードを拾う**(実測: 拾えず → 修正)。 完了後 `refreshAgents` で状態を再判定 - [x] **引き継ぎプロンプトの生成**: `core/agent-handoff.ts`(`handoffInstruction` / - `lastUserInstruction`・純粋・英語固定 = AI 向け文字列なので i18n 対象外)。ブランチ・ - 最初の指示・直前の指示を並べ、**続ける前に自分で `git status` / `git diff` を読む**よう - 促す。渡し方は `AgentRunOptions.systemPrompt`(`composeSystemPrompt` の最後の節)で、 - `Session` が**使い捨て**で持ち次の `open()` で消費する。 + `handoffTranscript` / `attachHandoff` / `stripHandoff` / `lastUserInstruction`・純粋・ + 英語固定 = AI 向け文字列なので i18n 対象外)。ブランチ・最初の指示・直前の指示に加えて + **codiva 側のログから写した会話**(`user` / `assistant_text`)を並べ、**続ける前に自分で + `git status` / `git diff` を読む**よう促す。渡し方は `AgentRunOptions.handoff` で、 + `Session` が**使い捨て**で持ち次の `open()` で消費し、アダプタが切替後の最初のユーザー + プロンプトに前置する(`attachHandoff`)。 + - **systemPrompt には載せない** — `codex exec resume` のように再開時に systemPrompt を + 読み直さない provider があり、往復切替でだけ引き継ぎが消える - キューへ指示として積まない(積むと切替直後に「状況を読むだけのターン」が 1 本走り、 provider のプロセスを無駄に立てる) - 常設にしない(引き継ぎ後のターンや通信断からの再起動でも「前任者から引き継いだ」と - 言い続けてしまう)。各項目は 1 行に畳んで `MAX_HANDOFF_FIELD_CHARS` で切る + 言い続けてしまう)。各項目は 1 行に畳んで `MAX_HANDOFF_FIELD_CHARS` で切り、会話は + `MAX_HANDOFF_TRANSCRIPT_BYTES`(**UTF-8 バイト**。Codex の argv 上限のため)で + 新しい方から詰めて省略を明示する + - アダプタ側では「provider へ実際に渡るまで」持つ(中断で捨てたターンや `thread.started` + 前に落ちたターンで捨てると、1 回きりの引き継ぎを空振りで使い切る) + - 復元は `stripHandoff` を通す(引き継ぎは CLI のトランスクリプトにユーザー発言として + 残るため、通さないと詳細ビューと `lastUserInstruction` に漏れて入れ子になる) - [x] i18n: `AgentLabel` を `DEFAULT_AGENT_LABEL` 固定ではなく**セッションのエージェント**から 引くよう配線(`agentLabelOf()` + `SessionManager.getSessionAgentLabel()`)。認証切れの案内は 一覧・詳細・デスクトップ通知の 3 経路すべてで駆動中の provider を出す — Codex のセッションに diff --git a/docs/TECH_NOTES.md b/docs/TECH_NOTES.md index b904caf..bcbbe0b 100644 --- a/docs/TECH_NOTES.md +++ b/docs/TECH_NOTES.md @@ -930,6 +930,15 @@ codex exec --json --skip-git-repo-check `resume -- ` の形でも同じに効く(実測で確認済み)。 - `resume` はサブコマンド(`codex exec [OPTIONS] resume `)だが、オプションは global なので前に置ける。プロンプトは**必ず最後の位置引数**。 +- **`resume` でも `thread.started` は出る**(実測 0.148.0。同じ `thread_id` が返る)。 + 1 ターン目の頭にだけ何かを前置したいとき(systemPrompt / エージェント切替の引き継ぎ)は、 + このイベントを「CLI がプロンプトを受け取った」合図として使ってよい。 +- **プロンプトは argv で渡るので実サイズに上限がある**。Linux の `execve` は引数 1 本あたり + `MAX_ARG_STRLEN`(32 ページ = 131,072 バイト)を超えると `E2BIG` で失敗する + (`ulimit` では上げられないカーネル定数。macOS は per-arg 上限が無く合計 1 MiB なので + **手元では再現しない**)。日本語は 1 文字 3 バイトなので「文字数」で予算を組むと 3 倍 + 外す。systemPrompt とエージェント切替の引き継ぎを前置するときは、**UTF-8 バイト**で + 予算を持つこと(`MAX_HANDOFF_TRANSCRIPT_BYTES`)。 - **stdin は `'ignore'` で開く**。プロンプトを引数で渡していても、パイプされた stdin があると codex は追加入力として読もうとして `Reading additional input from stdin...` と出したままブロックする。 - **stdout = JSONL、stderr = ログ**(`2026-…Z ERROR codex_login::auth::manager: …` のような tracing 行)。 diff --git a/src/core/agent-handoff.spec.ts b/src/core/agent-handoff.spec.ts index e562026..7db2ba0 100644 --- a/src/core/agent-handoff.spec.ts +++ b/src/core/agent-handoff.spec.ts @@ -1,19 +1,26 @@ import { describe, expect, it } from 'vitest'; import { + attachHandoff, handoffInstruction, handoffTranscript, lastUserInstruction, MAX_HANDOFF_FIELD_CHARS, - MAX_HANDOFF_TRANSCRIPT_CHARS, + MAX_HANDOFF_TRANSCRIPT_BYTES, + stripHandoff, } from './agent-handoff'; -import type { LogEntry } from './types'; +import { MAX_LOG_ENTRY_CHARS } from './log-buffer'; +import type { AgentId, LogEntry } from './types'; -const entry = (seq: number, kind: LogEntry['kind'], text: string): LogEntry => ({ +const entry = (seq: number, kind: LogEntry['kind'], text: string, agent?: AgentId): LogEntry => ({ seq, kind, text, + ...(agent ? { agent } : {}), }); +/** UTF-8 のバイト長(引き継ぎの予算はこの単位で測る)。 */ +const bytes = (text: string): number => new TextEncoder().encode(text).length; + describe('lastUserInstruction', () => { it('最後のユーザー行を返す', () => { const messages = [ @@ -72,7 +79,22 @@ describe('handoffInstruction', () => { expect(text).not.toContain('Most recent instruction'); }); - it('長い指示は 1 行に畳んで切る(systemPrompt が本文より大きくならないように)', () => { + // 会話が無いのに「下に会話を写した」と名乗ると嘘になる(復元に失敗した復元セッション等)。 + it('会話が無いときは「文脈は渡せない」と正直に言う', () => { + const withoutLog = handoffInstruction({ from: 'Claude', task: 'ログイン画面を作る' }); + expect(withoutLog).toContain('history is NOT available to you'); + expect(withoutLog).not.toContain('Conversation before the switch'); + + const withLog = handoffInstruction({ + from: 'Claude', + task: 'ログイン画面を作る', + messages: [entry(1, 'user', 'ログイン画面を作る')], + }); + expect(withLog).toContain('copied the user/assistant conversation below'); + expect(withLog).toContain('Conversation before the switch'); + }); + + it('長い指示は 1 行に畳んで切る(概要が会話本体より大きくならないように)', () => { const text = handoffInstruction({ from: 'Claude', task: `${'あ'.repeat(MAX_HANDOFF_FIELD_CHARS + 50)}`, @@ -95,14 +117,91 @@ describe('handoffTranscript', () => { expect(text).toBe('User:\n依頼\n\nAssistant:\n回答'); }); + it('会話が 1 件も無ければ undefined', () => { + expect(handoffTranscript([])).toBeUndefined(); + // 本文が空白だけの行は「発言」ではないので落とす。 + expect(handoffTranscript([entry(1, 'user', ' \n ')])).toBeUndefined(); + }); + + // 帰属が入るのは**切替後のエージェントの発言だけ**(ユーザーの指示は誰が受けても + // 「ユーザー」なので `LogEntry.agent` は付かない)。境目はこの印で読める。 + it('切替後のエージェント発言には名前を添える(LogEntry.agent)', () => { + const text = handoffTranscript([ + entry(1, 'user', '最初の指示'), + entry(2, 'assistant_text', 'claude の回答'), + entry(3, 'user', 'codex への指示'), + entry(4, 'assistant_text', 'codex の回答', 'codex'), + ]); + expect(text).toBe( + 'User:\n最初の指示\n\nAssistant:\nclaude の回答\n\nUser:\ncodex への指示\n\nAssistant (codex):\ncodex の回答', + ); + }); + it('上限を超えたら新しい会話を優先して省略を明示する', () => { const text = handoffTranscript([ - entry(1, 'user', 'old'.repeat(MAX_HANDOFF_TRANSCRIPT_CHARS / 3)), - entry(2, 'assistant_text', 'middle'.repeat(MAX_HANDOFF_TRANSCRIPT_CHARS / 3)), + entry(1, 'user', 'o'.repeat(MAX_HANDOFF_TRANSCRIPT_BYTES)), + entry(2, 'assistant_text', 'm'.repeat(MAX_HANDOFF_TRANSCRIPT_BYTES)), entry(3, 'user', 'latest'), ]); - expect(text).toContain('Earlier conversation omitted'); + expect(text).toContain('Older conversation omitted'); expect(text).toContain('User:\nlatest'); - expect(text).not.toContain('oldoldold'); + expect(text).not.toContain('ooo'); + expect(text).not.toContain('mmm'); + }); + + // 予算は **UTF-8 バイト**で測る。文字数で測ると日本語(1 文字 3 バイト)の会話が + // 実サイズで 3 倍になり、指示文を argv で渡す `codex exec` が Linux の + // MAX_ARG_STRLEN(131,072 バイト)に当たって起動できなくなる。 + it('日本語でも予算を UTF-8 バイトで守る', () => { + const messages = Array.from({ length: 40 }, (_, i) => + entry(i + 1, i % 2 === 0 ? 'user' : 'assistant_text', 'あ'.repeat(MAX_LOG_ENTRY_CHARS)), + ); + const text = handoffTranscript(messages); + expect(text).toBeDefined(); + expect(bytes(text ?? '')).toBeLessThanOrEqual(MAX_HANDOFF_TRANSCRIPT_BYTES); + }); + + // ログの 1 件は MAX_LOG_ENTRY_CHARS で切られている(最悪 3 バイト/文字)。予算が + // それを上回っている限り「直近の 1 ターンは必ず入る」が保証できる。 + it('直近の 1 ターンは必ず入る(予算 > 1 件の上限)', () => { + expect(MAX_HANDOFF_TRANSCRIPT_BYTES).toBeGreaterThan(MAX_LOG_ENTRY_CHARS * 3); + const text = handoffTranscript([ + entry(1, 'user', 'い'.repeat(MAX_LOG_ENTRY_CHARS)), + entry(2, 'assistant_text', 'あ'.repeat(MAX_LOG_ENTRY_CHARS)), + ]); + // 古い方は落ちるが、最大サイズの 1 件でも直近は丸ごと残る。 + expect(text).toContain('Older conversation omitted'); + expect(text).not.toContain('いい'); + expect(text).toContain('あ'.repeat(MAX_LOG_ENTRY_CHARS)); + expect(bytes(text ?? '')).toBeLessThanOrEqual(MAX_HANDOFF_TRANSCRIPT_BYTES); + }); +}); + +describe('attachHandoff / stripHandoff', () => { + it('引き継ぎが無ければ素通し', () => { + expect(attachHandoff('やって', undefined)).toBe('やって'); + expect(stripHandoff('やって')).toBe('やって'); + }); + + // 引き継ぎは provider にはユーザーメッセージとして届くので、CLI のトランスクリプトにも + // そう残る。復元でそのまま積むと、詳細ビューにも `lastUserInstruction` にも漏れる。 + it('前置した指示から元の入力だけを取り出せる(復元の入口で使う)', () => { + const handoff = handoffInstruction({ + from: 'Claude', + branch: 'codiva/t', + task: '最初の指示', + messages: [entry(1, 'user', '最初の指示')], + }); + const sent = attachHandoff('次はこれ', handoff); + expect(sent).toContain('# Session handover (codiva)'); + expect(sent.endsWith('# Current instruction after the switch\n\n次はこれ')).toBe(true); + expect(stripHandoff(sent)).toBe('次はこれ'); + // 剥がしたあとの行は「直前の指示」としても正しく拾える。 + expect(lastUserInstruction([entry(1, 'user', stripHandoff(sent))])).toBe('次はこれ'); + }); + + it('見出しで始まらない入力は触らない(ユーザーの本文を削らない)', () => { + const text = '# Current instruction after the switch\n\nこれは普通の指示'; + expect(stripHandoff(text)).toBe(text); }); }); diff --git a/src/core/agent-handoff.ts b/src/core/agent-handoff.ts index cf46005..efe5b45 100644 --- a/src/core/agent-handoff.ts +++ b/src/core/agent-handoff.ts @@ -6,7 +6,13 @@ import type { LogEntry } from './types'; * なぜ要るか: 切替先は**前の会話を持たない**(モデル側の文脈は provider をまたげず、 * 各 CLI が自分のトランスクリプトを持つ)。共有されているのは worktree だけなので、 * 何も渡さないと切替先は「途中まで作業された作業ツリー」を白紙から見ることになり、 - * 済んだ作業をやり直したり、直前の指示を無視したりする。 + * 済んだ作業をやり直したり、直前の指示を無視したりする。そこで codiva 側が持っている + * 会話ログ(user / assistant_text)を写して渡す(`handoffTranscript`)。 + * + * **往復切替では重複を許す**。切替先が過去に自分のスレッドを持っていれば resume される + * ので、そのぶんは向こうの文脈と重なる。それでも全部渡すのは、resume が失敗したり + * 圧縮で落ちていたりしたときに「渡しすぎ」より「足りない」方が害が大きいため + * (重複していても新しい指示ではないことは引き継ぎ文の中で断ってある)。 * * **AI 向けの文字列なので i18n カタログには置かない**(`core/system-prompt.ts` の * `SHARED_IGNORED_FILES_NOTICE` / `utils/title.ts` の `TITLE_INSTRUCTION` と同じ扱いで @@ -16,18 +22,48 @@ import type { LogEntry } from './types'; */ /** - * 1 項目に載せる最大文字数。指示文はファイルを丸ごと貼り付けたものになりうるので、 - * systemPrompt が本文より大きくなる(= 毎ターン全部読ませる)のを防ぐために切る。 - * 切ったことは `…` で示す(黙って切らない)。 + * 見出しの 1 項目に載せる最大文字数。指示文はファイルを丸ごと貼り付けたものになりうるので、 + * 概要の箇条書きが会話本体より大きくなるのを防ぐために切る(会話そのものは下の + * {@link MAX_HANDOFF_TRANSCRIPT_BYTES} が別に面倒を見る)。切ったことは `…` で示す + * (黙って切らない)。 */ export const MAX_HANDOFF_FIELD_CHARS = 600; /** - * 会話履歴を引き継ぐ最大文字数。codiva の表示ログ自体は 400k 文字まで保持するが、 - * それを丸ごと system prompt にすると切替だけで巨大なコンテキストを消費する。 - * 新しい会話から優先して収め、切れたことは明示する。 + * 会話履歴を引き継ぐ最大サイズ(**UTF-8 バイト**)。codiva の表示ログ自体は 400k 文字まで + * 保持するが、それを丸ごと渡すと切替だけで巨大なコンテキストを消費する。新しい会話から + * 優先して収め、切れたことは明示する。 + * + * **文字数ではなくバイト数で測る。** Codex は指示文を **argv で渡す**ため + * (`utils/codex.ts` の `codexArgs` = `codex exec … -- `)、Linux の `execve` が + * 課す引数 1 本あたりの上限 `MAX_ARG_STRLEN`(32 ページ = 131,072 バイト)に当たると + * `E2BIG` で起動そのものが落ちる。日本語は 1 文字 3 バイトなので「文字数」で 80,000 を + * 許すと最大 240 KiB になり、**日本語で長く続けたセッションを Codex へ切り替えた瞬間に + * spawn が失敗する**。しかも Codex アダプタは `thread.started` を見るまで引き継ぎを + * 持ち続けるので、以後どのターンも同じ理由で落ち続けてセッションが詰む。 + * + * 値の根拠: 同じ argv には systemPrompt(`SHARED_IGNORED_FILES_NOTICE` ≒ 3.5 KiB + + * リポジトリ追加指示)とユーザーの指示文も載る。64 KiB 弱に抑えておけば、上限まで + * 使い切ってもユーザーの指示に 60 KiB 以上の余地が残る。 + * + * 併せて「直近の 1 ターンは必ず入る」ことも保証する: ログの 1 件は + * `MAX_LOG_ENTRY_CHARS`(20,000 文字 ⇒ 最大 60,000 バイト)に切られているので、 + * この予算はそれを必ず上回っていること(番人は `agent-handoff.spec.ts`)。 */ -export const MAX_HANDOFF_TRANSCRIPT_CHARS = 80_000; +export const MAX_HANDOFF_TRANSCRIPT_BYTES = 64_000; + +/** 省略が起きたことを引き継ぎ先に明示する 1 行(黙って切らない)。 */ +const OMITTED_MARKER = '[Older conversation omitted: the handover reached its size limit.]'; + +/** ターンの区切り(`join('\n\n')`)のぶん。予算にはこれも数える。 */ +const SEPARATOR_BYTES = 2; + +const UTF8 = new TextEncoder(); + +/** UTF-8 でのバイト長(argv に載る実サイズ。`.length` は UTF-16 の符号単位数)。 */ +function utf8Length(text: string): number { + return UTF8.encode(text).length; +} export interface HandoffInput { /** 引き継ぐ側の表示名(切替前のエージェント)。 */ @@ -42,12 +78,37 @@ export interface HandoffInput { messages?: readonly LogEntry[]; } +/** 引き継ぎの見出し。復元時に「引き継ぎ付きのプロンプト」を見分ける目印も兼ねる。 */ +export const HANDOFF_HEADING = '# Session handover (codiva)'; + +/** 引き継ぎと「ユーザーが実際に打った指示」の境目。 */ +const CURRENT_INSTRUCTION_HEADING = '# Current instruction after the switch'; + /** Provider に送る最初の指示へ、内部の引き継ぎ情報を前置する。 */ export function attachHandoff(text: string, handoff: string | undefined): string { - return handoff ? `${handoff}\n\n# Current instruction after the switch\n\n${text}` : text; + return handoff ? `${handoff}\n\n${CURRENT_INSTRUCTION_HEADING}\n\n${text}` : text; } -/** 1 行に畳んで長すぎるものを切る(systemPrompt の箇条書きに収めるため)。 */ +/** + * 引き継ぎを前置したプロンプトから、ユーザーが実際に打った指示だけを取り出す(純粋)。 + * + * なぜ要るか: 引き継ぎは provider には**ユーザーメッセージ**として届くので、CLI の + * トランスクリプトにもそう記録される。それをそのまま復元すると、 + * (1) 詳細ビューに「ユーザーが打った覚えのない巨大なブロック」が並び、 + * (2) `lastUserInstruction` が引き継ぎの見出しを直前の指示として拾い、 + * (3) 次の切替でその引き継ぎが会話ごと入れ子に写される。 + * 復元は `core/transcript.ts` の唯一の入口(`appendUserLine`)で通す。 + */ +export function stripHandoff(text: string): string { + if (!text.startsWith(HANDOFF_HEADING)) { + return text; + } + const marker = `\n\n${CURRENT_INSTRUCTION_HEADING}\n\n`; + const at = text.indexOf(marker); + return at === -1 ? text : text.slice(at + marker.length); +} + +/** 1 行に畳んで長すぎるものを切る(引き継ぎの箇条書きに収めるため)。 */ function field(text: string | undefined): string | undefined { const flat = text?.replace(/\s+/g, ' ').trim(); if (!flat) { @@ -77,39 +138,49 @@ export function lastUserInstruction(messages: readonly LogEntry[]): string | und * ツール実行・system/error 行は作業ツリーを見れば確認でき、量も大きいため除外する。 */ export function handoffTranscript(messages: readonly LogEntry[]): string | undefined { - const turns = messages - .filter((entry) => entry.kind === 'user' || entry.kind === 'assistant_text') - .map((entry) => { - const role = entry.kind === 'user' ? 'User' : 'Assistant'; - const agent = entry.agent ? ` (${entry.agent})` : ''; - return `${role}${agent}:\n${entry.text.trim()}`; - }) - .filter((turn) => !turn.endsWith(':\n')); + const turns: string[] = []; + for (const entry of messages) { + if (entry.kind !== 'user' && entry.kind !== 'assistant_text') { + continue; + } + // 空行(本文の無いターン)は載せない。判定は整形後の文字列ではなく**本文**で行う + // (`…:\n` で終わるかを見る形だと、役割ラベルの書式を変えた瞬間に黙って壊れる)。 + const text = entry.text.trim(); + if (!text) { + continue; + } + const role = entry.kind === 'user' ? 'User' : 'Assistant'; + // 帰属が入るのは**切替後のエージェントの発言だけ**(`LogEntry.agent`)。ユーザーの + // 指示は誰が受けても「ユーザー」なので付かない。切替の境目はこの印で読める。 + const agent = entry.agent ? ` (${entry.agent})` : ''; + turns.push(`${role}${agent}:\n${text}`); + } if (turns.length === 0) { return undefined; } + // 新しい会話から詰める(切替直後に効くのは直近の文脈)。省略の断り書き自身も + // argv に載るので予算に数える。 const kept: string[] = []; - let chars = 0; + let bytes = utf8Length(OMITTED_MARKER) + SEPARATOR_BYTES; for (let i = turns.length - 1; i >= 0; i -= 1) { const turn = turns[i]; if (turn === undefined) { continue; } - const separator = kept.length === 0 ? 0 : 2; - if (chars + separator + turn.length > MAX_HANDOFF_TRANSCRIPT_CHARS) { + const size = utf8Length(turn) + (kept.length === 0 ? 0 : SEPARATOR_BYTES); + if (bytes + size > MAX_HANDOFF_TRANSCRIPT_BYTES) { break; } kept.unshift(turn); - chars += separator + turn.length; + bytes += size; + } + if (kept.length === turns.length) { + return kept.join('\n\n'); } - const omitted = kept.length < turns.length; - return [ - ...(omitted - ? ['[Earlier conversation omitted because the handover reached its size limit.]'] - : []), - ...kept, - ].join('\n\n'); + // 1 件も入らないのは想定外(`MAX_LOG_ENTRY_CHARS` が 1 件の上限なので、予算がそれを + // 上回っている限り直近の 1 ターンは必ず入る)。それでも黙って空を返さず断り書きは出す。 + return [OMITTED_MARKER, ...kept].join('\n\n'); } /** @@ -128,11 +199,20 @@ export function handoffInstruction(input: HandoffInput): string | undefined { if (!task && !last && !branch && !transcript) { return undefined; } + // 会話を載せられたかで前置きを変える。常に「下に会話を写した」と名乗ると、ログが + // 空のセッション(トランスクリプト復元に失敗した復元セッション等)で嘘になる。 const lines = [ - '# Session handover (codiva)', + HANDOFF_HEADING, '', - `You are taking over this session from ${input.from}. The providers cannot share their`, - 'native session, so codiva has copied the user/assistant conversation below.', + ...(transcript + ? [ + `You are taking over this session from ${input.from}. The two agents cannot share their`, + 'native session, so codiva has copied the user/assistant conversation below.', + ] + : [ + `You are taking over this session from ${input.from}. The previous agent's conversation`, + 'history is NOT available to you — only the working tree it left behind is shared.', + ]), '', ]; if (branch) { @@ -150,7 +230,9 @@ export function handoffInstruction(input: HandoffInput): string | undefined { '## Conversation before the switch', '', 'Treat this as the prior conversation in the same task. Continue from it; do not ask the', - 'user to repeat information already present here.', + 'user to repeat information already present here. If you worked on this session before', + 'the switch, some of it may already be in your own context — repeated lines are not new', + 'instructions.', '', '', transcript, diff --git a/src/core/claude-adapter.spec.ts b/src/core/claude-adapter.spec.ts index 330d04b..9e58c41 100644 --- a/src/core/claude-adapter.spec.ts +++ b/src/core/claude-adapter.spec.ts @@ -1,28 +1,49 @@ import type { Options, Query } from '@anthropic-ai/claude-agent-sdk'; import { describe, expect, it } from 'vitest'; -import type { AgentRunRequest } from './agent-ports'; +import type { AgentRunOptions, AgentRunRequest } from './agent-ports'; import { createClaudeAdapter, type QueryFn } from './claude-adapter'; -/** 呼び出された `query()` の options を覗くだけのフェイク(推論も I/O も無い)。 */ -function makeQuerySpy(): { queryFn: QueryFn; seen: () => Options | undefined } { +/** 呼び出された `query()` の options とプロンプト列を覗くフェイク(推論も I/O も無い)。 */ +function makeQuerySpy(): { + queryFn: QueryFn; + seen: () => Options | undefined; + /** SDK へ実際に渡ったユーザーメッセージの本文(渡された順)。 */ + prompts: () => Promise; +} { let seen: Options | undefined; - const queryFn: QueryFn = ({ options }) => { + let drained: Promise = Promise.resolve([]); + const queryFn: QueryFn = ({ options, prompt }) => { seen = options; + drained = (async () => { + const texts: string[] = []; + for await (const message of prompt) { + const content = message.message.content; + texts.push(typeof content === 'string' ? content : JSON.stringify(content)); + } + return texts; + })(); return (async function* () {})() as unknown as Query; }; - return { queryFn, seen: () => seen }; + return { queryFn, seen: () => seen, prompts: () => drained }; } -function request(): AgentRunRequest { +function request(over: Partial = {}): AgentRunRequest { return { cwd: '/tmp/worktree', prompt: (async function* () {})(), options: {}, requestPermission: async () => ({ behavior: 'deny' }), abortController: new AbortController(), + ...over, }; } +async function* prompts(...texts: readonly string[]): AsyncIterable { + for (const text of texts) { + yield text; + } +} + describe('createClaudeAdapter', () => { // 既定は project のみ。ここが広がるとユーザーの手元設定(hooks 等)が worktree の // セッションへ黙って載るので、既定値そのものを固定しておく。 @@ -42,4 +63,24 @@ describe('createClaudeAdapter', () => { }).open(request()); expect(spy.seen()?.settingSources).toEqual(['user', 'project', 'local']); }); + + // `/agent` の引き継ぎは systemPrompt ではなく**最初のユーザーメッセージ**に前置する + // (3 provider 共通の契約。ここが抜けると切替の文脈が黙って消える)。 + it('prepends the handoff to the first user message only', async () => { + const spy = makeQuerySpy(); + const options: AgentRunOptions = { handoff: 'HANDOVER' }; + createClaudeAdapter({ queryFn: spy.queryFn }).open( + request({ prompt: prompts('now you', 'and this'), options }), + ); + expect(await spy.prompts()).toEqual([ + 'HANDOVER\n\n# Current instruction after the switch\n\nnow you', + 'and this', + ]); + }); + + it('passes the prompt through untouched when there is no handoff', async () => { + const spy = makeQuerySpy(); + createClaudeAdapter({ queryFn: spy.queryFn }).open(request({ prompt: prompts('just this') })); + expect(await spy.prompts()).toEqual(['just this']); + }); }); diff --git a/src/core/codex-adapter.spec.ts b/src/core/codex-adapter.spec.ts index 25704ae..20c6079 100644 --- a/src/core/codex-adapter.spec.ts +++ b/src/core/codex-adapter.spec.ts @@ -164,6 +164,57 @@ describe('createCodexAdapter turn loop', () => { expect(events.filter((e) => e.kind === 'turn_completed')).toHaveLength(2); }); + // `/agent` の引き継ぎは systemPrompt ではなく**最初のユーザープロンプト**に載る + // (`codex exec resume` は再開したスレッドに systemPrompt を渡し直さないため)。 + it('prepends the handoff to the first prompt only', async () => { + const codex = makeFakeCodex(); + const adapter = createCodexAdapter({ spawn: codex.spawn }); + const options: AgentRunOptions = { handoff: '# Session handover (codiva)' }; + // 往復切替: Codex は既に自分のスレッドを持っている(systemPrompt は前置されない)。 + const { prompts, events, done } = drive(adapter, { options, resume: 'th-old' }); + + prompts.push('now you'); + await waitFor(() => codex.requests.length === 1, 'the first spawn'); + expect(codex.requests[0]?.prompt).toBe( + '# Session handover (codiva)\n\n# Current instruction after the switch\n\nnow you', + ); + codex.at(0).emit(threadStarted('th-old')); + codex.at(0).emit(turnCompleted); + codex.at(0).end(); + await waitFor(() => events.some((e) => e.kind === 'turn_completed'), 'the first turn to end'); + + prompts.push('and this'); + await waitFor(() => codex.requests.length === 2, 'the second spawn'); + expect(codex.requests[1]?.prompt).toBe('and this'); + + codex.at(1).end(); + prompts.close(); + await done; + }); + + // 初回のターンが `thread.started` より前に落ちる(未導入 / 未ログイン / 不正な --model)と + // 次のターンは**新しいスレッド**として始まる。そこで引き継ぎを落としていると、切替の + // 文脈を一度も渡せないセッションになる(systemPrompt の前置と同じ理由で latch しない)。 + it('keeps the handoff when the first turn dies before the thread starts', async () => { + const codex = makeFakeCodex([{ code: 1, stderr: 'not logged in' }]); + const adapter = createCodexAdapter({ spawn: codex.spawn }); + const options: AgentRunOptions = { handoff: 'HANDOVER' }; + const { prompts, events, done } = drive(adapter, { options }); + + prompts.push('now you'); + await waitFor(() => codex.requests.length === 1, 'the first spawn'); + codex.at(0).end(); + await waitFor(() => events.some((e) => e.kind === 'turn_stopped'), 'the failed turn'); + + prompts.push('try again'); + await waitFor(() => codex.requests.length === 2, 'the second spawn'); + expect(codex.requests[1]?.prompt).toContain('HANDOVER'); + + codex.at(1).end(); + prompts.close(); + await done; + }); + it('prepends the systemPrompt to the first prompt only', async () => { const codex = makeFakeCodex(); const adapter = createCodexAdapter({ spawn: codex.spawn }); diff --git a/src/core/codex-adapter.ts b/src/core/codex-adapter.ts index cdff1c5..6201023 100644 --- a/src/core/codex-adapter.ts +++ b/src/core/codex-adapter.ts @@ -270,6 +270,12 @@ export function createCodexAdapter(deps: { } if (event.type === 'thread.started') { threadId = event.thread_id; + // 引き継ぎは CLI が確かに受け取ったときだけ落とす(systemPrompt の + // 前置と同じ理由 = 初回のターンが起動前に落ちたら次で渡し直す)。 + // **`codex exec resume` も `thread.started` を出す**ので、往復切替で + // 既存スレッドへ戻る場合もここを通る(実測: codex 0.147 系。同じ + // thread_id が返る)。ここが唯一の解除点なので、出さなくなったら + // 引き継ぎが毎ターン前置され続ける。 handoff = undefined; probeDuringTurn(event.thread_id); } else if (event.type === 'turn.completed' || event.type === 'turn.failed') { diff --git a/src/core/grok-adapter.spec.ts b/src/core/grok-adapter.spec.ts index 9ca39a7..e8f8e9f 100644 --- a/src/core/grok-adapter.spec.ts +++ b/src/core/grok-adapter.spec.ts @@ -230,6 +230,31 @@ describe('createGrokAdapter', () => { expect(harness.events[1]).toEqual({ kind: 'model_resolved', model: 'grok-4.5' }); }); + // `/agent` の引き継ぎは systemPrompt(`_meta.rules`)ではなく最初のユーザープロンプトに + // 載る。session/resume には rules を渡し直さないので、そこでも確実に届く。 + it('引き継ぎは切替後の最初のプロンプトにだけ前置する', async () => { + const grok = makeFakeGrok(); + const { harness } = run(grok.spawn, { options: { handoff: 'HANDOVER' } }); + harness.prompts.push('now you'); + const proc = await grok.at(0); + await handshake(proc); + + await waitFor(() => proc.find('session/prompt') !== undefined, 'first prompt'); + expect(proc.find('session/prompt')?.params?.prompt).toEqual([ + { type: 'text', text: 'HANDOVER\n\n# Current instruction after the switch\n\nnow you' }, + ]); + proc.reply('session/prompt', { stopReason: 'end_turn' }); + await waitFor(() => harness.events.some((e) => e.kind === 'turn_completed'), 'first turn'); + + harness.prompts.push('and this'); + await waitFor( + () => proc.sent.filter((m) => m.method === 'session/prompt').length === 2, + 'second prompt', + ); + const second = proc.sent.filter((m) => m.method === 'session/prompt')[1]; + expect(second?.params?.prompt).toEqual([{ type: 'text', text: 'and this' }]); + }); + it('2 ターン目は同じプロセスを使い回す(1 ターン 1 プロセスではない)', async () => { const grok = makeFakeGrok(); const { harness } = run(grok.spawn); diff --git a/src/core/grok-adapter.ts b/src/core/grok-adapter.ts index 468afb3..c99b1c4 100644 --- a/src/core/grok-adapter.ts +++ b/src/core/grok-adapter.ts @@ -448,8 +448,13 @@ export function createGrokAdapter(deps: { } }; - /** 1 ターン。`session/prompt` の応答が終わりを告げる。 */ - const runTurn = async (text: string): Promise => { + /** + * 1 ターン。`session/prompt` の応答が終わりを告げる。 + * + * 戻り値は「そのターンを実際に投げたか」。中断で捨てたターンと投げたターンを + * 呼び出し側が区別できないと、1 回きりの引き継ぎを空振りで使い切ってしまう。 + */ + const runTurn = async (text: string): Promise => { // **中断されたターンは始めない**。`Ctrl+C` はセッションの立ち上げ // (`initialize` → `session/new` / `session/resume`)の最中にも押せる。そこで // 送る `session/cancel` は「今走っているターン」向けの通知なので空振りし、 @@ -457,7 +462,7 @@ export function createGrokAdapter(deps: { // エージェントだけが worktree を書き換え続ける**。指示ごと捨てるのが正しい // (ユーザーは止めたのだから、やり直すときは改めて送る)。 if (interrupted || request.abortController.signal.aborted) { - return; + return false; } turnInFlight = true; try { @@ -465,6 +470,7 @@ export function createGrokAdapter(deps: { } finally { turnInFlight = false; } + return true; }; /** `session/prompt` の 1 往復。 */ @@ -548,9 +554,14 @@ export function createGrokAdapter(deps: { continue; } } - const prompt = attachHandoff(text, handoff); - handoff = undefined; - await runTurn(prompt); + // 引き継ぎを落とすのは**ターンを実際に投げたときだけ**。`runTurn` は + // 立ち上げ中に `Ctrl+C` された指示を丸ごと捨てるので、ここで無条件に + // 落とすと切替の文脈だけが黙って消える(`Session` 側の使い捨ては + // `open()` の時点で済んでいるので、二度と渡らない)。 + const sent = await runTurn(attachHandoff(text, handoff)); + if (sent) { + handoff = undefined; + } } } catch (error: unknown) { // **`finally` で閉じる前に積む**。閉じたキューへの push は黙って捨てられる diff --git a/src/core/i18n.ts b/src/core/i18n.ts index 8253bdb..890fe01 100644 --- a/src/core/i18n.ts +++ b/src/core/i18n.ts @@ -200,8 +200,9 @@ export interface Messages { /** ダイアログ下部の操作ヒント */ help: string; /** - * 切替の注意書き。モデル側の文脈は provider をまたげない(各 CLI が自分の - * トランスクリプトを持つ)ので、引き継がれるのは worktree と codiva のログだけ。 + * 切替の注意書き。provider 固有のセッションはまたげない(各 CLI が自分の + * トランスクリプトを持つ)が、codiva が持っている会話ログは切替先へ写す + * (`core/agent-handoff.ts`)。worktree はそのまま共有される。 */ warning: string; /** 今このセッションを駆動している行に付ける印(詳細ビュー) */ @@ -656,7 +657,7 @@ const ja: Messages = { agent: { title: 'エージェントを選択', help: '↑↓: 選択 ・ Enter: 決定 ・ Esc: キャンセル', - warning: '会話の文脈は引き継がれません(worktree の変更とログはそのまま)', + warning: '会話ログを切替先に引き継ぎます(worktree の変更もそのまま)', current: '使用中', currentDefault: '既定', defaultHint: '以降の新規セッションに適用されます', @@ -957,7 +958,7 @@ const en: Messages = { agent: { title: 'Select agent', help: '↑↓: select · Enter: confirm · Esc: cancel', - warning: 'The conversation context does not carry over (worktree changes and log stay)', + warning: 'The conversation log is handed to the new agent (worktree changes stay too)', current: 'in use', currentDefault: 'default', defaultHint: 'Applies to new sessions from now on', diff --git a/src/core/session.spec.ts b/src/core/session.spec.ts index ab59bee..8e1e22f 100644 --- a/src/core/session.spec.ts +++ b/src/core/session.spec.ts @@ -982,14 +982,17 @@ describe('Session.setAgent', () => { }); it('hands the new agent a handover briefing on the first run only', async () => { - // 切替先は前の会話を持たない(各 CLI が自分のトランスクリプトを持つ)ので、 - // worktree の状況を systemPrompt で 1 回だけ渡す(`core/agent-handoff.ts`)。 + // 切替先は provider 固有の会話を引き継げない(各 CLI が自分のトランスクリプトを + // 持つ)ので、codiva 側のログを `AgentRunOptions.handoff` で 1 回だけ渡す + // (`core/agent-handoff.ts`。systemPrompt には載せない — resume したスレッドに + // systemPrompt を渡し直さない provider があるため)。 const a = recorder('claude'); const b = recorder('codex'); const session = new Session({ agent: a.adapter, input: INPUT, now: () => 0 }); session.start(); await tick(); // 切替前は引き継ぎの説明を渡さない。 + expect(a.handoffs).toEqual([undefined]); expect(a.systemPrompts).toEqual([undefined]); session.setAgent(b.adapter); @@ -1000,7 +1003,11 @@ describe('Session.setAgent', () => { expect(briefing).toContain('taking over this session from claude'); expect(briefing).toContain('- Branch: codiva/t'); expect(briefing).toContain('- Original task: do the thing'); - expect(briefing).not.toBeUndefined(); + // 会話そのもの(ユーザー・アシスタント双方)が載っているのがこの PR の眼目。 + expect(briefing).toContain('User:\ndo the thing'); + expect(briefing).toContain('Assistant:\nclaude answered: do the thing'); + // 引き継ぎは systemPrompt には混ぜない(役割を分けておく)。 + expect(b.systemPrompts).toEqual([undefined]); // 2 回目のターン(同じエージェント)には持ち越さない — 引き継ぎは済んでいる。 session.send('and this'); @@ -1008,6 +1015,30 @@ describe('Session.setAgent', () => { expect(b.handoffs.slice(1).every((p) => p === undefined)).toBe(true); }); + it('carries the other agent conversation back on a round trip', async () => { + const a = recorder('claude'); + const b = recorder('codex'); + const session = new Session({ agent: a.adapter, input: INPUT, now: () => 0 }); + session.start(); + await tick(); + + session.setAgent(b.adapter); + session.send('to codex'); + await tick(); + session.setAgent(a.adapter); + session.send('back to claude'); + await tick(); + + // Claude へ戻るときの引き継ぎには、Codex 側でのやり取りも載る。帰属が付くのは + // **エージェントの発言だけ**(ユーザーの指示は誰が受けても「ユーザー」なので、 + // `user_input` は `reduce` を通り attribution を刻まない)。 + const briefing = a.handoffs[1]; + expect(briefing).toContain('User:\nto codex'); + expect(briefing).toContain('Assistant (codex):\ncodex answered: to codex'); + // 自分(Claude)が切替前に話したぶんも、帰属なしでそのまま載る。 + expect(briefing).toContain('Assistant:\nclaude answered: do the thing'); + }); + it('resumes the previous conversation when switching back', async () => { const a = recorder('claude'); const b = recorder('codex'); diff --git a/src/core/session.ts b/src/core/session.ts index 3b2ae9e..a502b1d 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -128,9 +128,10 @@ export class Session { */ private attribution?: AgentId; /** - * 切替直後の 1 回だけ systemPrompt に載せる引き継ぎの状況説明 - * (`core/agent-handoff.ts`)。**使い捨て**にするのは、引き継ぎが済んだ以降の - * ターンでも「前任者から引き継いだ」と言い続けないため。 + * 切替直後の 1 回だけ渡す引き継ぎ(`core/agent-handoff.ts`)。`AgentRunOptions.handoff` + * として次の `open()` が消費し、アダプタが最初のユーザープロンプトに前置する。 + * **使い捨て**にするのは、引き継ぎが済んだ以降のターンでも「前任者から引き継いだ」と + * 言い続けないため。 */ private handoff?: string; private run?: AgentRun; @@ -557,10 +558,13 @@ export class Session { const resume = this.attribution ? this.state.sdkSessionId : (this.deps.resume ?? this.state.sdkSessionId); - // 引き継ぎは切替後の最初のユーザープロンプトにだけ添える。systemPrompt では - // ないのは、resume 済み Codex thread など provider によっては再開時の - // systemPrompt を読まないため。画面のログには元の入力だけを積んであるので、 - // この内部添付がユーザー発言として二重表示されることはない。 + // 引き継ぎは切替後の最初のユーザープロンプトにだけ添える(前置はアダプタの仕事 = + // `attachHandoff`)。systemPrompt に載せないのは、resume 済みの Codex thread など + // provider によっては再開時に systemPrompt を読み直さないため。 + // + // 画面のログには元の入力だけを積むので、この内部添付が二重表示されることはない。 + // CLI のトランスクリプトには**ユーザー発言として**残るが、復元は + // `stripHandoff` を通るので詳細ビューにも `lastUserInstruction` にも漏れない。 const handoff = this.handoff; this.handoff = undefined; const systemPrompt = composeSystemPrompt({ diff --git a/src/core/system-prompt.ts b/src/core/system-prompt.ts index c02970c..d25dc48 100644 --- a/src/core/system-prompt.ts +++ b/src/core/system-prompt.ts @@ -95,30 +95,27 @@ never write into the main repository's working tree, where those shared targets live.`; /** - * worktree の環境説明・リポジトリ追加指示・引き継ぎの状況説明から systemPrompt を - * 組み立てる。 + * worktree の環境説明とリポジトリ追加指示から systemPrompt を組み立てる。 * - * 順序は「環境説明 → リポジトリ追加指示 → 引き継ぎ」。前者は前提条件の説明、次は著者が - * 書いた常設の指示、最後がこのターン限りの状況(`core/agent-handoff.ts`)で、 - * より具体的で今すぐ効くものを後ろに置く。 + * 順序は「環境説明 → リポジトリ追加指示」。前者は前提条件の説明、後者は著者が書いた + * 常設の指示で、より具体的なものを後ろに置く。 * * `ignoredFiles` は合成レイヤが `resolveIgnoredFilesMode(config)` の結果を渡す。 * 未指定(テストや直接構築)は注意書きを載せない —— 実体が共有されているかどうかを * 知らないまま「共有されている」と告げる方が危険なため。 * - * `handoff` は `/agent` でエージェントを切り替えた**直後の 1 回だけ**渡される - * (`Session` が使い捨てで保持する)。常設にすると、引き継ぎが済んだあとのターンでも - * 「前任者から引き継いだ」と言い続けることになる。 + * **エージェント切替の引き継ぎ(`core/agent-handoff.ts`)はここには載らない。** + * あれは `AgentRunOptions.handoff` として渡り、各アダプタが切替後の最初のユーザー + * プロンプトに前置する — resume したスレッドに systemPrompt を渡し直さない provider + * (`codex exec resume`)にも確実に届ける必要があるため。 */ export function composeSystemPrompt(parts: { ignoredFiles?: IgnoredFilesMode; repoPrompt?: string; - handoff?: string; }): string | undefined { const sections = [ parts.ignoredFiles === 'symlink' ? SHARED_IGNORED_FILES_NOTICE : undefined, parts.repoPrompt, - parts.handoff, ].filter((section): section is string => section !== undefined && section.length > 0); return sections.length > 0 ? sections.join('\n\n') : undefined; } diff --git a/src/core/transcript.ts b/src/core/transcript.ts index c3ef653..1598570 100644 --- a/src/core/transcript.ts +++ b/src/core/transcript.ts @@ -1,3 +1,4 @@ +import { stripHandoff } from './agent-handoff'; import { summarizeToolUse, toolResultSummary } from './claude-parse'; import { capLogEntries, clipLogText, MAX_LOG_ENTRIES } from './log-buffer'; import type { LogEntry } from './types'; @@ -72,7 +73,7 @@ function appendUserLine(out: History, content: unknown, timestamp: number | unde // A plain string is the prompt the user typed; block arrays carry either // typed text blocks or tool_result blocks (summarized like the live reducer). if (typeof content === 'string') { - const text = content.trim(); + const text = stripHandoff(content).trim(); if (text.length > 0) { out.push({ kind: 'user', text, timestamp }); } @@ -87,7 +88,7 @@ function appendUserLine(out: History, content: unknown, timestamp: number | unde } const block = raw as TranscriptContentBlock; if (block.type === 'text' && typeof block.text === 'string') { - const text = block.text.trim(); + const text = stripHandoff(block.text).trim(); if (text.length > 0) { out.push({ kind: 'user', text, timestamp }); } diff --git a/tests/app.test.tsx b/tests/app.test.tsx index cc3490c..f53649f 100644 --- a/tests/app.test.tsx +++ b/tests/app.test.tsx @@ -3029,8 +3029,8 @@ describe('App detail view (/agent)', () => { expect(frame).toContain('エージェントを選択'); expect(frame).toContain('Claude'); expect(frame).toContain('Codex'); - // 文脈が引き継がれないことを必ず伝える(切替の唯一の副作用)。 - expect(frame).toContain('会話の文脈は引き継がれません'); + // 何が引き継がれるのかを必ず伝える(切替の唯一の副作用)。 + expect(frame).toContain('会話ログを切替先に引き継ぎます'); stdin.write('\x1b[B'); // ↓ → Codex await flush();