Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions src/renderer/actions/handoffTranscript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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");
Expand All @@ -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) => ({
Expand All @@ -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");
Expand All @@ -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]");
Expand All @@ -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", () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: nothing tests the PR's core claim — that the dialog forwards the destination's contextSize — and there are no cases for realistic ids ("1M", "272k", "default", "1.05M"). A wiring test plus parametrized ids would lock this (and the parser-parity point on tokensFromContextSize) in.

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);
});
});
48 changes: 44 additions & 4 deletions src/renderer/actions/handoffTranscript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 /**

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The /** Whole-file budget, roughly 12-15k tokens… */ block above (lines 5–9) documented the deleted MAX_TRANSCRIPT_CONTEXT_CHARS and now contradicts the new comment below it (“Small next to any current context window”). It was orphaned when the constant was removed — suggest deleting lines 5–9.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the leading export on this line now exports HANDOFF_CONTEXT_SHARE — leftover from replacing export const MAX_TRANSCRIPT_CONTEXT_CHARS. Only handoffTranscriptBudget needs to be public; suggest dropping this 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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this reimplements parseContextWindowInput() (src/shared/agents/codexContextWindows.ts) for the same contextSize id format, minus its comma handling and 1k–10M clamps — e.g. "20m" is rejected there but yields a ~28M-char budget here. Suggest reusing the shared parser and keeping the 400k floor on top.

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]";
Expand All @@ -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<HandoffRow> {
function selectRows(rows: readonly HandoffRow[], maxChars: number): ReadonlySet<HandoffRow> {
const kept = new Set<HandoffRow>();
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;
Expand Down Expand Up @@ -80,6 +119,7 @@ function joinRows(rows: readonly HandoffRow[], kept: ReadonlySet<HandoffRow>): s
export function buildTranscriptContext(
thread: Thread,
sourceLabel: string,
maxChars: number = DEFAULT_MAX_TRANSCRIPT_CONTEXT_CHARS,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coupling note: raising this default 50k → 400k silently 8×'s the other caller, src/mobile/useRemoteDesktop.ts:1184, which passes no budget — and mobile inlines the transcript into the prompt with no attachment-file fallback (the buildHandoffLaunchInput inline path), so it is the path most exposed to prompt-size limits. input.targetConfig.contextSize is in scope there; suggest threading handoffTranscriptBudget(...) through it too, or calling out mobile as intentionally default-only.

): ExtractContextResult | null {
const state = useAppStore.getState();
const itemIds = state.runtimeItemIdsByThread[thread.id] ?? [];
Expand All @@ -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 {
Expand Down
11 changes: 9 additions & 2 deletions src/renderer/components/thread/ContinueInProviderDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down