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 fd1c2add0..85adb8cf4 100644 --- a/src/renderer/actions/handoffTranscript.test.ts +++ b/src/renderer/actions/handoffTranscript.test.ts @@ -2,9 +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"; import { MAX_HANDOFF_MESSAGE_CHARS } from "./handoffTranscriptRows"; +const TEST_BUDGET = 50_000; + const thread: Thread = { id: "thread-1", projectId: "project-1", @@ -145,7 +147,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 +158,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 +173,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,12 +185,25 @@ 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]"); }); + 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 @@ -205,10 +220,31 @@ 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); }); }); + +describe("handoffTranscriptBudget", () => { + 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.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 5b082656e..f1f7d636b 100644 --- a/src/renderer/actions/handoffTranscript.ts +++ b/src/renderer/actions/handoffTranscript.ts @@ -1,13 +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"; + +/** Approximate token allocation; leave the rest of the window for the next task. */ +const HANDOFF_CONTEXT_SHARE = 0.35; +const CHARS_PER_TOKEN = 4; +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; + +/** Character budget for the destination; unknown sizes use a bounded fallback. */ +export function handoffTranscriptBudget(contextSize?: string): number { + 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), + ); +} -/** - * 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 const MAX_TRANSCRIPT_CONTEXT_CHARS = 50_000; const ROW_SEPARATOR = "\n\n"; const LEADING_GAP_MARKER = "[earlier turns omitted]"; const INNER_GAP_MARKER = "[turns omitted]"; @@ -30,13 +47,12 @@ 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; + const cost = candidate.text.length + ROW_SEPARATOR.length + GAP_MARKER_ALLOWANCE; + if (used + cost > maxChars) return false; kept.add(candidate); used += cost; return true; @@ -80,30 +96,31 @@ 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] ?? []; 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)); + 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/renderer/components/thread/ContinueInProviderDialog.tsx b/src/renderer/components/thread/ContinueInProviderDialog.tsx index 6c850d590..449a21820 100644 --- a/src/renderer/components/thread/ContinueInProviderDialog.tsx +++ b/src/renderer/components/thread/ContinueInProviderDialog.tsx @@ -90,7 +90,10 @@ import { import { resolveSavedProviderDraftConfig, 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, @@ -857,7 +860,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, 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; +}