Skip to content
Merged
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
117 changes: 75 additions & 42 deletions src/mobile/useRemoteDesktop.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
20 changes: 17 additions & 3 deletions src/mobile/useRemoteDesktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
52 changes: 44 additions & 8 deletions src/renderer/actions/handoffTranscript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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");
Expand All @@ -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) => ({
Expand All @@ -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");
Expand All @@ -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
Expand All @@ -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", () => {
Comment thread
SDSLeon marked this conversation as resolved.
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);
},
);
});
53 changes: 35 additions & 18 deletions src/renderer/actions/handoffTranscript.ts
Original file line number Diff line number Diff line change
@@ -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]";
Expand All @@ -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<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;
const cost = candidate.text.length + ROW_SEPARATOR.length + GAP_MARKER_ALLOWANCE;
if (used + cost > maxChars) return false;
kept.add(candidate);
used += cost;
return true;
Expand Down Expand Up @@ -80,30 +96,31 @@ function joinRows(rows: readonly HandoffRow[], kept: ReadonlySet<HandoffRow>): s
export function buildTranscriptContext(
thread: Thread,
sourceLabel: string,
maxChars: number = DEFAULT_MAX_TRANSCRIPT_CONTEXT_CHARS,
Comment thread
SDSLeon marked this conversation as resolved.
): 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 } : {}),
Expand Down
Loading