From ba01c6daf75538b8885546e68a880c3ccfb77773 Mon Sep 17 00:00:00 2001 From: "Maicon (Playtix)" Date: Thu, 3 Sep 2026 15:01:03 -0300 Subject: [PATCH 1/2] feat(handoff): scale transcript budget to the destination context window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handoff transcripts are truncated from the front, so a 50k-char budget (~12k tokens) drops the start of the conversation — goal, constraints, decisions — and hands the next agent a transcript that begins mid-word. Size the budget from the destination model's context id instead, claiming 35% of it and never going below the previous-generation default of 400k chars. A 1M-context handoff now carries ~350k tokens instead of ~12k. Co-Authored-By: Claude Opus 5 (1M context) --- .../actions/handoffTranscript.test.ts | 38 ++++++++++++--- src/renderer/actions/handoffTranscript.ts | 48 +++++++++++++++++-- .../thread/ContinueInProviderDialog.tsx | 11 ++++- 3 files changed, 84 insertions(+), 13 deletions(-) diff --git a/src/renderer/actions/handoffTranscript.test.ts b/src/renderer/actions/handoffTranscript.test.ts index fd1c2add0..8de085bd9 100644 --- a/src/renderer/actions/handoffTranscript.test.ts +++ b/src/renderer/actions/handoffTranscript.test.ts @@ -2,7 +2,11 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { Thread } from "@/shared/contracts"; import { useAppStore } from "../state/appStore"; import type { RuntimeChatItem } from "../state/slices/runtimeEventSlice"; -import { buildTranscriptContext, MAX_TRANSCRIPT_CONTEXT_CHARS } from "./handoffTranscript"; +import { buildTranscriptContext, handoffTranscriptBudget } from "./handoffTranscript"; + +// The budget-filling tests below build fixtures sized against this value, so +// they assert selection behaviour rather than whatever the default happens to be. +const TEST_BUDGET = 50_000; import { MAX_HANDOFF_MESSAGE_CHARS } from "./handoffTranscriptRows"; const thread: Thread = { @@ -145,7 +149,7 @@ describe("buildTranscriptContext", () => { userMessage("u2", "Latest follow-up"), ]); - const summary = buildTranscriptContext(thread, "Claude")?.summary ?? ""; + const summary = buildTranscriptContext(thread, "Claude", TEST_BUDGET)?.summary ?? ""; expect(summary).toContain("User:\nOriginal ask: migrate the auth module"); expect(summary).toContain("User:\nLatest follow-up"); @@ -156,7 +160,7 @@ describe("buildTranscriptContext", () => { }); it("spends the budget on conversation before tool activity", () => { - // Eight near-cap assistant rows take ~48k of the 50k budget; twenty + // Eight near-cap assistant rows take ~48k of the 50k test budget; twenty // 480-char command rows cannot all fit in what remains. const long = "z".repeat(MAX_HANDOFF_MESSAGE_CHARS - 10); const commands: RuntimeChatItem[] = Array.from({ length: 20 }, (_, index) => ({ @@ -171,7 +175,7 @@ describe("buildTranscriptContext", () => { ...Array.from({ length: 8 }, (_, index) => assistantMessage(`a${index}`, `${index}:${long}`)), ]); - const summary = buildTranscriptContext(thread, "Claude")?.summary ?? ""; + const summary = buildTranscriptContext(thread, "Claude", TEST_BUDGET)?.summary ?? ""; expect(summary).toContain("0:zzz"); expect(summary).toContain("7:zzz"); @@ -183,7 +187,7 @@ describe("buildTranscriptContext", () => { it("truncates a single oversized user message from the tail, keeping its start", () => { seed([userMessage("u1", `ASK ${"w".repeat(MAX_HANDOFF_MESSAGE_CHARS * 2)}`)]); - const summary = buildTranscriptContext(thread, "Claude")?.summary ?? ""; + const summary = buildTranscriptContext(thread, "Claude", TEST_BUDGET)?.summary ?? ""; expect(summary).toContain("User:\nASK "); expect(summary).toContain("[message truncated]"); @@ -205,10 +209,30 @@ describe("buildTranscriptContext", () => { ]).flat(); seed(interleaved); - const summary = buildTranscriptContext(thread, "Claude")?.summary ?? ""; + const summary = buildTranscriptContext(thread, "Claude", TEST_BUDGET)?.summary ?? ""; expect(summary).toContain("[turns omitted]"); // The header line rides outside the row budget, hence the small slack. - expect(summary.length).toBeLessThanOrEqual(MAX_TRANSCRIPT_CONTEXT_CHARS + 500); + expect(summary.length).toBeLessThanOrEqual(TEST_BUDGET + 500); + }); +}); + +describe("handoffTranscriptBudget", () => { + it("scales the budget with the destination model's context window", () => { + // A handoff is truncated from the front, so a fixed small budget silently + // drops the beginning of the conversation on large-context models. + expect(handoffTranscriptBudget("1m")).toBeGreaterThan(handoffTranscriptBudget("200k")); + expect(handoffTranscriptBudget("1m")).toBeGreaterThan(1_000_000); + }); + + it("falls back to the default when the context size is missing or unparsable", () => { + const fallback = handoffTranscriptBudget(undefined); + expect(fallback).toBe(400_000); + expect(handoffTranscriptBudget("unlimited")).toBe(fallback); + expect(handoffTranscriptBudget("")).toBe(fallback); + }); + + it("never returns less than the default", () => { + expect(handoffTranscriptBudget("1k")).toBeGreaterThanOrEqual(400_000); }); }); diff --git a/src/renderer/actions/handoffTranscript.ts b/src/renderer/actions/handoffTranscript.ts index 5b082656e..e4719bfea 100644 --- a/src/renderer/actions/handoffTranscript.ts +++ b/src/renderer/actions/handoffTranscript.ts @@ -7,7 +7,46 @@ import { formatHandoffRow, type HandoffRow } from "./handoffTranscriptRows"; * window, but the file rides in the new provider's first message for the rest * of its session, so it is filled by priority rather than recency alone. */ -export const MAX_TRANSCRIPT_CONTEXT_CHARS = 50_000; +export /** + * Handoff transcripts are truncated from the front, so a small budget silently + * throws away the beginning of the conversation — the part that usually carries + * the goal and the decisions. 50k chars is roughly 12k tokens, a sliver of any + * current model's window, so the receiving agent used to start half-blind. + * + * The budget now scales with the destination model's context window and only + * claims a share of it, leaving the rest for the actual work. + */ +const HANDOFF_CONTEXT_SHARE = 0.35; +const CHARS_PER_TOKEN = 4; +/** Used when the destination declares no context size. */ +const DEFAULT_MAX_TRANSCRIPT_CONTEXT_CHARS = 400_000; + +/** `"1m"` / `"200k"` / `"272000"` -> token count, or undefined when unparsable. */ +function tokensFromContextSize(contextSize: string | undefined): number | undefined { + if (!contextSize) return undefined; + const match = /^(\d+(?:\.\d+)?)\s*([mk])?$/i.exec(contextSize.trim()); + if (!match) return undefined; + const value = Number(match[1]); + if (!Number.isFinite(value) || value <= 0) return undefined; + const unit = match[2]?.toLowerCase(); + if (unit === "m") return value * 1_000_000; + if (unit === "k") return value * 1_000; + return value; +} + +/** + * Character budget for a handoff transcript aimed at a model with `contextSize`. + * Exported so the caller can size the transcript to the provider it is handing + * off to rather than to a fixed constant. + */ +export function handoffTranscriptBudget(contextSize?: string): number { + const tokens = tokensFromContextSize(contextSize); + if (tokens === undefined) return DEFAULT_MAX_TRANSCRIPT_CONTEXT_CHARS; + return Math.max( + DEFAULT_MAX_TRANSCRIPT_CONTEXT_CHARS, + Math.floor(tokens * CHARS_PER_TOKEN * HANDOFF_CONTEXT_SHARE), + ); +} const ROW_SEPARATOR = "\n\n"; const LEADING_GAP_MARKER = "[earlier turns omitted]"; const INNER_GAP_MARKER = "[turns omitted]"; @@ -30,13 +69,13 @@ const GAP_MARKER_ALLOWANCE = ROW_SEPARATOR.length + LEADING_GAP_MARKER.length; * Each tier stops at the first row that does not fit, so the kept set is a * recent contiguous run per tier rather than a scatter of small rows. */ -function selectRows(rows: readonly HandoffRow[]): ReadonlySet { +function selectRows(rows: readonly HandoffRow[], maxChars: number): ReadonlySet { const kept = new Set(); let used = 0; const tryKeep = (candidate: HandoffRow): boolean => { const cost = candidate.text.length + (kept.size > 0 ? ROW_SEPARATOR.length + GAP_MARKER_ALLOWANCE : 0); - if (used + cost > MAX_TRANSCRIPT_CONTEXT_CHARS) return false; + if (used + cost > maxChars) return false; kept.add(candidate); used += cost; return true; @@ -80,6 +119,7 @@ function joinRows(rows: readonly HandoffRow[], kept: ReadonlySet): s export function buildTranscriptContext( thread: Thread, sourceLabel: string, + maxChars: number = DEFAULT_MAX_TRANSCRIPT_CONTEXT_CHARS, ): ExtractContextResult | null { const state = useAppStore.getState(); const itemIds = state.runtimeItemIdsByThread[thread.id] ?? []; @@ -95,7 +135,7 @@ export function buildTranscriptContext( }); if (rows.length === 0) return null; - const transcript = joinRows(rows, selectRows(rows)); + const transcript = joinRows(rows, selectRows(rows, maxChars)); if (!transcript.trim()) return null; return { diff --git a/src/renderer/components/thread/ContinueInProviderDialog.tsx b/src/renderer/components/thread/ContinueInProviderDialog.tsx index b308e30b2..5609d27c1 100644 --- a/src/renderer/components/thread/ContinueInProviderDialog.tsx +++ b/src/renderer/components/thread/ContinueInProviderDialog.tsx @@ -90,7 +90,10 @@ import { import { supportsUsableFastMode } from "./threadDraftViewHelpers"; import { useAppStore } from "@/renderer/state/appStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; -import { buildTranscriptContext } from "@/renderer/actions/handoffTranscript"; +import { + buildTranscriptContext, + handoffTranscriptBudget, +} from "@/renderer/actions/handoffTranscript"; import { defaultHandoffPrompt, type ProviderHandoffContext, @@ -804,7 +807,11 @@ export function ContinueInProviderDialog(props: { // before the new provider can. This holds whether or not the user typed a // prompt: a typed prompt narrows the next step, not the history behind it. // A terminal source has no stored rows and still goes through extraction. - const history = buildTranscriptContext(thread, sourceAgent?.label ?? thread.agentKind); + const history = buildTranscriptContext( + thread, + sourceAgent?.label ?? thread.agentKind, + handoffTranscriptBudget(targetConfig.contextSize), + ); if (history) { onContinue( selectedKind, From 638ca74d62ae3dfed4be7e303e6a983f1f343e58 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Thu, 10 Sep 2026 10:11:59 -0700 Subject: [PATCH 2/2] fix(handoff): bound destination budgets across delivery paths --- src/mobile/useRemoteDesktop.test.tsx | 117 +++++++++++------- src/mobile/useRemoteDesktop.ts | 20 ++- .../actions/handoffTranscript.test.ts | 52 +++++--- src/renderer/actions/handoffTranscript.ts | 75 ++++------- src/renderer/actions/handoffTranscriptRows.ts | 18 +-- src/renderer/actions/providerHandoff.test.ts | 40 ++++++ src/renderer/actions/providerHandoff.ts | 14 ++- .../thread/ContinueInProviderDialog.test.tsx | 47 +++++++ src/shared/agents/codexContextWindows.ts | 23 +--- src/shared/contextWindow.ts | 25 ++++ 10 files changed, 287 insertions(+), 144 deletions(-) create mode 100644 src/shared/contextWindow.ts diff --git a/src/mobile/useRemoteDesktop.test.tsx b/src/mobile/useRemoteDesktop.test.tsx index f2ee5c090..1c8d55d64 100644 --- a/src/mobile/useRemoteDesktop.test.tsx +++ b/src/mobile/useRemoteDesktop.test.tsx @@ -1800,51 +1800,84 @@ describe("useRemoteDesktop", () => { expect(order[0]).toBe("interests"); }); - it("returns and titles a mobile provider fork with the same marker as desktop", async () => { - const desktop = makeDesktop("d1"); - const client = clientFor("d1"); - const view = await mountWith([desktop], "d1"); - const project: Project = { - id: "p", - name: "Project", - location: { kind: "posix", path: "/repo" }, - createdAt: "2026-01-01T00:00:00.000Z", - }; - const thread: Thread = { - id: "source-thread", - projectId: project.id, - title: "Incident triage", - agentKind: "claude", - config: { model: "opus" }, - status: "idle", - attention: "none", - canResumeWithConfig: false, - archived: false, - done: false, - starred: false, - presentationMode: "gui", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }; - useAppStore.setState({ projects: [project], threads: [thread] }); + it.each([ + { fork: true, contextSize: "1m", budget: 50_000 }, + { fork: false, contextSize: "1m", budget: 50_000 }, + { fork: true, contextSize: "32k", budget: 44_800 }, + { fork: false, contextSize: "32k", budget: 44_800 }, + ])( + "hands off bounded mobile history ($fork fork, $contextSize)", + async ({ fork, contextSize, budget }) => { + const desktop = makeDesktop("d1"); + const client = clientFor("d1"); + const view = await mountWith([desktop], "d1"); + const project: Project = { + id: "p", + name: "Project", + location: { kind: "posix", path: "/repo" }, + createdAt: "2026-01-01T00:00:00.000Z", + }; + const thread: Thread = { + id: "source-thread", + projectId: project.id, + title: "Incident triage", + agentKind: "claude", + config: { model: "opus", contextSize: "1k" }, + status: "idle", + attention: "none", + canResumeWithConfig: false, + archived: false, + done: false, + starred: false, + presentationMode: "gui", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; + const items = Array.from({ length: 100 }, (_, index) => ({ + id: `u${index}`, + type: "user_message" as const, + state: "completed" as const, + payload: { content: [{ kind: "text", text: `Turn ${index}: ${"文".repeat(5_900)}` }] }, + streams: {}, + })); + useAppStore.setState({ + projects: [project], + threads: [thread], + runtimeItemIdsByThread: { [thread.id]: items.map((item) => item.id) }, + runtimeItemsByIdByThread: { + [thread.id]: Object.fromEntries(items.map((item) => [item.id, item])), + }, + }); - let createdThreadId: string | null = null; - await act(async () => { - createdThreadId = await view.result.current.continueThreadProvider(thread, { - targetAgentKind: "codex", - targetConfig: { model: "gpt-5" }, - targetPresentationMode: "gui", - fork: true, + let createdThreadId: string | null = null; + await act(async () => { + createdThreadId = await view.result.current.continueThreadProvider(thread, { + targetAgentKind: "codex", + targetConfig: { model: "gpt-5", contextSize }, + targetPresentationMode: "gui", + fork, + }); }); - }); - const input = client.startNewThread.mock.calls[0]?.[0] as { - threadId: string; - title: string; - }; - expect(input.title).toBe("Incident triage (fork)"); - expect(createdThreadId).toBe(input.threadId); - }); + const input = (fork ? client.startNewThread : client.startThread).mock.calls[0]?.[0] as { + threadId: string; + title: string; + prompt: string; + }; + expect(input).toEqual( + expect.objectContaining( + fork ? { title: "Incident triage (fork)" } : { threadId: thread.id }, + ), + ); + expect(createdThreadId).toBe(input.threadId); + expect(input.prompt).toContain("Turn 0:"); + expect(input.prompt).toContain("Turn 99:"); + expect(input.prompt).not.toContain("Turn 1:"); + expect(input.prompt.length).toBeLessThan(budget + 500); + expect(input.prompt.length).toBeGreaterThan(budget - 7_000); + expect(Buffer.byteLength(JSON.stringify(input))).toBeLessThan(1_048_576); + }, + ); it("[#8] does not claim offline while cached data renders during the first boot refresh", async () => { const d = makeDesktop("d1"); diff --git a/src/mobile/useRemoteDesktop.ts b/src/mobile/useRemoteDesktop.ts index b33d426b2..1bfc8edd2 100644 --- a/src/mobile/useRemoteDesktop.ts +++ b/src/mobile/useRemoteDesktop.ts @@ -28,8 +28,15 @@ import { type RemoteThreadSnapshot, } from "@/shared/remote"; import { performThreadInputSubmit } from "@/renderer/actions/threadRuntimeActions"; -import { buildTranscriptContext } from "@/renderer/actions/handoffTranscript"; -import { DEFAULT_HANDOFF_PROMPT, handoffInlineLabel } from "@/renderer/actions/providerHandoff"; +import { + buildTranscriptContext, + handoffTranscriptBudget, +} from "@/renderer/actions/handoffTranscript"; +import { + DEFAULT_HANDOFF_PROMPT, + handoffInlineLabel, + MAX_INLINE_HANDOFF_CONTEXT_CHARS, +} from "@/renderer/actions/providerHandoff"; import { continuesInPlace } from "@/shared/continueProviderRanking"; import { worktreePlacementPayload } from "@/renderer/actions/worktreePlacement"; import { captureFileCheckpoint } from "@/renderer/state/fileCheckpointActions"; @@ -1190,7 +1197,14 @@ export function useRemoteDesktop() { // The phone has no composer here, so the handoff carries the chat history // inline (the attachment-file route is a desktop-owned bridge path) plus // the shared default instruction. - const context = buildTranscriptContext(thread, thread.agentKind); + const context = buildTranscriptContext( + thread, + thread.agentKind, + Math.min( + handoffTranscriptBudget(input.targetConfig.contextSize), + MAX_INLINE_HANDOFF_CONTEXT_CHARS, + ), + ); const handoffPrompt = context ? `${handoffInlineLabel(context)}\n\n${context.summary}\n\n${DEFAULT_HANDOFF_PROMPT}` : DEFAULT_HANDOFF_PROMPT; diff --git a/src/renderer/actions/handoffTranscript.test.ts b/src/renderer/actions/handoffTranscript.test.ts index 8de085bd9..85adb8cf4 100644 --- a/src/renderer/actions/handoffTranscript.test.ts +++ b/src/renderer/actions/handoffTranscript.test.ts @@ -3,11 +3,9 @@ import type { Thread } from "@/shared/contracts"; import { useAppStore } from "../state/appStore"; import type { RuntimeChatItem } from "../state/slices/runtimeEventSlice"; import { buildTranscriptContext, handoffTranscriptBudget } from "./handoffTranscript"; +import { MAX_HANDOFF_MESSAGE_CHARS } from "./handoffTranscriptRows"; -// The budget-filling tests below build fixtures sized against this value, so -// they assert selection behaviour rather than whatever the default happens to be. const TEST_BUDGET = 50_000; -import { MAX_HANDOFF_MESSAGE_CHARS } from "./handoffTranscriptRows"; const thread: Thread = { id: "thread-1", @@ -193,6 +191,19 @@ describe("buildTranscriptContext", () => { expect(summary).toContain("[message truncated]"); }); + it("keeps the original ask when the destination budget is smaller than one message", () => { + seed([ + userMessage("u1", `Original ask: ${"x".repeat(6_000)}`), + assistantMessage("a1", "y".repeat(6_000)), + ]); + const budget = handoffTranscriptBudget("1k"); + const context = buildTranscriptContext(thread, "Source", budget); + + expect(context?.summary).toContain("User:\nOriginal ask:"); + expect(context?.summary).toContain("[message truncated]"); + expect(context?.summary.length).toBeLessThanOrEqual(budget); + }); + it("stays near the character budget when interleaved rows force gap markers", () => { // Alternating commands and tiny messages make the kept conversation rows // position-scattered, so every join needs a gap marker the row budget @@ -212,27 +223,28 @@ describe("buildTranscriptContext", () => { const summary = buildTranscriptContext(thread, "Claude", TEST_BUDGET)?.summary ?? ""; expect(summary).toContain("[turns omitted]"); - // The header line rides outside the row budget, hence the small slack. - expect(summary.length).toBeLessThanOrEqual(TEST_BUDGET + 500); + expect(summary.length).toBeLessThanOrEqual(TEST_BUDGET); }); }); describe("handoffTranscriptBudget", () => { - it("scales the budget with the destination model's context window", () => { - // A handoff is truncated from the front, so a fixed small budget silently - // drops the beginning of the conversation on large-context models. - expect(handoffTranscriptBudget("1m")).toBeGreaterThan(handoffTranscriptBudget("200k")); - expect(handoffTranscriptBudget("1m")).toBeGreaterThan(1_000_000); - }); - - it("falls back to the default when the context size is missing or unparsable", () => { - const fallback = handoffTranscriptBudget(undefined); - expect(fallback).toBe(400_000); - expect(handoffTranscriptBudget("unlimited")).toBe(fallback); - expect(handoffTranscriptBudget("")).toBe(fallback); + it.each([ + ["1k", 1_400], + ["32k", 44_800], + ["200k", 280_000], + ["272k", 380_800], + ["272,000", 380_800], + [" 1M ", 1_400_000], + ["1.05M", 1_470_000], + ["10m", 4_000_000], + ])("budgets the destination window %s without exceeding delivery limits", (size, expected) => { + expect(handoffTranscriptBudget(size)).toBe(expected); }); - it("never returns less than the default", () => { - expect(handoffTranscriptBudget("1k")).toBeGreaterThanOrEqual(400_000); - }); + it.each([undefined, "", "default", "unlimited", "20m", "0", "999", "1e9"])( + "uses the fallback for an unknown or invalid context size %s", + (size) => { + expect(handoffTranscriptBudget(size)).toBe(400_000); + }, + ); }); diff --git a/src/renderer/actions/handoffTranscript.ts b/src/renderer/actions/handoffTranscript.ts index e4719bfea..f1f7d636b 100644 --- a/src/renderer/actions/handoffTranscript.ts +++ b/src/renderer/actions/handoffTranscript.ts @@ -1,52 +1,30 @@ import type { ExtractContextResult, Thread } from "@/shared/contracts"; +import { parseContextWindowTokens } from "@/shared/contextWindow"; import { useAppStore } from "@/renderer/state/appStore"; -import { formatHandoffRow, type HandoffRow } from "./handoffTranscriptRows"; +import { + formatHandoffRow, + MAX_HANDOFF_MESSAGE_CHARS, + type HandoffRow, +} from "./handoffTranscriptRows"; -/** - * Whole-file budget, roughly 12-15k tokens. Small next to any current context - * window, but the file rides in the new provider's first message for the rest - * of its session, so it is filled by priority rather than recency alone. - */ -export /** - * Handoff transcripts are truncated from the front, so a small budget silently - * throws away the beginning of the conversation — the part that usually carries - * the goal and the decisions. 50k chars is roughly 12k tokens, a sliver of any - * current model's window, so the receiving agent used to start half-blind. - * - * The budget now scales with the destination model's context window and only - * claims a share of it, leaving the rest for the actual work. - */ +/** Approximate token allocation; leave the rest of the window for the next task. */ const HANDOFF_CONTEXT_SHARE = 0.35; const CHARS_PER_TOKEN = 4; -/** Used when the destination declares no context size. */ const DEFAULT_MAX_TRANSCRIPT_CONTEXT_CHARS = 400_000; +// Even UTF-8 text must fit the remote attachment upload's 20 MiB ceiling. +const MAX_TRANSCRIPT_CONTEXT_CHARS = 4_000_000; -/** `"1m"` / `"200k"` / `"272000"` -> token count, or undefined when unparsable. */ -function tokensFromContextSize(contextSize: string | undefined): number | undefined { - if (!contextSize) return undefined; - const match = /^(\d+(?:\.\d+)?)\s*([mk])?$/i.exec(contextSize.trim()); - if (!match) return undefined; - const value = Number(match[1]); - if (!Number.isFinite(value) || value <= 0) return undefined; - const unit = match[2]?.toLowerCase(); - if (unit === "m") return value * 1_000_000; - if (unit === "k") return value * 1_000; - return value; -} - -/** - * Character budget for a handoff transcript aimed at a model with `contextSize`. - * Exported so the caller can size the transcript to the provider it is handing - * off to rather than to a fixed constant. - */ +/** Character budget for the destination; unknown sizes use a bounded fallback. */ export function handoffTranscriptBudget(contextSize?: string): number { - const tokens = tokensFromContextSize(contextSize); - if (tokens === undefined) return DEFAULT_MAX_TRANSCRIPT_CONTEXT_CHARS; - return Math.max( - DEFAULT_MAX_TRANSCRIPT_CONTEXT_CHARS, - Math.floor(tokens * CHARS_PER_TOKEN * HANDOFF_CONTEXT_SHARE), - ); + const tokens = parseContextWindowTokens(contextSize ?? ""); + return tokens === undefined + ? DEFAULT_MAX_TRANSCRIPT_CONTEXT_CHARS + : Math.min( + MAX_TRANSCRIPT_CONTEXT_CHARS, + Math.floor(tokens * CHARS_PER_TOKEN * HANDOFF_CONTEXT_SHARE), + ); } + const ROW_SEPARATOR = "\n\n"; const LEADING_GAP_MARKER = "[earlier turns omitted]"; const INNER_GAP_MARKER = "[turns omitted]"; @@ -73,8 +51,7 @@ function selectRows(rows: readonly HandoffRow[], maxChars: number): ReadonlySet< const kept = new Set(); let used = 0; const tryKeep = (candidate: HandoffRow): boolean => { - const cost = - candidate.text.length + (kept.size > 0 ? ROW_SEPARATOR.length + GAP_MARKER_ALLOWANCE : 0); + const cost = candidate.text.length + ROW_SEPARATOR.length + GAP_MARKER_ALLOWANCE; if (used + cost > maxChars) return false; kept.add(candidate); used += cost; @@ -126,24 +103,24 @@ export function buildTranscriptContext( const itemsById = state.runtimeItemsByIdByThread[thread.id]; if (!itemsById || itemIds.length === 0) return null; + const header = `Chat history of this conversation from the ${sourceLabel} session, oldest turn first. Tool output is omitted; rerun commands if you need their results.\n\n`; + const rowBudget = maxChars - header.length; + // Leave room for the row label, truncation marker, separators, and gap marker. + const messageBudget = Math.min(MAX_HANDOFF_MESSAGE_CHARS, Math.max(0, rowBudget - 100)); const rows: HandoffRow[] = []; itemIds.forEach((itemId) => { const item = itemsById[itemId]; if (!item || item.parentItemId) return; - const formatted = formatHandoffRow(item); + const formatted = formatHandoffRow(item, messageBudget); if (formatted?.text.trim()) rows.push(formatted); }); if (rows.length === 0) return null; - const transcript = joinRows(rows, selectRows(rows, maxChars)); + const transcript = joinRows(rows, selectRows(rows, rowBudget)); if (!transcript.trim()) return null; return { - summary: [ - `Chat history of this conversation from the ${sourceLabel} session, oldest turn first. Tool output is omitted; rerun commands if you need their results.`, - "", - transcript, - ].join("\n"), + summary: header + transcript, sourceProvider: thread.agentKind, sourceSessionId: thread.sessionRef?.providerSessionId ?? thread.id, ...(thread.worktreePath ? { worktreePath: thread.worktreePath } : {}), diff --git a/src/renderer/actions/handoffTranscriptRows.ts b/src/renderer/actions/handoffTranscriptRows.ts index 150530bd7..aa98dc7e6 100644 --- a/src/renderer/actions/handoffTranscriptRows.ts +++ b/src/renderer/actions/handoffTranscriptRows.ts @@ -51,13 +51,16 @@ function row(tier: HandoffRowTier, text: string, isUserMessage = false): Handoff } /** Render one top-level thread item for the handoff file, or null to drop it. */ -export function formatHandoffRow(item: RuntimeChatItem): HandoffRow | null { +export function formatHandoffRow( + item: RuntimeChatItem, + maxMessageChars = MAX_HANDOFF_MESSAGE_CHARS, +): HandoffRow | null { const payload = asRecord(item.payload); switch (item.type) { case "user_message": { const text = textFromRuntimeContentBlocks(item.payload); return text - ? row("conversation", `User:\n${truncateHead(text, MAX_HANDOFF_MESSAGE_CHARS)}`, true) + ? row("conversation", `User:\n${truncateHead(text, maxMessageChars)}`, true) : null; } case "assistant_message": { @@ -65,7 +68,7 @@ export function formatHandoffRow(item: RuntimeChatItem): HandoffRow | null { // exactly what the user saw, never the replaced stream. const text = assistantTranscriptContent(item); return text - ? row("conversation", `Assistant:\n${truncateTail(text, MAX_HANDOFF_MESSAGE_CHARS)}`) + ? row("conversation", `Assistant:\n${truncateTail(text, maxMessageChars)}`) : null; } case "plan": { @@ -78,23 +81,20 @@ export function formatHandoffRow(item: RuntimeChatItem): HandoffRow | null { return [`- [${status}] ${record.step}`]; }); return lines.length > 0 - ? row("conversation", `Plan:\n${truncateTail(lines.join("\n"), MAX_HANDOFF_MESSAGE_CHARS)}`) + ? row("conversation", `Plan:\n${truncateTail(lines.join("\n"), maxMessageChars)}`) : null; } case "goal": { const objective = typeof payload?.objective === "string" ? payload.objective : ""; const status = typeof payload?.status === "string" ? ` (${payload.status})` : ""; return objective - ? row( - "conversation", - `Goal${status}:\n${truncateHead(objective, MAX_HANDOFF_MESSAGE_CHARS)}`, - ) + ? row("conversation", `Goal${status}:\n${truncateHead(objective, maxMessageChars)}`) : null; } case "error": { const message = typeof payload?.message === "string" ? payload.message : ""; return message - ? row("conversation", `Error:\n${truncateHead(message, MAX_HANDOFF_MESSAGE_CHARS)}`) + ? row("conversation", `Error:\n${truncateHead(message, maxMessageChars)}`) : null; } case "provider_handoff": { diff --git a/src/renderer/actions/providerHandoff.test.ts b/src/renderer/actions/providerHandoff.test.ts index b2f4c7b85..a43114142 100644 --- a/src/renderer/actions/providerHandoff.test.ts +++ b/src/renderer/actions/providerHandoff.test.ts @@ -99,6 +99,46 @@ describe("buildHandoffLaunchInput", () => { expect(saveHandoffContext).toHaveBeenCalledWith({ threadId: "t1", content: "Prior context" }); }); + it("keeps a large transcript intact when attachment delivery succeeds", async () => { + const summary = "文".repeat(1_400_000); + const launch = await buildHandoffLaunchInput({ + threadId: "t1", + prompt: "Continue", + segments: undefined, + extractedContext: extracted({ summary, contentKind: "transcript" }), + }); + + expect(saveHandoffContext).toHaveBeenCalledWith({ threadId: "t1", content: summary }); + expect(launch.prompt.length).toBeLessThan(1_000); + expect(launch.segments).toContainEqual(expect.objectContaining({ kind: "attachment" })); + }); + + it.each(["文", "\u0000"])( + "bounds fallback JSON while keeping both ends of %j context", + async (fill) => { + saveHandoffContext.mockRejectedValueOnce(new Error("disk full")); + const launch = await buildHandoffLaunchInput({ + threadId: "t1", + prompt: "Next task", + segments: undefined, + extractedContext: extracted({ + summary: `Original ask\n${fill.repeat(400_000)}\nLatest result`, + contentKind: "transcript", + }), + }); + + expect(launch.prompt).toContain("Original ask"); + expect(launch.prompt).toContain("Latest result"); + expect(launch.prompt).toContain("[transferred context omitted]"); + expect(launch.prompt.endsWith("Next task")).toBe(true); + expect(Buffer.byteLength(JSON.stringify(launch))).toBeLessThan(1_048_576); + expect(launch.segments?.[0]).toEqual({ + kind: "text", + content: launch.prompt.slice(0, -"Next task".length), + }); + }, + ); + it("labels an inlined chat history when the file write fails", async () => { saveHandoffContext.mockRejectedValueOnce(new Error("disk full")); diff --git a/src/renderer/actions/providerHandoff.ts b/src/renderer/actions/providerHandoff.ts index 65fcbb786..66dae2aff 100644 --- a/src/renderer/actions/providerHandoff.ts +++ b/src/renderer/actions/providerHandoff.ts @@ -7,6 +7,9 @@ import type { import { readBridge } from "@/renderer/bridge"; import { flattenSegments } from "@/renderer/components/composer/serializeMentions"; +/** Inline context is duplicated in prompt/segments and must fit the remote 1 MiB JSON limit. */ +export const MAX_INLINE_HANDOFF_CONTEXT_CHARS = 50_000; + interface HandoffLaunchInput { prompt: string; segments: PromptSegment[] | undefined; @@ -106,7 +109,16 @@ export async function buildHandoffLaunchInput(input: { ], }; } catch { - const inlineHeader = `${handoffInlineLabel(extractedContext)}\n\n${extractedContext.summary}\n\n`; + const summary = extractedContext.summary; + const marker = "\n\n[transferred context omitted]\n\n"; + const headChars = Math.floor((MAX_INLINE_HANDOFF_CONTEXT_CHARS - marker.length) / 2); + const inlineSummary = + summary.length <= MAX_INLINE_HANDOFF_CONTEXT_CHARS + ? summary + : summary.slice(0, headChars) + + marker + + summary.slice(-(MAX_INLINE_HANDOFF_CONTEXT_CHARS - marker.length - headChars)); + const inlineHeader = `${handoffInlineLabel(extractedContext)}\n\n${inlineSummary}\n\n`; return { prompt: `${inlineHeader}${prompt}`, segments: [{ kind: "text", content: inlineHeader }, ...promptSegments], diff --git a/src/renderer/components/thread/ContinueInProviderDialog.test.tsx b/src/renderer/components/thread/ContinueInProviderDialog.test.tsx index adc9a21bb..4f89b9b74 100644 --- a/src/renderer/components/thread/ContinueInProviderDialog.test.tsx +++ b/src/renderer/components/thread/ContinueInProviderDialog.test.tsx @@ -171,6 +171,53 @@ describe("ContinueInProviderDialog handoff flow", () => { ); }); + it.each([ + ["32k", 44_800], + ["200k", 280_000], + ["1m", 1_400_000], + ])("sizes stored history for the destination's %s window", async (contextSize, budget) => { + seedRuntimeItems([ + { + id: "u1", + type: "user_message", + state: "completed", + payload: { content: [{ kind: "text", text: "Original ask" }] }, + streams: {}, + }, + ...Array.from({ length: 100 }, (_, index): RuntimeChatItem => ({ + id: `a${index}`, + type: "assistant_message", + state: "completed", + payload: {}, + streams: { assistant_text: `Turn ${index}: ${"x".repeat(5_900)}` }, + })), + ]); + const onContinue = renderDialog({ + thread: { config: { model: "source-model", contextSize: "1k" } }, + installedAgents: [ + agent("claude", "Claude", "gui"), + agent("codex", "Codex", "terminal", { + contextSizes: [{ id: contextSize, label: contextSize }], + defaultContextSize: contextSize, + }), + ], + }); + + await pressSwitch(); + + expect(bridge.extractContext).not.toHaveBeenCalled(); + expect(onContinue.mock.calls[0]?.[1].contextSize).toBe(contextSize); + const context = onContinue.mock.calls[0]?.[6]; + expect(context?.strategy).toBe("context-file"); + if (context?.strategy !== "context-file") throw new Error("Expected transferred context"); + const summary = context.extracted?.summary ?? ""; + expect(summary).toContain("Original ask"); + expect(summary).toContain("Turn 99:"); + expect(summary.length).toBeLessThanOrEqual(budget); + expect(summary.length).toBeGreaterThan(Math.min(budget, 590_000) - 7_000); + expect(summary.includes("Turn 0:")).toBe(contextSize === "1m"); + }); + it("hands the thread itself over when the target can read it", async () => { useAppStore.setState({ threadMentionToolsAvailableByThreadId: { [thread.id]: true }, diff --git a/src/shared/agents/codexContextWindows.ts b/src/shared/agents/codexContextWindows.ts index 6e7279a96..445dcbd3b 100644 --- a/src/shared/agents/codexContextWindows.ts +++ b/src/shared/agents/codexContextWindows.ts @@ -1,4 +1,5 @@ import type { AgentCapability, LabeledOption } from "@/shared/contracts"; +import { parseContextWindowTokens } from "@/shared/contextWindow"; /** Agent-settings key storing the user's Codex context-window list as JSON. */ export const CODEX_CONTEXT_WINDOWS_SETTING_KEY = "contextWindows"; @@ -6,8 +7,6 @@ export const CODEX_CONTEXT_WINDOWS_SETTING_KEY = "contextWindows"; /** Poracode's default Codex context window. Codex's own CLI default is 272k. */ export const DEFAULT_CODEX_CONTEXT_SIZE = "400k"; -const MIN_CONTEXT_WINDOW_TOKENS = 1_000; -const MAX_CONTEXT_WINDOW_TOKENS = 10_000_000; const AUTO_COMPACT_RATIO = 0.95; export interface CodexContextWindow { @@ -16,25 +15,9 @@ export interface CodexContextWindow { tokens: number; } -const CONTEXT_WINDOW_INPUT = /^(\d+(?:\.\d+)?)\s*([kKmM])?$/; - export function parseContextWindowInput(raw: string): CodexContextWindow | undefined { - const trimmed = raw.trim().replaceAll(",", ""); - if (!trimmed) return undefined; - const match = CONTEXT_WINDOW_INPUT.exec(trimmed); - if (!match) return undefined; - const amount = Number.parseFloat(match[1]!); - if (!Number.isFinite(amount) || amount <= 0) return undefined; - const suffix = match[2]?.toLowerCase(); - const tokens = - suffix === "m" - ? Math.round(amount * 1_000_000) - : suffix === "k" - ? Math.round(amount * 1_000) - : Math.round(amount); - if (tokens < MIN_CONTEXT_WINDOW_TOKENS || tokens > MAX_CONTEXT_WINDOW_TOKENS) { - return undefined; - } + const tokens = parseContextWindowTokens(raw); + if (tokens === undefined) return undefined; const id = contextWindowIdFromTokens(tokens); return { id, label: contextWindowLabelFromId(id), tokens }; } diff --git a/src/shared/contextWindow.ts b/src/shared/contextWindow.ts new file mode 100644 index 000000000..2f35e8118 --- /dev/null +++ b/src/shared/contextWindow.ts @@ -0,0 +1,25 @@ +const MIN_CONTEXT_WINDOW_TOKENS = 1_000; +const MAX_CONTEXT_WINDOW_TOKENS = 10_000_000; + +const CONTEXT_WINDOW_INPUT = /^(\d+(?:\.\d+)?)\s*([kKmM])?$/; + +/** Parse a numeric context size in tokens, with optional k/m suffix (1k–10m). */ +export function parseContextWindowTokens(raw: string): number | undefined { + const trimmed = raw.trim().replaceAll(",", ""); + if (!trimmed) return undefined; + const match = CONTEXT_WINDOW_INPUT.exec(trimmed); + if (!match) return undefined; + const amount = Number.parseFloat(match[1]!); + if (!Number.isFinite(amount) || amount <= 0) return undefined; + const suffix = match[2]?.toLowerCase(); + const tokens = + suffix === "m" + ? Math.round(amount * 1_000_000) + : suffix === "k" + ? Math.round(amount * 1_000) + : Math.round(amount); + if (tokens < MIN_CONTEXT_WINDOW_TOKENS || tokens > MAX_CONTEXT_WINDOW_TOKENS) { + return undefined; + } + return tokens; +}