diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 6b9430b80..6e29069ca 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -247,6 +247,7 @@ "packages/app/src/i18n/uk.ts": "c1f2fb529563fa211e3e322aca0d9417a34909fd056ae80b65c3861b0d6caee4", "packages/app/src/i18n/zh.ts": "50755c05ef47e910c3de7c1471f66b7cd21cea93c005480636ec085a11d5e09b", "packages/app/src/i18n/zht.ts": "c65962a29d6d772d1333ce85a7b3a6f8f3246fbef634ec148cc6531253bbeae9", + "packages/app/src/pages/directory-layout.tsx": "e580e6197408358ddd44eaa76cddd0828e87db255a9068971998f787851369d6", "packages/app/src/pages/empty-workspace-landing.tsx": "ac9410f5b223b30e2d82a6d222992fed2226b6afc1213ff0c8305a230cda7cb2", "packages/app/src/pages/error.tsx": "db9ba0847cfb205100ad6133cce30c6ea2ce983802af44947e6d2b746c2a6ec5", "packages/app/src/pages/hold-debug-badge-gate.test.ts": "661f59e172a5b074a6807896f46c18338faa59b6d9c0912fd824df2dae4a5730", @@ -279,6 +280,8 @@ "packages/app/src/utils/amicode-workspace-projects.test.ts": "8b5c32172e2039b3d9c52f2257023263fc846132e7c185e4a63ae014bc2e2bfb", "packages/app/src/utils/amicode-workspace-projects.ts": "cbe091c463177375c93cb43676c202bd7bf06baf36fdec779f7d641558ff6bee", "packages/app/src/utils/chrome-dropdown.ts": "f8b878c5df4e1afd96b0751c73daaad0a8127411030724cb8f9a5089ebb29eac", + "packages/app/src/utils/diffs.test.ts": "38a5b5dbe23014660204fe08e30599dad16c93665eeaf555284915d0de83a569", + "packages/app/src/utils/diffs.ts": "313ec1085e24b5b8d2844cf42ea2caa93cda46081a6aa5691de5bce8a6686744", "packages/app/src/utils/global-clipboard.test.ts": "18ac0ea486d36eed00597f2718f03bffce70a6e6a8f77c79aefe0795790d8771", "packages/app/src/utils/global-clipboard.ts": "b26373289d7402c88e8038575703b11fa5e4ef5a7f50ee05bceb45488022ab26", "packages/app/src/utils/pane-bridge.ts": "a8a8a40dc60a367086f3c4e4c561a289fb85d8b2086a8d62c2b66102de652ce4", @@ -338,7 +341,7 @@ "packages/llm/src/protocols/bedrock-converse.ts": "834a415fa8ddf9ad1224133f8352d0a926694f19f0954201d182111c171ec03b", "packages/llm/test/provider/bedrock-converse.test.ts": "e8e14d60e8502e928f170f8ac5b406c44bf16d2dd34996ddc2fb7b9261decf85", "packages/opencode/src/agent/agent.ts": "512372a4c62997e9565266638ada19b9dc0a3685b0c0483fd28fede2abdb5c9b", - "packages/opencode/src/bus/global-replay.ts": "d4d64e027ab136655db088419b33e3ae524c1d3d8f892b5bb8697050ddc03d33", + "packages/opencode/src/bus/global-replay.ts": "db06eec6d70b7f81cff41b11172222320e93a411577e94039aef99f4ff473a04", "packages/opencode/src/bus/global.ts": "65b3b380936fc34b2c5ef9f3ab419a6c8e6d4cfc696302a266188f26ccdc320a", "packages/opencode/src/control-plane/workspace.ts": "ab986282d1bd15b7f4473e33766884e17c97684ef0778cd8d84fd173604175cc", "packages/opencode/src/effect/runner.ts": "b660b5b22df810b16293c23c6d832e5e8dcfb12b9a8a858c875d2c051c855b81", @@ -423,10 +426,11 @@ "packages/opencode/test/server/httpapi-compression.test.ts": "5f7603d7018e0b95f1c2f36c01dd297c01770bfcdd91ce29778e03b7d6143f75", "packages/opencode/test/server/httpapi-event-replay.test.ts": "c94c13dbfb4ca5d5e9b80f4c1a17955d1530a8bf9141b9a83a810ee56743269f", "packages/opencode/test/server/httpapi-event-sequencing.test.ts": "a2ec57f999b26b8ac9e4dfd6208c8028264cb0b267f4e6383429df62738967fa", + "packages/opencode/test/server/httpapi-event-summary-strip.test.ts": "bfb13fbacdb2764cdd11ab0e110c93e635ef68f70500b19333350e4805a9164a", "packages/opencode/test/server/httpapi-event.test.ts": "274619e3489c253b1693f4f0d42309f767fcce2074a0da406f8f9cfd27afb042", "packages/opencode/test/server/httpapi-file-write-gate.test.ts": "0c91b7417e615850edfde3f2878207dab6b6624d4cfbb6e6783b2e6d9aa4dff0", "packages/opencode/test/server/httpapi-file.test.ts": "d3386129bc81467ecbcc57a3861f53767ee56ac41b4ff6ad58d8b168fb3b3373", - "packages/opencode/test/server/httpapi-global-event.test.ts": "6359f12508e6a34bfd9e0abe72b03113f933e1c83b9c55aae2a84c4bc5222bad", + "packages/opencode/test/server/httpapi-global-event.test.ts": "444382b549bc70b8ccd7881a893eb4c68335a8d3e44b84b78b1cc4b48fc5d921", "packages/opencode/test/server/httpapi-instance-route-auth.test.ts": "4b73a89d2431c02bc3b665d93f9002914e51eb1add979dc1867efbcbc0c36e25", "packages/opencode/test/server/httpapi-provider.test.ts": "9e8b3e085b478e3f99b689ffff600e10c0b4b50b3cabdbd749f88dae19bf5904", "packages/opencode/test/server/httpapi-session.test.ts": "eda6fd763ac4c17891cedaa4ccd49597c03e7227fcfdcf0f94a1b4545ba623ed", @@ -437,6 +441,7 @@ "packages/opencode/test/server/session-diff-missing-patch.test.ts": "70526ca7fb86741843fd167f8f2f9c6f6d7e5e415a20a5448d17d24229bdd65b", "packages/opencode/test/server/session-diff-scoped.test.ts": "20ef80b92ad872573b371540575f028be8f1ad3c9f34d72f814b621e14e7a59e", "packages/opencode/test/server/sse-tick-telemetry.test.ts": "95738379df27d2da236f968a3d197cf27bcce17e8ec8ce1d288d4d2c1184d8ee", + "packages/opencode/test/server/summary-diff-wire.test.ts": "080236ce0a7985195a8f385ae65311d63dc18826439a9729c7c87ce4f55efd55", "packages/opencode/test/server/tcp-keepalive.test.ts": "e5c26de10fe44fd6485e0f75f667b2662f69896135f014b0b86772a6ca48fff5", "packages/opencode/test/server/workspace-routing.test.ts": "2574731ac379c540f450edc15f8750ac5360e8c33b4a0dad7c61e8eb686137bb", "packages/opencode/test/session/adversarial-e2e.test.ts": "1b471a69223d040c5d5f4f2ee99c2daa6df56bdf11679b65d902c15bedac3e80", @@ -496,11 +501,14 @@ "packages/session-ui/src/components/session-review.css": "ec8c5b359dd4b7ff80d7fad8906359678adade9ce62c9d74aedc000b646db341", "packages/session-ui/src/components/session-review.tsx": "d2015c9f7419279f08e84523439a0b73e83bd5d06587ad56b85ea53b66ecaee4", "packages/session-ui/src/components/session-turn.css": "792915f3373134eadf3cd51e1d4bfa88ec347e6b037b6caf60b639ec5e74fbd8", - "packages/session-ui/src/components/session-turn.tsx": "9cbc0d7a35f42e32dad53ace7b3c7d92623fa2d3108edb49e0b7bc868f5c1966", + "packages/session-ui/src/components/session-turn.tsx": "42f247b0434a9f52d88d9fb96adff59afe6edeab84dd912b62eb153253b6696f", + "packages/session-ui/src/components/summary-patch.test.ts": "84f2b5e5671b991130ce221ed605b214511491c883c683c9099173a4890e5d96", + "packages/session-ui/src/components/summary-patch.ts": "ab086ea1bc741e15b94f6e9aedc4d934cac17597f4aa1392b146994020006170", "packages/session-ui/src/components/tool-count-label.test.ts": "c62bbe2c662f6740794d25bd7c33fb3b9b46394a1b41d01d6568a8a2ee2b4e75", "packages/session-ui/src/components/tool-count-label.tsx": "06c96479710314d443d42d4c29a29b46d58a55ee84a844a5b6865b49a0513340", "packages/session-ui/src/components/tool-error-card.tsx": "e27b89836b7c709120ac15fbffd2200038944058ea3506e61e5897ffb3537503", "packages/session-ui/src/components/tool-status-title.tsx": "ae4561b58f55985d2294a352802e723b18943ac9a6551d97f1c4a6cc95368c8a", + "packages/session-ui/src/context/data.tsx": "7b840b5bf6929445d21da2cbd8e8006316c6950a09d559ea77e8c397e728cd99", "packages/session-ui/src/pierre/worker.ts": "7f210f931777956567eaeea15f2f5478befc7874a64a4b685504c59c66503fee", "packages/session-ui/src/util/clipboard.ts": "c37ba53914714ed04135e5a254e8ea90186f097d7470fa0873a83de82f3fef2d", "packages/ui/src/amicode/amico-presence.css": "b3f345b607e318dcfbefcdc677fe21cc7d387b9946b0f9d625b0a842fe7e31b9", @@ -980,7 +988,7 @@ "packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts": "048efa7b74d43c4ad4a75d0f8421c808760c8c0dcd593f0051a87044e40487d6", "packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts": "34c6f1ab1a085d1b047c220bbff32a36032dc5e924784e9272cda166cf0ec84b", "packages/opencode/src/server/routes/instance/httpapi/groups/session.ts": "1421f8c07948b121c7e2005bd9b0fcd6b2c456565ddd1da32d8e6c35d03fac9c", - "packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts": "505b8ed74ef7186ae14f051b0a645551b639effb1ef1cab5ca8282bf3542af47", + "packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts": "88c7bae32fa01ab213e95cb4e4891fd3ccb961782e80a416e321c5daf90fb487", "packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts": "69ca3dd48d95157fc822e1f76eb42ac873df50f22616b85194619ca658613867", "packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts": "10bcb79b117beebee04184fb48c1ad789e782e2f7ae4b0c68603edc00f03aee9", "packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts": "a31e4d0abe4f76e667167c5017aca5559b53938db63bdc1d656a154e1aaf5fea", @@ -1004,11 +1012,11 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-10-17T09:51:09.816Z", + "extracted_at": "2026-10-17T11:01:45.600Z", "per_package": { "packages/app": { "A": 251, - "M": 159, + "M": 162, "D": 0 }, "packages/core": { @@ -1022,7 +1030,7 @@ "D": 0 }, "packages/opencode": { - "A": 95, + "A": 97, "M": 97, "D": 0 }, @@ -1042,8 +1050,8 @@ "D": 0 }, "packages/session-ui": { - "A": 42, - "M": 31, + "A": 44, + "M": 32, "D": 0 }, "packages/ui": { @@ -1053,7 +1061,7 @@ } }, "counts": { - "overlay_total": 982, + "overlay_total": 990, "deletions": 7, "server_coupled": 0 }, @@ -1297,6 +1305,7 @@ "packages/app/src/i18n/uk.ts": "M", "packages/app/src/i18n/zh.ts": "M", "packages/app/src/i18n/zht.ts": "M", + "packages/app/src/pages/directory-layout.tsx": "M", "packages/app/src/pages/empty-workspace-landing.tsx": "A", "packages/app/src/pages/error.tsx": "M", "packages/app/src/pages/hold-debug-badge-gate.test.ts": "A", @@ -1329,6 +1338,8 @@ "packages/app/src/utils/amicode-workspace-projects.test.ts": "A", "packages/app/src/utils/amicode-workspace-projects.ts": "A", "packages/app/src/utils/chrome-dropdown.ts": "A", + "packages/app/src/utils/diffs.test.ts": "M", + "packages/app/src/utils/diffs.ts": "M", "packages/app/src/utils/global-clipboard.test.ts": "A", "packages/app/src/utils/global-clipboard.ts": "A", "packages/app/src/utils/pane-bridge.ts": "A", @@ -1473,6 +1484,7 @@ "packages/opencode/test/server/httpapi-compression.test.ts": "M", "packages/opencode/test/server/httpapi-event-replay.test.ts": "A", "packages/opencode/test/server/httpapi-event-sequencing.test.ts": "A", + "packages/opencode/test/server/httpapi-event-summary-strip.test.ts": "A", "packages/opencode/test/server/httpapi-event.test.ts": "M", "packages/opencode/test/server/httpapi-file-write-gate.test.ts": "A", "packages/opencode/test/server/httpapi-file.test.ts": "M", @@ -1487,6 +1499,7 @@ "packages/opencode/test/server/session-diff-missing-patch.test.ts": "M", "packages/opencode/test/server/session-diff-scoped.test.ts": "A", "packages/opencode/test/server/sse-tick-telemetry.test.ts": "A", + "packages/opencode/test/server/summary-diff-wire.test.ts": "A", "packages/opencode/test/server/tcp-keepalive.test.ts": "A", "packages/opencode/test/server/workspace-routing.test.ts": "M", "packages/opencode/test/session/adversarial-e2e.test.ts": "A", @@ -1547,10 +1560,13 @@ "packages/session-ui/src/components/session-review.tsx": "M", "packages/session-ui/src/components/session-turn.css": "M", "packages/session-ui/src/components/session-turn.tsx": "M", + "packages/session-ui/src/components/summary-patch.test.ts": "A", + "packages/session-ui/src/components/summary-patch.ts": "A", "packages/session-ui/src/components/tool-count-label.test.ts": "A", "packages/session-ui/src/components/tool-count-label.tsx": "M", "packages/session-ui/src/components/tool-error-card.tsx": "M", "packages/session-ui/src/components/tool-status-title.tsx": "M", + "packages/session-ui/src/context/data.tsx": "M", "packages/session-ui/src/pierre/worker.ts": "M", "packages/session-ui/src/util/clipboard.ts": "A", "packages/ui/src/amicode/amico-presence.css": "A", diff --git a/packages/app-bundle/overlay/packages/app/src/pages/directory-layout.tsx b/packages/app-bundle/overlay/packages/app/src/pages/directory-layout.tsx new file mode 100644 index 000000000..7120fd4cc --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/directory-layout.tsx @@ -0,0 +1,135 @@ +import { DataProvider } from "@opencode-ai/session-ui/context" +import { showToast } from "@/utils/toast" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { useLocation, useNavigate, useParams } from "@solidjs/router" +import { type Accessor, createEffect, createMemo, createResource, onCleanup, type ParentProps, Show } from "solid-js" +import { useLanguage } from "@/context/language" +import { LocalProvider } from "@/context/local" +import { SDKProvider, useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { decode64 } from "@/utils/base64" +import { Schema } from "effect" +import type { ServerConnection } from "@/context/server" +import { sessionHref } from "@/utils/session-route" +import { useServerSync } from "@/context/server-sync" + +export function DirectoryDataProvider( + props: ParentProps<{ + directory: string | Accessor + draftID?: string + server?: Accessor + }>, +) { + const location = useLocation() + const navigate = useNavigate() + const params = useParams() + const sdk = useSDK() + const sync = useSync() + const serverSync = useServerSync() + const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory) + const slug = createMemo(() => base64Encode(directory())) + const href = (sessionID: string) => { + const server = props.server?.() + if (server) return sessionHref(server, sessionID) + return `/${slug()}/session/${sessionID}` + } + // #1778 (amicode overlay): live summaries carry no patch bodies; the per-turn + // diff view fetches a turn's stored diffs (patches included) when a file is + // expanded — the stored message is the source. + const loadSummaryDiffs = ({ sessionID, messageID }: { sessionID: string; messageID: string }) => + sdk() + .client.session.message({ sessionID, messageID }) + .then((response) => { + const info = response.data?.info + return info?.role === "user" ? info.summary?.diffs : undefined + }) + + createEffect(() => { + // A draft lives at /new-session?draftId=… and has no directory segment to normalize. + if (props.draftID || props.server?.()) return + const next = sync().data.path.directory + if (!next || next === directory()) return + const path = location.pathname.slice(slug().length + 1) + navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) + }) + + createResource( + () => params.id, + (id) => + sync() + .session.sync(id) + .catch(() => {}), + ) + + createEffect(() => { + const sessionID = params.id + if (!sessionID) return + serverSync().session.pin(sessionID) + onCleanup(() => serverSync().session.unpin(sessionID)) + }) + + return ( + + {(directory) => ( + navigate(href(sessionID))} + onSessionHref={href} + onLoadSummaryDiffs={loadSummaryDiffs} + > + {props.children} + + )} + + ) +} + +export const ProjectDirString = Schema.String.pipe(Schema.brand("ProjectDirString")) +export type ProjectDirString = Schema.Schema.Type + +export function decodeDirectory(dir: string): ProjectDirString | undefined { + const decoded = decode64(dir) + if (!decoded) return + return ProjectDirString.make(decoded) +} + +export default function Layout(props: ParentProps) { + const params = useParams() + const language = useLanguage() + const navigate = useNavigate() + let invalid = "" + + const resolved = createMemo(() => { + if (!params.dir) return "" + return decodeDirectory(params.dir) ?? "" + }) + + createEffect(() => { + const dir = params.dir + if (!dir) return + if (resolved()) { + invalid = "" + return + } + if (invalid === dir) return + invalid = dir + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: language.t("directory.error.invalidUrl"), + }) + navigate("/", { replace: true }) + }) + + return ( + + {(resolved) => ( + + {props.children} + + )} + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/utils/diffs.test.ts b/packages/app-bundle/overlay/packages/app/src/utils/diffs.test.ts new file mode 100644 index 000000000..bfa354df6 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/utils/diffs.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" +import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" +import type { Message } from "@opencode-ai/sdk/v2/client" +import { diffs, message } from "./diffs" + +const item = { + file: "src/app.ts", + patch: "@@ -1 +1 @@\n-old\n+new\n", + additions: 1, + deletions: 1, + status: "modified", +} satisfies FileDiffInfo & SnapshotFileDiff + +describe("diffs", () => { + test("keeps valid arrays", () => { + expect(diffs([item])).toEqual([item]) + }) + + test("wraps a single diff object", () => { + expect(diffs(item)).toEqual([item]) + }) + + test("reads keyed diff objects", () => { + expect(diffs({ a: item })).toEqual([item]) + }) + + test("drops invalid entries", () => { + expect( + diffs([ + item, + { file: "src/bad.ts", patch: 42, additions: 1, deletions: 1 }, + { file: "src/bad.ts", additions: "1", deletions: 1 }, + { patch: item.patch, additions: 1, deletions: 1 }, + ]), + ).toEqual([item]) + }) + + // #1778 (amicode overlay): a live summary's diffs carry no patch body — the + // engine strips it on the wire, and the per-turn diff view fetches it on + // demand. A patch-less diff (file, status, counts) is valid, not dropped: + // before, the whole live file list vanished. + test("keeps a patch-less diff — the live wire form", () => { + const { patch: _patch, ...wire } = item + expect(diffs([item, wire, { file: "src/new.ts", additions: 4, deletions: 0 }])).toEqual([ + item, + wire, + { file: "src/new.ts", additions: 4, deletions: 0 }, + ]) + }) +}) + +describe("message", () => { + test("normalizes user summaries with object diffs", () => { + const input = { + id: "msg_1", + sessionID: "ses_1", + role: "user", + time: { created: 1 }, + agent: "build", + model: { providerID: "openai", modelID: "gpt-5" }, + summary: { + title: "Edit", + diffs: { a: item }, + }, + } as unknown as Message + + expect(message(input)).toMatchObject({ + summary: { + title: "Edit", + diffs: [item], + }, + }) + }) + + test("drops invalid user summaries", () => { + const input = { + id: "msg_1", + sessionID: "ses_1", + role: "user", + time: { created: 1 }, + agent: "build", + model: { providerID: "openai", modelID: "gpt-5" }, + summary: true, + } as unknown as Message + + expect(message(input)).toMatchObject({ summary: undefined }) + }) + + test("a live message.updated summary keeps its file list and counts without patch bodies (#1778)", () => { + const wire = [ + { file: "src/app.ts", additions: 1, deletions: 1, status: "modified" }, + { file: "src/new.ts", additions: 4, deletions: 0, status: "added" }, + ] + const input = { + id: "msg_1", + sessionID: "ses_1", + role: "user", + time: { created: 1 }, + agent: "build", + model: { providerID: "openai", modelID: "gpt-5" }, + summary: { title: "Edit", diffs: wire }, + } as unknown as Message + + // unchanged: the normalizer returns the very same message + expect(message(input)).toBe(input) + }) +}) + +describe("directory data provider — on-demand summary patches (#1778)", () => { + const source = readFileSync(resolve(__dirname, "../pages/directory-layout.tsx"), "utf8") + + test("supplies the per-turn diff view's patch source from the stored message", () => { + expect(source).toContain("onLoadSummaryDiffs={loadSummaryDiffs}") + expect(source).toContain(".client.session.message({ sessionID, messageID })") + expect(source).toContain("summary?.diffs") + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/utils/diffs.ts b/packages/app-bundle/overlay/packages/app/src/utils/diffs.ts new file mode 100644 index 000000000..47415f871 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/utils/diffs.ts @@ -0,0 +1,53 @@ +import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" +import type { Message } from "@opencode-ai/sdk/v2/client" + +type Diff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff + +function diff(value: unknown): value is Diff { + if (!value || typeof value !== "object" || Array.isArray(value)) return false + if (!("file" in value) || typeof value.file !== "string") return false + // #1778 (amicode overlay): a live summary diff has no patch body (the engine + // strips it on the wire; it is fetched on demand) — absent is valid, a + // non-string patch is not. + if ("patch" in value && value.patch !== undefined && typeof value.patch !== "string") return false + if (!("additions" in value) || typeof value.additions !== "number") return false + if (!("deletions" in value) || typeof value.deletions !== "number") return false + if (!("status" in value) || value.status === undefined) return true + return value.status === "added" || value.status === "deleted" || value.status === "modified" +} + +function object(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +export function diffs(value: unknown): Diff[] { + if (Array.isArray(value) && value.every(diff)) return value + if (Array.isArray(value)) return value.filter(diff) + if (diff(value)) return [value] + if (!object(value)) return [] + return Object.values(value).filter(diff) +} + +export function message(value: Message): Message { + if (value.role !== "user") return value + + const raw = value.summary as unknown + if (raw === undefined) return value + if (!object(raw)) return { ...value, summary: undefined } + + const title = typeof raw.title === "string" ? raw.title : undefined + const body = typeof raw.body === "string" ? raw.body : undefined + const next = diffs(raw.diffs) + + if (title === raw.title && body === raw.body && next === raw.diffs) return value + + return { + ...value, + summary: { + ...(title === undefined ? {} : { title }), + ...(body === undefined ? {} : { body }), + diffs: next, + }, + } +} diff --git a/packages/app-bundle/overlay/packages/opencode/src/bus/global-replay.ts b/packages/app-bundle/overlay/packages/opencode/src/bus/global-replay.ts index 7d49f3764..9467e70a3 100644 --- a/packages/app-bundle/overlay/packages/opencode/src/bus/global-replay.ts +++ b/packages/app-bundle/overlay/packages/opencode/src/bus/global-replay.ts @@ -16,8 +16,10 @@ // payload ids (#1264), and wire compatibility requires its cursors to keep // being served — resolved when the retained history covers them, reset as // evicted when it does not; -// - main's patch-body wire cut (#1778's wireEvent, 06eac851) is P3 and is -// deliberately NOT ported — the serialized frame is the whole event. +// - #1814 P3 ports main's patch-body wire cut (#1778's wireEvent, 06eac851): +// the serialized frame and the instance route's payload view both strip +// summary-diff patch bodies (`wirePayload` below), while the `event` the +// buffer keeps — what in-process subscribers and storage see — is whole. import { GlobalBus, type GlobalEvent } from "./global" export type Frame = { @@ -257,6 +259,48 @@ export const GlobalEventReplay = new ReplayBuffer({ start: GlobalBus.seq, }) +/** + * The wire form of an event payload (#1778, #1814 P3). A per-turn summary + * (`info.summary.diffs` of a message or session) carries the full patch text + * of every changed file — measured up to 12.3 MB in one event. On the wire each + * diff keeps its file, status and counts and drops its patch body; the surfaces + * that render a patch fetch the stored message on demand. Only the wire forms + * change: the event object (what in-process subscribers and storage see) is + * never mutated, and a sync copy is sent whole — its subscriber replays it into + * a store of its own, which must keep the full record. + */ +export function wirePayload(payload: T): T { + if (!payload || typeof payload !== "object" || payload.type === "sync") return payload + const info = payload.properties?.info + const diffs = info?.summary?.diffs + if (!Array.isArray(diffs) || !diffs.some((diff) => diff && typeof diff === "object" && "patch" in diff)) return payload + return { + ...payload, + properties: { + ...payload.properties, + info: { + ...info, + summary: { + ...info.summary, + diffs: diffs.map((diff) => { + if (!diff || typeof diff !== "object" || !("patch" in diff)) return diff + const { patch: _patch, ...rest } = diff + return rest + }), + }, + }, + }, + } +} + +/** The wire form of a global event: `wirePayload` over its payload. */ +export function wireEvent(event: GlobalEvent): GlobalEvent { + const payload = event.payload + if (!payload || typeof payload !== "object" || payload.type === "sync") return event + const next = wirePayload(payload) + return next === payload ? event : { ...event, payload: next } +} + GlobalBus.tap((event, seq) => { // A sync copy whose sequence was already handed out mirrors its source. One // that consumed a fresh sequence (no source framed nearby) is a frame of its @@ -265,7 +309,7 @@ GlobalBus.tap((event, seq) => { let data: string try { // serialized once: every subscriber, live or replayed, gets this same frame - data = JSON.stringify(event) + data = JSON.stringify(wireEvent(event)) } catch (error) { // An unserializable event cannot be framed; its sequence becomes a hole the // client detects (heartbeats carry the current sequence), never a dead stream. diff --git a/packages/app-bundle/overlay/packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts b/packages/app-bundle/overlay/packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts index 723fe6d9e..e5e076975 100644 --- a/packages/app-bundle/overlay/packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts +++ b/packages/app-bundle/overlay/packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts @@ -1,5 +1,5 @@ import { InstanceState } from "@/effect/instance-state" -import { GlobalEventReplay, formatID, type Frame } from "@/bus/global-replay" +import { GlobalEventReplay, formatID, wirePayload, type Frame } from "@/bus/global-replay" import { sseTickTelemetry } from "@/server/shared/sse-tick-telemetry" import { armSseSocketKeepalive } from "@/server/shared/tcp-keepalive" import { NodeHttpServerRequest } from "@effect/platform-node" @@ -87,7 +87,11 @@ function eventResponse() { return true } const payloadOf = (frame: Frame): SsePayload => { - const payload = frame.event.payload as SsePayload + // #1814 P3 (main's #1778 wire cut, lane-adapted): this route serializes + // its payload view from the frame's live `event`, so the patch-body strip + // must be applied HERE too — the shared buffer's serialized `data` covers + // only the global route. In-process subscribers keep the whole event. + const payload = wirePayload(frame.event.payload) as SsePayload return { id: payload.id, type: payload.type, properties: payload.properties } } // The shared replay buffer is the connection's live source: one buffer per diff --git a/packages/app-bundle/overlay/packages/opencode/test/server/httpapi-event-summary-strip.test.ts b/packages/app-bundle/overlay/packages/opencode/test/server/httpapi-event-summary-strip.test.ts new file mode 100644 index 000000000..c9037de2c --- /dev/null +++ b/packages/app-bundle/overlay/packages/opencode/test/server/httpapi-event-summary-strip.test.ts @@ -0,0 +1,177 @@ +/** + * #1814 P3 (lane adaptation of main's 06eac851) — the instance /event route + * is the wire the APP's session view consumes, and unlike main this engine + * serves TWO wire shapes off the one replay buffer: the global route streams + * the buffer's serialized `data` (main's shape, stripped once at emit), while + * the instance route projects its payload view from the frame's live `event` + * object. The patch-body cut must hold on BOTH wires: a summary-bearing + * message.updated reaches this route with file, status and counts and no patch + * text, and the frame stays under #1803's per-frame ceiling. The stored + * message (the on-demand patch source) is untouched — proven end-to-end in + * summary-diff-wire.test.ts. + */ +import { afterEach, describe, expect } from "bun:test" +import { Effect, Queue, Stream } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event" +import { GlobalBus, type GlobalEvent } from "../../src/bus/global" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" + +const it = testEffect(httpApiLayer) + +const KB = 1024 +const STRIP_CEILING = 256 * KB + +type Block = { id: string | undefined; bytes: number; data: string } + +// The raw-block variant of the sequencing suite's openFrames: every SSE block +// with its whole on-the-wire size (the byte number the ceiling asserts on). +const openBlocks = (input: { directory: string; headers?: Record }) => + Effect.gen(function* () { + const url = new URL(EventPaths.event, "http://localhost") + const response = yield* HttpClientRequest.fromWeb( + new Request(url, { headers: { "x-opencode-directory": input.directory, ...input.headers } }), + ).pipe( + HttpClientRequest.setUrl(url.pathname + url.search), + HttpClient.execute, + ) + const blocks = yield* Queue.unbounded() + // SSE frames can straddle body chunks — accumulate and split on the + // frame boundary, keeping the trailing partial block buffered. + const state = { buffer: "" } + yield* response.stream.pipe( + Stream.decodeText(), + Stream.map((chunk: string) => { + state.buffer += chunk + const frames = state.buffer.split("\n\n") + state.buffer = frames.pop() ?? "" + return frames + }), + Stream.flatMap((frames: Array) => Stream.fromArray(frames)), + Stream.filter((frame: string) => frame.includes("data:")), + Stream.mapEffect((frame: string) => + Effect.sync(() => { + const lines = frame.split("\n") + const data = lines.find((line) => line.startsWith("data: "))?.slice(6) + if (data === undefined) return undefined + const id = lines.find((line) => line.startsWith("id: "))?.slice(4) + return { id, bytes: Buffer.byteLength(frame) + 2, data } satisfies Block + }), + ), + Stream.filter((block): block is Block => block !== undefined), + Stream.runForEach((block) => Queue.offer(blocks, block)), + Effect.forkScoped, + ) + return blocks + }) + +const nextBlock = (blocks: Queue.Dequeue) => + Queue.take(blocks).pipe( + Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.die(new Error("timed out waiting for block")) }), + ) + +/** Skip to the frame of `type`, decoding the last one seen on the way. */ +const untilType = (blocks: Queue.Dequeue, type: string) => + Effect.gen(function* () { + for (;;) { + const block = yield* nextBlock(blocks) + const parsed = JSON.parse(block.data) as { type: string } + if (parsed.type === type) return block + } + }) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +const diffs = [ + { file: "src/app.ts", patch: "@@ -1,2 +1,2 @@\n-old line\n+new line\n context\n", additions: 1, deletions: 1, status: "modified" as const }, + { file: `src/new.ts`, patch: `@@ -0,0 +1,3000 @@\n${"+x\n".repeat(3000)}`, additions: 3000, deletions: 0, status: "added" as const }, +] +const stripped = () => diffs.map(({ patch: _patch, ...rest }) => rest) + +describe("instance event stream — summary diffs carry no patch bodies (#1814 P3 lane adaptation)", () => { + it.instance( + "a summary-bearing message.updated reaches the app's wire stripped and under the ceiling; the bus payload keeps the patches", + () => + Effect.gen(function* () { + const { directory } = yield* TestInstance + const blocks = yield* openBlocks({ directory }) + yield* untilType(blocks, "server.connected") + + const seen: GlobalEvent[] = [] + const listener = (event: GlobalEvent) => seen.push(event) + GlobalBus.on("event", listener) + yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", listener))) + + const event: GlobalEvent = { + directory, + payload: { + id: "evt_i1814_strip", + type: "message.updated", + properties: { + sessionID: "ses_i1814", + info: { id: "msg_i1814", sessionID: "ses_i1814", role: "user", summary: { title: "Edit", diffs } }, + }, + }, + } + GlobalBus.emit("event", event) + + const block = yield* untilType(blocks, "message.updated") + const parsed = JSON.parse(block.data) + // the wire keeps the file list and counts and drops every patch body + expect(parsed.properties.info.summary.diffs).toEqual(stripped()) + expect(parsed.properties.info.summary.title).toBe("Edit") + expect(block.data).not.toContain('"patch"') + // the frame stays under #1803's per-frame ceiling + expect(block.bytes).toBeLessThan(STRIP_CEILING) + // the projection is wire-only: in-process subscribers see the full event + expect(seen.find((candidate) => candidate.payload.type === "message.updated")?.payload.properties.info.summary.diffs).toEqual(diffs) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "a replayed summary frame through the instance route is stripped too", + () => + Effect.gen(function* () { + const { directory } = yield* TestInstance + const live = yield* openBlocks({ directory }) + const connected = yield* untilType(live, "server.connected") + // the control frame's id line is the connection's position — the + // sequence the summary event will follow. + const cursor = (JSON.parse(connected.data) as { id?: string }).id + + GlobalBus.emit("event", { + directory, + payload: { + id: "evt_i1814_replay_src", + type: "message.updated", + properties: { + sessionID: "ses_i1814", + info: { id: "msg_i1814r", sessionID: "ses_i1814", role: "user", summary: { title: "Edit", diffs } }, + }, + }, + }) + const liveBlock = yield* untilType(live, "message.updated") + const liveParsed = JSON.parse(liveBlock.data) + + // reconnect from the pre-event position: the replayed summary frame is + // the same stripped shape (the shared buffer serves both routes' + // replay paths). + const resumed = yield* openBlocks({ directory, headers: { "last-event-id": cursor! } }) + yield* untilType(resumed, "server.connected") + const replayed = yield* untilType(resumed, "message.updated") + const replayedParsed = JSON.parse(replayed.data) + expect((replayedParsed as { id?: string }).id).toBe((liveParsed as { id?: string }).id) + expect(replayedParsed.properties.info.summary.diffs).toEqual(liveParsed.properties.info.summary.diffs) + expect(replayed.data).not.toContain('"patch"') + expect(replayed.bytes).toBeLessThan(STRIP_CEILING) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) +}) diff --git a/packages/app-bundle/overlay/packages/opencode/test/server/httpapi-global-event.test.ts b/packages/app-bundle/overlay/packages/opencode/test/server/httpapi-global-event.test.ts index cf02173a8..8427eeee6 100644 --- a/packages/app-bundle/overlay/packages/opencode/test/server/httpapi-global-event.test.ts +++ b/packages/app-bundle/overlay/packages/opencode/test/server/httpapi-global-event.test.ts @@ -4,8 +4,8 @@ // carry time + seq, a client abort releases its subscription, and the lane's // bounded-write + tick-telemetry machinery covers the route (SSE-TICK lines // flow, a stalled write drops, never SELF-STALLs). Sync copies are opt-in per -// subscription (#1778's subscription flag rides with the port; main's -// patch-body wire cuts are P3 and stay out). +// subscription (#1778's subscription flag rides with the port; the patch-body +// wire cut is #1814 P3, ported from main's 06eac851 below). // // Lane adaptation vs main's suite: a cursor that is not `:` (the // lane's client tracks payload ids) resolves against retained frames instead @@ -314,6 +314,219 @@ describe("global event stream — sync copies are opt-in (#1778 subscription fla ) }) +// ── #1778/#1814: summary diffs carry no patch bodies on the wire ──────────────── +// +// A per-turn summary as the engine publishes it: every changed file with its +// full patch text (measured up to 12.3 MB in one event on the Studio; the live +// flood baseline is 1,800 frames/30s during a tool-heavy turn). On the wire +// each diff keeps its file, status and counts and drops its patch body; the +// surfaces that render a patch fetch the stored message on demand. +const summaryDiffs = () => [ + { file: "src/app.ts", patch: "@@ -1,2 +1,3 @@\n-old\n+new\n+more\n", additions: 2, deletions: 1, status: "modified" }, + { file: "src/new.ts", patch: `@@ -0,0 +1,400 @@\n${"+line\n".repeat(400)}`, additions: 400, deletions: 0, status: "added" }, + { file: "src/gone.ts", patch: "@@ -1 +0,0 @@\n-gone\n", additions: 0, deletions: 1, status: "deleted" }, +] +const stripped = (diffs: ReturnType) => diffs.map(({ patch: _patch, ...rest }) => rest) + +/** A summary-bearing durable event and its sync copy, as the event-v2 bridge emits them. */ +const summaryTwin = (id: string, type: "message.updated" | "session.updated") => { + const info = + type === "message.updated" + ? { id: "msg_i1778", sessionID: "ses_i1778", role: "user", summary: { title: "Edit", diffs: summaryDiffs() } } + : { id: "ses_i1778", title: "Session", summary: { additions: 402, deletions: 2, files: 3, diffs: summaryDiffs() } } + const properties = { sessionID: "ses_i1778", info } + const source = { directory: "/tmp/i1778", payload: { id, type, properties } } + const copy = { + directory: "/tmp/i1778", + payload: { type: "sync", syncEvent: { id, type: `${type}.1`, seq: 3, aggregateID: "ses_i1778", data: properties } }, + } + return { source, copy } +} + +describe("global event stream — summary diffs carry no patch bodies on the wire (#1778, #1814 P3)", () => { + for (const type of ["message.updated", "session.updated"] as const) { + it.live(`a live ${type} keeps file, status and counts but drops patch bodies; the bus payload keeps them`, () => + Effect.gen(function* () { + const plain = yield* connect() + const synced = yield* connect({ headers: { "x-opencode-sync-copies": "true" } }) + yield* plain.next + yield* synced.next + const seen: GlobalEvent[] = [] + const listener = (event: GlobalEvent) => seen.push(event) + GlobalBus.on("event", listener) + yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", listener))) + + const { source, copy } = summaryTwin(`evt_i1778_${type}`, type) + GlobalBus.emit("event", source) + GlobalBus.emit("event", copy) + + const live = yield* plain.next + expect(live.data.payload.type).toBe(type) + const info = live.data.payload.properties.info + expect(info.summary.diffs).toEqual(stripped(summaryDiffs())) + expect(live.raw).not.toContain('"patch"') + // everything else in the event is as published + expect({ ...info, summary: { ...info.summary, diffs: undefined } }).toEqual({ + ...source.payload.properties.info, + summary: { ...source.payload.properties.info.summary, diffs: undefined }, + }) + // the projection is wire-only: in-process subscribers see the full event, untouched + expect(seen.find((event) => event.payload.type === type)?.payload.properties.info.summary.diffs).toEqual( + summaryDiffs(), + ) + + // the opt-in subscriber gets the same stripped frame, and a full sync copy + // (the replica replays it into its own store, so it keeps the patches) + const [same, sync] = yield* synced.take(2) + expect(same.raw).toBe(live.raw) + expect(same.id).toBe(live.id) + expect(sync.data.payload.type).toBe("sync") + expect(sync.data.payload.syncEvent.data.info.summary.diffs).toEqual(summaryDiffs()) + }), + ) + } + + it.live("a replayed summary frame is byte-identical to the live one", () => + Effect.gen(function* () { + const a = yield* connect() + const cursor = (yield* a.next).id! + const { source } = summaryTwin("evt_i1778_replayed", "message.updated") + GlobalBus.emit("event", source) + const live = yield* a.next + a.abort() + + const b = yield* connect({ headers: { "last-event-id": cursor } }) + yield* b.next + const replayed = yield* b.next + expect(replayed.id).toBe(live.id) + expect(replayed.raw).toBe(live.raw) + expect(replayed.raw).not.toContain('"patch"') + }), + ) +}) + +// ── #1814 P3: the flood byte report — a file-heavy turn on the global wire ───── +// +// The live flood measured 1,800 frames/30s (~60fps) during running turns, the +// biggest bodies being per-turn summaries carrying every file's full patch +// text. This report measures what one such turn costs every subscriber BEFORE +// the wire cut (each event serialized whole — the pre-port framing formula) +// and AFTER (the wire as this engine serves it). The ceiling is #1803's P3 +// per-frame bound (256 KB). Scope: the patch-body cut only — main's shell- +// output throttling and its combined fixture turn ride 06eac851's neighbors +// (6fdcbb77/72525732) and stay follow-up slices. +const FLOOD = { edits: 20, summaryFiles: 88, patchBytes: 30_000 } +const FLOOD_CEILING = 256 * 1024 +const KB = 1024 + +// the engine's SSE framing (the encoder omits the default `event: message` line) +const sseBytes = (id: string, data: string) => Buffer.byteLength(`id: ${id}\ndata: ${data}\n\n`) + +const patchFor = (file: string, bytes: number) => { + const line = `+ export const value_${file.length} = "${"x".repeat(60)}"\n` + return `--- a/${file}\n+++ b/${file}\n@@ -1,0 +1,${Math.ceil(bytes / line.length)} @@\n${line.repeat(Math.ceil(bytes / line.length))}` +} + +describe("global event stream — file-heavy turn flood byte report (#1814 P3)", () => { + it.live("a file-heavy turn's biggest summary frames drop under the 256 KB ceiling", () => + Effect.gen(function* () { + const a = yield* connect() + yield* a.next + const sessionID = "ses_i1814_flood" + const before: number[] = [] + const emitted: { event: GlobalEvent }[] = [] + + // Emit like the event-v2 bridge: each durable event through the bus, and + // record the pre-port wire formula (the whole event serialized) for it. + const publish = (event: GlobalEvent) => { + GlobalBus.emit("event", event) + emitted.push({ event }) + before.push(sseBytes(`${GlobalBus.epoch}:${GlobalBus.seq}`, JSON.stringify(event))) + } + + // the turn: a stream of edit-tool completions, then the per-turn summary + // carrying every changed file with its full patch text. + for (let i = 0; i < FLOOD.edits; i++) { + const file = `packages/app/src/feature/module-${i}.ts` + publish({ + directory: "/tmp/i1814", + payload: { + id: `evt_i1814_edit_${i}`, + type: "message.part.updated", + properties: { + sessionID, + part: { id: `prt_i1814_${i}`, sessionID, messageID: "msg_i1814_asst", type: "tool", callID: `edit_${i}`, tool: "edit", state: { status: "completed", title: file } }, + time: i, + }, + }, + }) + } + const diffs = Array.from({ length: FLOOD.summaryFiles }, (_, i) => ({ + file: `packages/app/src/feature/module-${i}.ts`, + patch: patchFor(`packages/app/src/feature/module-${i}.ts`, FLOOD.patchBytes), + additions: 400, + deletions: 12, + status: "modified", + })) + publish({ + directory: "/tmp/i1814", + payload: { + id: "evt_i1814_summary", + type: "message.updated", + properties: { sessionID, info: { id: "msg_i1814_user", sessionID, role: "user", summary: { title: "Edit 88 modules", diffs } } }, + }, + }) + publish({ + directory: "/tmp/i1814", + payload: { + id: "evt_i1814_session", + type: "session.updated", + properties: { sessionID, info: { id: sessionID, title: "flood", summary: { additions: 400 * 88, deletions: 12 * 88, files: 88, diffs } } }, + }, + }) + + const frames = yield* a.take(emitted.length) + // the wire carries exactly the turn's events — no sync copy, no hole + expect(frames.filter((f) => f.data.payload.type === "sync")).toEqual([]) + // the BEFORE formula is the wire's own framing + for (const frame of frames) expect(sseBytes(frame.id!, frame.raw)).toBe(frame.bytes) + + const total = (sizes: number[]) => sizes.reduce((sum, n) => sum + n, 0) + const largest = (sizes: number[]) => Math.max(...sizes) + const after = frames.map((frame) => frame.bytes) + const summaryFrames = frames.filter((f) => f.data.payload.type !== "message.part.updated") + const report = { + before: { frames: before.length, totalBytes: total(before), largestFrameBytes: largest(before) }, + after: { frames: after.length, totalBytes: total(after), largestFrameBytes: largest(after) }, + summaryFrames: { + beforeBytes: total(before.slice(-summaryFrames.length)), + afterBytes: total(summaryFrames.map((frame) => frame.bytes)), + }, + } + console.log(`[#1814 flood byte report] ${JSON.stringify(report)}`) + console.log( + `[#1814 flood byte report] total ${(report.before.totalBytes / KB / KB).toFixed(2)} MiB -> ${(report.after.totalBytes / KB / KB).toFixed(2)} MiB` + + ` (${(100 * (1 - report.after.totalBytes / report.before.totalBytes)).toFixed(1)}% cut); largest frame ${(report.before.largestFrameBytes / KB).toFixed(1)} KiB -> ${(report.after.largestFrameBytes / KB).toFixed(1)} KiB;` + + ` summary frames ${report.summaryFrames.beforeBytes} -> ${report.summaryFrames.afterBytes} bytes`, + ) + + // the fixture is honest: BEFORE breaks the ceiling + expect(report.before.largestFrameBytes).toBeGreaterThan(FLOOD_CEILING) + // AFTER: every frame on the wire is under the measured ceiling + expect(report.after.largestFrameBytes).toBeLessThan(FLOOD_CEILING) + // and the stream measurably shrank + expect(report.after.totalBytes).toBeLessThan(report.before.totalBytes) + // the wire keeps each summary's file list — patch-less, not vanished + for (const frame of summaryFrames) { + const wireDiffs = frame.data.payload.properties.info.summary.diffs + expect(wireDiffs).toHaveLength(FLOOD.summaryFiles) + expect(wireDiffs.every((diff: any) => diff.file && diff.additions !== undefined && diff.status !== undefined)).toBe(true) + expect(frame.raw).not.toContain('"patch"') + } + }), + ) +}) + describe("global event stream — resume from a cursor (#1776 port)", () => { for (const carrier of ["Last-Event-ID header", "lastEventID query"] as const) { it.live(`replays exactly the frames after a ${carrier} cursor, then continues live`, () => diff --git a/packages/app-bundle/overlay/packages/opencode/test/server/summary-diff-wire.test.ts b/packages/app-bundle/overlay/packages/opencode/test/server/summary-diff-wire.test.ts new file mode 100644 index 000000000..50454d5de --- /dev/null +++ b/packages/app-bundle/overlay/packages/opencode/test/server/summary-diff-wire.test.ts @@ -0,0 +1,110 @@ +/** + * #1778 — summary patch bodies are cut on the wire only. A real user message + * with per-file summary diffs goes through the session store and the event-v2 + * bridge: its live global-stream frame carries file/status/counts and no patch + * text, while the stored message — what `GET /session/:id/message/:messageID` + * serves, the source the per-turn diff view fetches a patch from on demand — + * keeps every patch. The durable event's sync copy (sent only to subscribers + * that replay it into a store of their own) keeps them too. + */ +import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Database } from "@opencode-ai/core/database/database" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { Effect, Layer } from "effect" +import { GlobalEventReplay, type Frame } from "@/bus/global-replay" +import { Session } from "@/session/session" +import { Snapshot } from "@/snapshot" +import { Storage } from "@/storage/storage" +import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session" +import { MessageID } from "@/session/schema" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" + +const it = testEffect( + Layer.mergeAll( + LayerNode.compile(LayerNode.group([Session.node, Snapshot.node, Storage.node, FSUtil.node, Database.node])), + httpApiLayer, + ), +) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +const pathFor = (template: string, params: Record) => + Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), template) + +const diffs = [ + { file: "src/main.ts", patch: "@@ -1 +1,2 @@\n-a\n+b\n+c\n", additions: 2, deletions: 1, status: "modified" as const }, + { file: "src/new.ts", patch: `@@ -0,0 +1,300 @@\n${"+x\n".repeat(300)}`, additions: 300, deletions: 0, status: "added" as const }, +] + +describe("summary diffs — wire-only projection (#1778, #1814 P3)", () => { + it.instance( + "the live frame drops patch bodies; the stored message and the sync copy keep them", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const frames: Frame[] = [] + const subscription = GlobalEventReplay.subscribe(undefined, { sync: true }) + subscription.start((frame) => frames.push(frame)) + yield* Effect.addFinalizer(() => Effect.sync(() => subscription.close())) + + const session = yield* Effect.acquireRelease(Session.use.create({ title: "wire-projection" }), (created) => + Session.use.remove(created.id).pipe(Effect.ignore), + ) + const messageID = MessageID.ascending() + yield* Session.use.updateMessage({ + id: messageID, + sessionID: session.id, + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("model") }, + summary: { title: "Edit", diffs }, + } satisfies SessionV1.User) + + const ours = () => + frames + .map((frame) => ({ frame, event: JSON.parse(frame.data) })) + .filter(({ event }) => + event.payload.type === "sync" + ? event.payload.syncEvent.data?.info?.id === messageID + : event.payload.type === "message.updated" && event.payload.properties.info.id === messageID, + ) + // the bridge emits synchronously inside publish: both frames exist now + const [live, copy, ...extra] = ours() + expect(extra).toEqual([]) + + expect(live.event.payload.type).toBe("message.updated") + expect(live.frame.syncCopy).toBe(false) + expect(live.frame.data).not.toContain('"patch"') + expect(live.event.payload.properties.info.summary).toEqual({ + title: "Edit", + diffs: diffs.map(({ patch: _patch, ...rest }) => rest), + }) + + expect(copy.event.payload.type).toBe("sync") + expect(copy.frame.syncCopy).toBe(true) + expect(copy.frame.seq).toBe(live.frame.seq) + expect(copy.event.payload.syncEvent.data.info.summary.diffs).toEqual(diffs) + + // the stored record is unchanged — and it is the on-demand patch source + const response = yield* requestInDirectory( + pathFor(SessionPaths.message, { sessionID: session.id, messageID }), + test.directory, + ) + expect(response.status).toBe(200) + const stored = (yield* response.json) as { info: { summary?: { diffs: unknown } } } + expect(stored.info.summary?.diffs).toEqual(diffs) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) +}) diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/session-turn.tsx b/packages/app-bundle/overlay/packages/session-ui/src/components/session-turn.tsx index da8ed7d53..56028dd43 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/components/session-turn.tsx +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/session-turn.tsx @@ -26,6 +26,7 @@ import { TextReveal } from "@opencode-ai/ui/text-reveal" import { createAutoScroll } from "@opencode-ai/ui/hooks" import { useI18n } from "@opencode-ai/ui/context/i18n" import { normalize } from "./session-diff" +import { hasBody } from "./summary-patch" function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) @@ -470,9 +471,32 @@ export function SessionTurn( > {(diff) => { - const view = normalize(diff) const active = createMemo(() => expanded().includes(diff.file)) const [shown, setShown] = createSignal(false) + // #1778: a live summary carries no patch body — fetch the + // turn's stored diffs once this file is expanded and shown. + // A plain signal, not a resource: a pending fetch must not + // suspend the timeline around it. + const [fetched, setFetched] = createSignal() + createEffect( + on(shown, (value) => { + if (!value || hasBody(diff) || fetched() !== undefined) return + void data + .summaryPatch({ + sessionID: props.sessionID, + messageID: props.messageID, + file: diff.file, + summary: diffs(), + }) + .then((patch) => { + if (patch !== undefined) setFetched(patch) + }) + }), + ) + const view = createMemo(() => { + const patch = fetched() + return normalize(patch === undefined ? diff : { ...diff, patch }) + }) createEffect( on( @@ -519,7 +543,7 @@ export function SessionTurn(
- +
diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/summary-patch.test.ts b/packages/app-bundle/overlay/packages/session-ui/src/components/summary-patch.test.ts new file mode 100644 index 000000000..92ece6bc4 --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/summary-patch.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" +import { normalize } from "./session-diff" +import { createSummaryPatchLoader, hasBody, type SummaryDiffSource } from "./summary-patch" + +// #1778 — live per-turn summaries carry each file's status and counts but no +// patch body (the engine strips patches on the wire; storage keeps them). The +// per-turn diff view fetches a patch on demand when a file is expanded, from +// the stored message. The loader is the behavior; session-turn.tsx's wiring is +// asserted on its source below (bun's test transpiler does not run Solid's +// JSX transform — the repo's component-source-assertion convention). + +const PATCH = "@@ -1,2 +1,2 @@\n-old line\n+new line\n context\n" +const stored = [ + { file: "src/app.ts", patch: PATCH, additions: 1, deletions: 1, status: "modified" as const }, + { file: "src/other.ts", patch: "@@ -0,0 +1 @@\n+other\n", additions: 1, deletions: 0, status: "added" as const }, +] +// what a live message.updated carries after the wire projection +const live = stored.map(({ patch: _patch, ...rest }) => rest) +const turn = { sessionID: "ses_1", messageID: "msg_1" } + +function recordingSource(answer: () => Promise = async () => stored) { + const calls: { sessionID: string; messageID: string }[] = [] + const source: SummaryDiffSource = (input) => { + calls.push(input) + return answer() + } + return { calls, source } +} + +describe("per-turn diff view — patch bodies on demand (#1778, #1814 P3)", () => { + test("a live summary's file list and counts need no fetch; only a patch-less diff needs one", () => { + const { calls, source } = recordingSource() + createSummaryPatchLoader(source) + expect(live.map((diff) => [diff.file, diff.additions, diff.deletions, diff.status])).toEqual([ + ["src/app.ts", 1, 1, "modified"], + ["src/other.ts", 1, 0, "added"], + ]) + expect(live.map(hasBody)).toEqual([false, false]) + expect(stored.map(hasBody)).toEqual([true, true]) + expect(hasBody({ file: "a", before: "x", after: "y" })).toBe(true) + expect(calls).toEqual([]) + }) + + test("expanding a file fetches the turn's stored diffs once and renders that file's patch", async () => { + const { calls, source } = recordingSource() + const loader = createSummaryPatchLoader(source) + + const patch = await loader.patch({ ...turn, file: "src/app.ts", summary: live }) + expect(patch).toBe(PATCH) + const view = normalize({ ...live[0], patch }) + expect(view.fileDiff.additionLines.join("")).toContain("new line") + expect(view.fileDiff.deletionLines.join("")).toContain("old line") + expect({ additions: view.additions, deletions: view.deletions, status: view.status }).toEqual({ + additions: 1, + deletions: 1, + status: "modified", + }) + + // a second file of the same turn — and concurrent expands — share the one fetch + const [other, again] = await Promise.all([ + loader.patch({ ...turn, file: "src/other.ts", summary: live }), + loader.patch({ ...turn, file: "src/app.ts", summary: live }), + ]) + expect(other).toBe(stored[1].patch) + expect(again).toBe(PATCH) + expect(calls).toEqual([turn]) + }) + + test("a failed fetch resolves to no patch and is retried on the next expand", async () => { + let fail = true + const { calls, source } = recordingSource(async () => { + if (fail) throw new Error("offline") + return stored + }) + const loader = createSummaryPatchLoader(source) + expect(await loader.patch({ ...turn, file: "src/app.ts", summary: live })).toBeUndefined() + fail = false + expect(await loader.patch({ ...turn, file: "src/app.ts", summary: live })).toBe(PATCH) + expect(calls).toHaveLength(2) + }) + + test("a turn whose summary changed (a later message.updated) is fetched afresh", async () => { + const { calls, source } = recordingSource() + const loader = createSummaryPatchLoader(source) + await loader.patch({ ...turn, file: "src/app.ts", summary: live }) + await loader.patch({ ...turn, file: "src/app.ts", summary: [{ ...live[0], additions: 7 }, live[1]] }) + expect(calls).toHaveLength(2) + }) + + test("without a source (a provider that cannot fetch) a patch-less diff resolves to no patch", async () => { + expect(await createSummaryPatchLoader(undefined).patch({ ...turn, file: "src/app.ts", summary: live })).toBeUndefined() + }) +}) + +describe("per-turn diff view — wiring (#1778, #1814 P3)", () => { + const turnSource = readFileSync(resolve(__dirname, "session-turn.tsx"), "utf8") + const dataSource = readFileSync(resolve(__dirname, "../context/data.tsx"), "utf8") + + test("the data provider takes an on-demand summary-diff source and shares one loader", () => { + expect(dataSource).toContain("onLoadSummaryDiffs?: SummaryDiffSource") + expect(dataSource).toContain("createSummaryPatchLoader(props.onLoadSummaryDiffs)") + expect(dataSource).toContain("summaryPatch:") + }) + + test("the accordion fetches a patch only once a patch-less file is expanded, and renders the fetched patch", () => { + // the fetch fires from the expanded+shown edge, only for a diff without a body + expect(turnSource).toContain("on(shown, (value) => {") + expect(turnSource).toContain("if (!value || hasBody(diff) || fetched() !== undefined) return") + expect(turnSource).toContain(".summaryPatch({") + expect(turnSource).toContain("normalize(patch === undefined ? diff : { ...diff, patch })") + expect(turnSource).toContain("fileDiff={view().fileDiff}") + // the per-file view is no longer parsed eagerly for collapsed files + expect(turnSource).not.toContain("const view = normalize(diff)") + }) + + test("the file list and counts render from the summary itself, with no fetch", () => { + expect(turnSource).toContain("") + expect(turnSource).toContain("") + const memo = turnSource.slice(turnSource.indexOf("const diffs = createMemo"), turnSource.indexOf("const MAX_FILES")) + expect(memo).not.toContain("summaryPatch") + }) +}) diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/summary-patch.ts b/packages/app-bundle/overlay/packages/session-ui/src/components/summary-patch.ts new file mode 100644 index 000000000..ca250f917 --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/summary-patch.ts @@ -0,0 +1,77 @@ +// #1778 — per-turn summary patches on demand. The engine sends a live summary's +// diffs with file, status and counts but no patch body (patches stay in +// storage); the per-turn diff view fetches the turn's stored diffs only when a +// patch-less file is expanded. Plain TypeScript (no reactivity) so the +// behavior is testable directly; session-turn.tsx drives it from its expand +// state. + +/** A diff as a summary carries it — the patch body is optional on the wire. */ +export type SummaryDiffLike = { + file?: string + patch?: string + before?: string + after?: string + additions?: number + deletions?: number + status?: string +} + +/** Fetch a turn's stored summary diffs (with patch bodies), e.g. from `GET /session/:id/message/:messageID`. */ +export type SummaryDiffSource = (input: { + sessionID: string + messageID: string +}) => Promise + +export type SummaryPatchRequest = { + sessionID: string + messageID: string + /** The expanded file. */ + file: string + /** The turn's summary diffs as currently shown — a changed summary is fetched afresh. */ + summary: readonly SummaryDiffLike[] +} + +/** Whether a diff can render without a fetch: it carries a patch, or before/after contents. */ +export function hasBody(diff: SummaryDiffLike) { + return typeof diff.patch === "string" || typeof diff.before === "string" || typeof diff.after === "string" +} + +const signature = (summary: readonly SummaryDiffLike[]) => + JSON.stringify(summary.map((diff) => [diff.file, diff.status, diff.additions, diff.deletions])) + +/** + * One loader per data provider. A turn's stored diffs are fetched at most once + * per summary (every file of the turn, and concurrent expands, share it); a + * failed fetch is forgotten so the next expand retries it. + */ +export function createSummaryPatchLoader(source: SummaryDiffSource | undefined) { + const turns = new Map>() + const limit = 32 + + const fetchTurn = (input: SummaryPatchRequest) => { + const key = `${input.sessionID}\0${input.messageID}\0${signature(input.summary)}` + const hit = turns.get(key) + if (hit) return hit + const pending = Promise.resolve() + .then(() => source?.({ sessionID: input.sessionID, messageID: input.messageID })) + .catch(() => { + turns.delete(key) + return undefined + }) + turns.set(key, pending) + while (turns.size > limit) turns.delete(turns.keys().next().value!) + return pending + } + + return { + /** The expanded file's patch body, or `undefined` when it cannot be had. */ + async patch(input: SummaryPatchRequest): Promise { + if (!source) return undefined + const diffs = await fetchTurn(input) + const match = diffs?.findLast((diff) => diff.file === input.file) + return typeof match?.patch === "string" ? match.patch : undefined + }, + } +} + +export type SummaryPatchLoader = ReturnType diff --git a/packages/app-bundle/overlay/packages/session-ui/src/context/data.tsx b/packages/app-bundle/overlay/packages/session-ui/src/context/data.tsx new file mode 100644 index 000000000..4541bd681 --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/context/data.tsx @@ -0,0 +1,82 @@ +import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" +import { createSummaryPatchLoader, type SummaryDiffSource } from "../components/summary-patch" + +export type NormalizedProviderListResponse = { + all: Map + defaultModel?: { + providerID: string + modelID: string + } | null + default: { + [key: string]: string + } + connected: Array +} + +type Data = { + agent?: { + name: string + color?: string + }[] + provider?: NormalizedProviderListResponse + session: Session[] + session_status: { + [sessionID: string]: SessionStatus + } + session_diff: { + [sessionID: string]: (SnapshotFileDiff | FileDiffInfo)[] + } + session_diff_preload?: { + [sessionID: string]: PreloadMultiFileDiffResult[] + } + message: { + [sessionID: string]: Message[] + } + part: { + [messageID: string]: Part[] + } + part_text_accum_delta?: { + [partID: string]: string + } +} + +export type NavigateToSessionFn = (sessionID: string) => void + +export type SessionHrefFn = (sessionID: string) => string + +export const { use: useData, provider: DataProvider } = createSimpleContext({ + name: "Data", + init: (props: { + data: Data + directory: string + sessionID?: string + onNavigateToSession?: NavigateToSessionFn + onSessionHref?: SessionHrefFn + /** + * #1778 (amicode overlay): fetch a turn's stored summary diffs, patch + * bodies included. Live summaries carry no patch bodies (the engine + * strips them on the wire), so the per-turn diff view asks for a patch + * when a file is expanded. Without it a patch-less diff renders empty. + */ + onLoadSummaryDiffs?: SummaryDiffSource + }) => { + const loader = createSummaryPatchLoader(props.onLoadSummaryDiffs) + return { + get store() { + return props.data + }, + get directory() { + return props.directory + }, + get sessionID() { + return props.sessionID + }, + navigateToSession: props.onNavigateToSession, + sessionHref: props.onSessionHref, + summaryPatch: loader.patch, + } + }, +})