From 22e50c52434be096e72b2e045aa672903b7cb96c Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:39:31 -0800 Subject: [PATCH 1/9] fix(lcm): preserve complete evidence and resume summary catch-up --- .../ConversationSummariesRouteScreen.test.tsx | 132 ++- .../ConversationSummariesRouteScreen.tsx | 125 ++- .../toolkits/conversationSummaries/tools.ts | 6 +- .../ContextHandoffBudget.test.ts | 68 +- .../ContextHandoffDelivery.ts | 2 +- .../src/orchestration-v2/EffectOutbox.ts | 2 + .../src/orchestration-v2/EffectWorker.test.ts | 196 ++++- .../src/orchestration-v2/EffectWorker.ts | 37 +- .../src/orchestration-v2/ProjectionStore.ts | 3 + .../ConversationSummaries.test.ts | 468 ++++++++++- .../ConversationSummaries.ts | 772 +++++++++++++----- .../conversationSummarySchedule.ts | 33 +- .../conversationSummarySources.ts | 135 +++ ...nversationSummariesDialog.browser.test.tsx | 74 +- .../ConversationSummariesDialog.tsx | 268 ++++-- docs/internals/context-handoffs.md | 23 +- docs/user/portable-handoffs.md | 17 +- .../cases/project-intelligence.ndjson | 3 +- .../src/conversationSummaries/index.ts | 28 +- .../contracts/src/conversationSummaries.ts | 85 +- packages/provider-core/package.json | 4 + .../src/server/conversationEvidence.ts | 54 ++ .../provider-core/src/server/handoffBudget.ts | 13 +- .../src/server/textGeneration.ts | 5 +- .../src/server/textGenerationPrompts.ts | 16 +- 25 files changed, 2120 insertions(+), 449 deletions(-) create mode 100644 apps/server/src/project/conversationSummaries/conversationSummarySources.ts create mode 100644 packages/provider-core/src/server/conversationEvidence.ts diff --git a/apps/mobile/src/features/conversation-summaries/ConversationSummariesRouteScreen.test.tsx b/apps/mobile/src/features/conversation-summaries/ConversationSummariesRouteScreen.test.tsx index f65373b1cab53..6cf004964d397 100644 --- a/apps/mobile/src/features/conversation-summaries/ConversationSummariesRouteScreen.test.tsx +++ b/apps/mobile/src/features/conversation-summaries/ConversationSummariesRouteScreen.test.tsx @@ -10,10 +10,13 @@ import { ProviderInstanceId, RunId, ThreadId, + TurnItemId, + conversationSummarySourceKey, type ConversationSummary, type ConversationSummaryExpandInput, type ConversationSummaryList, type ConversationSummaryListInput, + type ConversationSummarySourceRef, } from "@t3tools/contracts"; const state = vi.hoisted(() => ({ @@ -22,6 +25,7 @@ const state = vi.hoisted(() => ({ list: null as ConversationSummaryList | null, lists: [] as { environmentId: EnvironmentId; input: ConversationSummaryListInput }[], expansions: [] as { environmentId: EnvironmentId; input: ConversationSummaryExpandInput }[], + longSource: "", })); vi.mock("react-native", () => ({ View: ({ children }: { children?: ReactNode }) =>
{children}
, @@ -36,6 +40,21 @@ vi.mock("../../components/ui/AppBar", () => ({ })); vi.mock("../../components/ui/AppText", () => ({ AppText: ({ children }: { children?: ReactNode }) => {children}, + AppTextInput: ({ + accessibilityLabel, + value, + onChangeText, + }: { + accessibilityLabel: string; + value: string; + onChangeText: (value: string) => void; + }) => ( + onChangeText(event.target.value)} + /> + ), })); vi.mock("../../components/ui/ScreenContent", () => ({ ScreenContent: ({ children }: { children?: ReactNode }) =>
{children}
, @@ -65,22 +84,36 @@ vi.mock("../../state/conversationSummaries", () => ({ expand: (request: { environmentId: EnvironmentId; input: ConversationSummaryExpandInput }) => { state.expansions.push(request); const offset = request.input.offset ?? 0; + const textOffset = request.input.textOffset ?? 0; + const maxChars = request.input.maxChars ?? 12_000; const summary = state.list?.summaries[0]; if (summary?.state !== "available") throw new Error("Expected summary"); + const firstPart = offset === 0 && textOffset === 0; return { summary, - sources: summary.summary.sources.slice(offset, offset + 6).map((ref, index) => ({ - ref, - result: - offset + index === 0 - ? { state: "unavailable", reason: "changed" } - : { - state: "available", - role: "assistant", - text: "Exact source " + (offset + index + 1) + ": 日本語 🧪\n unchanged.", - }, - })), - nextOffset: offset === 0 ? 6 : null, + sources: summary.summary.sources + .slice(offset, offset + (firstPart ? 1 : 6)) + .map((ref, index) => ({ + ref, + result: + offset + index === 0 + ? { + state: "available", + role: "assistant", + text: state.longSource.slice(textOffset, textOffset + maxChars), + textOffset, + totalChars: state.longSource.length, + } + : offset + index === 1 + ? { state: "unavailable", reason: "changed" } + : { + state: "available", + role: ref.kind === "turn_item" ? "tool" : "assistant", + text: "Exact source " + (offset + index + 1) + ": 日本語 🧪\n unchanged.", + }, + })), + nextOffset: firstPart ? 0 : offset === 0 ? 6 : null, + nextTextOffset: firstPart ? maxChars : 0, }; }, }, @@ -99,6 +132,8 @@ function setup() { state.lists = []; state.expansions = []; state.list = null; + state.longSource = + "Exact source 1: 日本語 🧪\n unchanged.\n" + "x".repeat(12_000) + "\nFinal evidence."; vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const container = document.createElement("div"); const root = createRoot(container); @@ -141,20 +176,39 @@ it("reads only while open and supported, shows pass/citations/stale reasons and screen.setSupported(false); expect(state.lists).toEqual([]); expect(screen.container.textContent).toContain("Update the server"); + const sources = Array.from({ length: 10 }, (_, index): ConversationSummarySourceRef => + index === 9 + ? { + kind: "turn_item", + threadId: ThreadId.make("thread"), + itemId: TurnItemId.make("tool-output"), + digest: "a".repeat(64), + } + : { + kind: "message", + threadId: ThreadId.make("thread"), + messageId: MessageId.make("message-" + (index < 2 ? 0 : index)), + digest: "a".repeat(64), + ...(index < 2 + ? { + range: { + start: index * state.longSource.length, + end: (index + 1) * state.longSource.length, + total: state.longSource.length * 2, + }, + } + : {}), + }, + ); const summary: ConversationSummary = { id: "summary:" + "a".repeat(64), projectId: ProjectId.make("project"), threadId: ThreadId.make("thread"), - sources: Array.from({ length: 10 }, (_, index) => ({ - kind: "message", - threadId: ThreadId.make("thread"), - messageId: MessageId.make("message-" + index), - digest: "a".repeat(64), - })), + sources, statements: [ { text: "Keep the canonical sources.", - citations: [MessageId.make("message-0"), MessageId.make("message-9")], + citations: [sources[0]!, sources[1]!, sources[9]!].map(conversationSummarySourceKey), }, ], modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, @@ -190,9 +244,9 @@ it("reads only while open and supported, shows pass/citations/stale reasons and }); expect(state.expansions).toEqual([]); expect(screen.container.textContent).toContain( - "Last pass failed. No model 10 older messages wait", + "Last pass failed. No model 10 older sources wait", ); - expect(screen.container.textContent).toContain("Keep the canonical sources. [#1, #10]"); + expect(screen.container.textContent).toContain("Keep the canonical sources. [#1, #2, #10]"); expect(screen.container.textContent).toContain( "Outdated: source changed. The summary text is hidden", ); @@ -200,27 +254,45 @@ it("reads only while open and supported, shows pass/citations/stale reasons and expect(state.lists.at(-1)?.input.cursor).toBe(staleId); await screen.click("Earlier summaries"); expect(state.lists.at(-1)?.input.cursor).toBeUndefined(); - await screen.click("Show source messages"); + await screen.click("Show sources"); expect(state.expansions.at(-1)).toEqual({ environmentId: "remote", - input: { projectId: "project", threadId: "thread", summaryId: summary.id, offset: 0, limit: 6 }, + input: { + projectId: "project", + threadId: "thread", + summaryId: summary.id, + offset: 0, + textOffset: 0, + limit: 6, + maxChars: 12_000, + }, }); + expect(screen.container.textContent).toContain("Exact source 1: 日本語 🧪\n unchanged."); + expect(screen.container.textContent).not.toContain("Final evidence."); + await screen.click("Next sources"); + expect(state.expansions.at(-1)?.input).toMatchObject({ offset: 0, textOffset: 12_000 }); + expect(screen.container.textContent).toContain("Final evidence."); + expect(screen.container.textContent).not.toContain("Exact source 1:"); expect(screen.container.textContent).toContain("Source changed."); - expect(screen.container.textContent).toContain("Exact source 2: 日本語 🧪\n unchanged."); - await screen.click("Next messages"); + await screen.click("Previous sources"); + expect(state.expansions.at(-1)?.input).toMatchObject({ offset: 0, textOffset: 0 }); + expect(screen.container.textContent).toContain("Exact source 1:"); + await screen.click("Next sources"); + await screen.click("Next sources"); expect(state.expansions.at(-1)?.input.offset).toBe(6); expect(screen.container.textContent).toContain("#7 · Assistant"); - expect(screen.container.textContent).not.toContain("Exact source 2:"); + expect(screen.container.textContent).toContain("#10 · Tool"); + expect(screen.container.textContent).not.toContain("Exact source 1:"); expect( [...screen.container.querySelectorAll("button")].find( - (button) => button.textContent === "Next messages", + (button) => button.textContent === "Next sources", )?.disabled, ).toBe(true); - await screen.click("Previous messages"); - expect(state.expansions.at(-1)?.input.offset).toBe(0); + await screen.click("Previous sources"); + expect(state.expansions.at(-1)?.input).toMatchObject({ offset: 0, textOffset: 12_000 }); await screen.click("Back to summaries"); expect(screen.container.textContent).not.toContain("Exact source"); - await screen.click("Show source messages"); + await screen.click("Show sources"); const listsBeforeCapabilityLoss = state.lists.length; const expansionsBeforeCapabilityLoss = state.expansions.length; screen.setSupported(false); diff --git a/apps/mobile/src/features/conversation-summaries/ConversationSummariesRouteScreen.tsx b/apps/mobile/src/features/conversation-summaries/ConversationSummariesRouteScreen.tsx index 5c2d6e32d5757..29ddbed53c99c 100644 --- a/apps/mobile/src/features/conversation-summaries/ConversationSummariesRouteScreen.tsx +++ b/apps/mobile/src/features/conversation-summaries/ConversationSummariesRouteScreen.tsx @@ -1,11 +1,16 @@ import { useAtomValue } from "@effect/atom-react"; import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; -import { conversationSummaryPassLabel } from "@t3tools/client-runtime/conversationSummaries"; +import { + conversationSummaryPassLabel, + conversationSummarySourceRangeLabel, + type ConversationSummarySourceCursor, +} from "@t3tools/client-runtime/conversationSummaries"; import { KNOWLEDGE_SOURCE_UNAVAILABLE_LABEL } from "@t3tools/client-runtime/projectKnowledge"; import { EnvironmentId, ProjectId, ThreadId, + conversationSummarySourceKey, type ConversationSummaryId, type ConversationSummaryRead, } from "@t3tools/contracts"; @@ -13,7 +18,7 @@ import { useState } from "react"; import { ScrollView, View } from "react-native"; import { AppBar } from "../../components/ui/AppBar"; -import { AppText as Text } from "../../components/ui/AppText"; +import { AppText as Text, AppTextInput } from "../../components/ui/AppText"; import { Button } from "../../components/ui/Button"; import { ScreenContent } from "../../components/ui/ScreenContent"; import { @@ -71,7 +76,7 @@ function SummaryText({ read }: { readonly read: ConversationSummaryRead }) { ); const position = new Map( - read.summary.sources.map((source, index) => [source.messageId, index + 1]), + read.summary.sources.map((source, index) => [conversationSummarySourceKey(source), index + 1]), ); return ( @@ -91,9 +96,16 @@ function SummaryText({ read }: { readonly read: ConversationSummaryRead }) { function SummaryPages(props: Scope) { const [cursors, setCursors] = useState>([]); const [selected, setSelected] = useState(null); + const [searchText, setSearchText] = useState(""); + const [query, setQuery] = useState(""); + const search = () => { + setQuery(searchText.trim()); + setCursors([]); + }; return ( @@ -103,14 +115,42 @@ function SummaryPages(props: Scope) { ) : ( - setCursors((current) => current.slice(0, -1))} - onLater={(cursor) => setCursors((current) => [...current, cursor])} - onSelect={setSelected} - /> + <> + + + + - + + ) : null} - + ); } function SummaryStatements(props: { readonly summary: ConversationSummary }) { const position = new Map( - props.summary.sources.map((source, index) => [source.messageId, index + 1]), + props.summary.sources.map((source, index) => [conversationSummarySourceKey(source), index + 1]), ); return (