@openchambery/web 1.18.2-beta.6 → 1.18.2-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/assets/{AgentAvatar-B2kZjgaf.js → AgentAvatar-BzRb9sP6.js} +1 -1
  2. package/dist/assets/{AssistantView-COiivQyD.js → AssistantView-C0v6WVEB.js} +1 -1
  3. package/dist/assets/{ChatContainer-CNQBw5D7.js → ChatContainer-CReoQTua.js} +12 -12
  4. package/dist/assets/{DiagramView-DD5haMGO.js → DiagramView-D_WMXwYz.js} +1 -1
  5. package/dist/assets/{FadeInOnReveal-aeWlaqVB.js → FadeInOnReveal-oeyB_nIi.js} +1 -1
  6. package/dist/assets/{Icon-AXqa-fYB.js → Icon-DHaR0peD.js} +1 -1
  7. package/dist/assets/{MarkdownRendererImpl-CY3U2y5M.js → MarkdownRendererImpl-DhYt2Oq2.js} +1 -1
  8. package/dist/assets/{MobileDetailNavigation-B8rygQQ7.js → MobileDetailNavigation-CgiLWb4L.js} +1 -1
  9. package/dist/assets/{MobileShareBridge-D89lMfZL.js → MobileShareBridge-CED5MFr5.js} +1 -1
  10. package/dist/assets/{MobileSurface-3Tv0F4Gr.js → MobileSurface-CH2_uvbK.js} +1 -1
  11. package/dist/assets/{MultiRunWindow-B9WbE8t-.js → MultiRunWindow-BUsyw0Ws.js} +1 -1
  12. package/dist/assets/{OnboardingScreen-DiPnIwza.js → OnboardingScreen-BZHN7C2r.js} +1 -1
  13. package/dist/assets/{OverlayScrollbar-B9JI2aL-.js → OverlayScrollbar-DP76aSig.js} +1 -1
  14. package/dist/assets/{SettingsView-CZIFnaZD.js → SettingsView-BkC2EjbS.js} +1 -1
  15. package/dist/assets/{SettingsWindow-5vXhJPxQ.js → SettingsWindow-CAW6MEmX.js} +1 -1
  16. package/dist/assets/{TerminalView-BxbGHB90.js → TerminalView-BlHms0VM.js} +1 -1
  17. package/dist/assets/{ToolOutputDialog-CeNm90zz.js → ToolOutputDialog-C6tIj91N.js} +1 -1
  18. package/dist/assets/{appThemeRegistry-CaSmAsn0.js → appThemeRegistry-DVSq4EWU.js} +2 -2
  19. package/dist/assets/{button-CDvMVaiO.js → button-DCUMIXDC.js} +1 -1
  20. package/dist/assets/{clipboard-CbqhDr1g.js → clipboard-DF5tFLDb.js} +1 -1
  21. package/dist/assets/{collapsible-C2aamDvD.js → collapsible-DNg5bLKV.js} +1 -1
  22. package/dist/assets/{context-menu-CWpj9MM3.js → context-menu-DpH6U8oW.js} +1 -1
  23. package/dist/assets/{desktopHosts-CBoBpJ1Z.js → desktopHosts-DSgGxDpI.js} +1 -1
  24. package/dist/assets/{desktopRelayRestore-l2R9muux.js → desktopRelayRestore-DVIx7jsz.js} +1 -1
  25. package/dist/assets/{diagram-CEon0BQs.js → diagram-cjvFqVa8.js} +1 -1
  26. package/dist/assets/{dialog--cf4dygO.js → dialog-DaSIYgrj.js} +1 -1
  27. package/dist/assets/{fileQueries-CE_DaZWQ.js → fileQueries-DXvhIL_U.js} +1 -1
  28. package/dist/assets/{gitApi-DOg904nv.js → gitApi-Bhdx5iUS.js} +1 -1
  29. package/dist/assets/gitApiHttp-SLrnr8a5.js +2 -0
  30. package/dist/assets/{input-k9DGGyZ6.js → input-QS3IWgY5.js} +1 -1
  31. package/dist/assets/{insertionBoundaries-CVlbmHxP.js → insertionBoundaries-nU86u9Wm.js} +1 -1
  32. package/dist/assets/{main-BPqbCeMO.js → main-B1xvOgYh.js} +4 -4
  33. package/dist/assets/{main-Co94kWxG.js → main-CE-WJrYW.js} +2 -2
  34. package/dist/assets/{markdown-worker-DkeWiER6.js → markdown-worker-D-OR2e_G.js} +1 -1
  35. package/dist/assets/{markdownTheme-DNT93i5L.js → markdownTheme-y_Rf6mLa.js} +1 -1
  36. package/dist/assets/{miniChat-CXKdmXX8.js → miniChat-BFIjJLav.js} +2 -2
  37. package/dist/assets/mobile-CAhmWqvi.js +2 -0
  38. package/dist/assets/{multirun-CImSf28y.js → multirun-CgvZwd2c.js} +1 -1
  39. package/dist/assets/{persistence-DPNMIbF5.js → persistence-oSFWyKgV.js} +1 -1
  40. package/dist/assets/{projectMeta-CjjJoGW4.js → projectMeta-1-jW5f2j.js} +1 -1
  41. package/dist/assets/{radio-D7GqsVKG.js → radio-B8jlOicg.js} +1 -1
  42. package/dist/assets/{renderElectronMiniChatApp-TowM-DTY.js → renderElectronMiniChatApp-B99BmjcT.js} +2 -2
  43. package/dist/assets/{renderMobileApp-BTK6u8V7.js → renderMobileApp-Dk7PNioy.js} +4 -4
  44. package/dist/assets/{runtimeConfig-BtWi74Pm.js → runtimeConfig-n4QAk1lU.js} +1 -1
  45. package/dist/assets/runtimeEndpointReset-Ca3v4eoA.js +145 -0
  46. package/dist/assets/{sortable-tabs-strip-9_5pMDEx.js → sortable-tabs-strip-D2vXQoD8.js} +1 -1
  47. package/dist/assets/{src-D5csxl6e.css → src-BhvNwAr-.css} +1 -1
  48. package/dist/assets/{terminalApi-tB3qLcfa.js → terminalApi-_NnfkxgP.js} +1 -1
  49. package/dist/assets/{tooltip-DMNR5rN0.js → tooltip-xEnco9xY.js} +1 -1
  50. package/dist/assets/{url-7MqoLs9X.js → url-DIZgk80M.js} +1 -1
  51. package/dist/assets/{useAppFontEffects-BbPkX7Aa.js → useAppFontEffects-CFXV7nrE.js} +2 -2
  52. package/dist/assets/{useDesktopWindowControlsLayout-Ddw59AGl.js → useDesktopWindowControlsLayout-BN0ppWAs.js} +1 -1
  53. package/dist/assets/{useEffectiveDirectory-tjzwKLjA.js → useEffectiveDirectory-DGlRozSn.js} +1 -1
  54. package/dist/assets/{useFontPreferences-CIurCeNv.js → useFontPreferences-B-QHyR3C.js} +1 -1
  55. package/dist/assets/{useMobileNavigationStore-BWxbxrxb.js → useMobileNavigationStore-BUsW7lSl.js} +19 -18
  56. package/dist/assets/{useSessionAutoCleanup-Bclq-U-4.js → useSessionAutoCleanup-BRoQwvvb.js} +1 -1
  57. package/dist/assets/{useWorkerHighlightedLines-Dv7-ZKa9.js → useWorkerHighlightedLines-Ds6gLNsj.js} +1 -1
  58. package/dist/assets/{utils-Co19EJEL.js → utils-Dxsi3J3l.js} +1 -1
  59. package/dist/index.html +8 -8
  60. package/dist/mini-chat.html +8 -8
  61. package/dist/mobile.html +8 -8
  62. package/package.json +1 -1
  63. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  64. package/server/lib/event-stream/diff-summary.js +35 -16
  65. package/server/lib/event-stream/diff-summary.test.js +46 -6
  66. package/server/lib/event-stream/protocol.test.js +7 -7
  67. package/server/lib/opencode/DOCUMENTATION.md +1 -1
  68. package/server/lib/opencode/feature-routes-runtime.test.js +10 -1
  69. package/server/lib/session-turn-pages/DOCUMENTATION.md +26 -6
  70. package/server/lib/session-turn-pages/changes.service.js +156 -0
  71. package/server/lib/session-turn-pages/changes.service.test.js +188 -0
  72. package/server/lib/session-turn-pages/reconcile.routes.test.js +48 -0
  73. package/server/lib/session-turn-pages/reconcile.service.js +3 -1
  74. package/server/lib/session-turn-pages/reconcile.service.test.js +40 -0
  75. package/server/lib/session-turn-pages/routes.js +273 -12
  76. package/server/lib/session-turn-pages/routes.test.js +359 -0
  77. package/server/lib/session-turn-pages/service.js +29 -7
  78. package/server/lib/session-turn-pages/service.test.js +30 -0
  79. package/dist/assets/gitApiHttp-D8j1qRH4.js +0 -2
  80. package/dist/assets/mobile-CTRPt2_t.js +0 -2
  81. package/dist/assets/runtimeEndpointReset-B0Ql9ZN6.js +0 -145
@@ -11,6 +11,8 @@ Exposed as:
11
11
 
12
12
  - `GET /api/openchamber/sessions/:sessionID/messages`
13
13
  - `GET /api/openchamber/sessions/:sessionID/messages/reconcile`
14
+ - `GET /api/openchamber/sessions/:sessionID/changes` (L2 file list / L3 single-file patch)
15
+ - `GET /api/session/:sessionID/message/:messageID` (exact message with L1 summary projection; registered before the generic OpenCode proxy)
14
16
 
15
17
  ## Why this exists
16
18
 
@@ -69,9 +71,22 @@ Success `200`:
69
71
  - `complete`: `upstreamComplete && selected.length === accumulated.length`. True when upstream history is exhausted **and** `selectTurnRecords` did not trim any older scanned rows (nothing left for the client). When the scan window held more than N turns and older rows were trimmed, `complete` stays `false` with a `cursor` so the client can fetch the trimmed history.
70
72
  - `partsProjection`: `"slim-v1"` on every turn-page response (first packet and prepend). Names the projection applied to `records`; see below. Reconcile responses omit this field and keep full parts.
71
73
 
72
- ### Turn-page parts projection
74
+ ### Turn-page payload projection
75
+
76
+ Every turn-page response summarizes `tool`, `reasoning`, and `file` parts plus message `summary.diffs` so it does not have to carry the bodies of a long tool-heavy turn, inline attachment data URLs, or full file patches. Projection runs **after** `selectTurnRecords`, so `turnCount`, `complete`, and cursor encoding are all derived from unprojected records and cannot shift.
77
+
78
+ ### Changes Host contract (L1 / L2 / L3)
79
+
80
+ | Layer | Surface | Payload |
81
+ |---|---|---|
82
+ | **L1** | Turn pages, reconcile response pages, message/session SSE fan-out, exact `GET /api/session/:sessionID/message/:messageID` | Any `info.summary.diffs` **array** is projected to `summary.diffCount: number` + `summary.hasDiffs: boolean` and **`summary.diffs` is removed**. Other lightweight summary fields are preserved. `diffCount` is the original array length; `hasDiffs === diffCount > 0`. L1 never retains a file array. |
83
+ | **L2** | `GET /api/openchamber/sessions/:sessionID/changes?messageID=` (no `file`) | Host reads official `session.message` locally, returns only `{ files: [{ file, status?, additions, deletions }] }`. Never returns patch/before/after/from/to or the full message envelope. |
84
+ | **L3** | Same route with `file=` | Host calls official `session.diff({ sessionID, messageID, directory })`, returns `{ diff: SnapshotFileDiff }` for the exact `file` string match (includes that file's patch). No match → HTTP 404 `{ error: 'change file not found', code: 'change_not_found' }`. |
85
+
86
+ Exact message GET keeps the official `{ info, parts }` response shape and only applies L1 summary projection so materialize/recovery never relays a raw multi-megabyte `summary.diffs` array through the generic proxy. Abort/45s timeout semantics match the turn-page routes. Global `/api` auth still applies.
87
+
88
+ Changes query guards: `sessionID` / `messageID` length `1..512`; optional `file` length `1..4096` and rejects control characters / NUL. Failures: invalid params → 400, abort → 499, upstream/malformed → 502. Logs carry only safe flags and ids — never paths, patch bodies, or tokens.
73
89
 
74
- Every turn-page response summarizes `tool`, `reasoning`, and `file` parts so it does not have to carry the bodies of a long tool-heavy turn or inline attachment data URLs. Projection runs **after** `selectTurnRecords`, so `turnCount`, `complete`, and cursor encoding are all derived from unprojected records and cannot shift.
75
90
 
76
91
  | Kept | Dropped |
77
92
  |---|---|
@@ -85,6 +100,7 @@ Rules:
85
100
  - Slim tool input keeps only short locator and task-identity fields (path, pattern, query, command, `subagent_type`, `description`, skill `name` / `id`). Slim metadata keeps `sessionId`, skill `name`, plus edit `additions`/`deletions`. Projection still drops result bodies, task prompts, patches, and large write payloads.
86
101
  - Assistant `text` stays intact. User rows are no longer wholesale pass-through: their `file` parts are projected; other user parts stay as-is.
87
102
  - `file` projection keeps already-present size/width/height (top-level or `metadata`). `byteSize` is derived from a data URL when present. PNG/GIF/JPEG/WebP headers may fill missing `width`/`height`. Unknown dimensions omit those fields.
103
+ - Message `info.summary.diffs` is L1-projected to `diffCount` / `hasDiffs` (file array removed). Reconcile preserves full parts and applies this same L1 summary projection to response-page byte budgeting and serialization; raw upstream scan budgets retain raw bytes. Clients that need file rows or patches use the Changes L2/L3 API.
88
104
  - A slim `file` part is a reference, not renderable content. Clients must hydrate the full message (`session.message` by `messageID`) before painting or editing the attachment. Do not treat slim file metadata as a complete part.
89
105
  - Records that gain nothing from projection keep their original object reference, so identity-based change detection downstream still works.
90
106
  - Projection applies to every turn-page response, including first packet, prepend, and explicit cursors (`before` present). Reconcile routes return full parts, unchanged.
@@ -242,10 +258,11 @@ When the per-page budget fills before the gap is complete, the host returns `con
242
258
 
243
259
  ## Files
244
260
 
245
- - `service.js` — `isUserAuthoredTurnBoundary`, `selectTurnRecords`, `encodeHostCursor`, `decodeHostCursor`, `createSessionTurnPageService({ fetchPage, maxScanPages?, maxScanMessages? })`
261
+ - `service.js` — `isUserAuthoredTurnBoundary`, `selectTurnRecords`, `encodeHostCursor`, `decodeHostCursor`, `projectMessageDiffSummaries` (L1), `projectSlimParts`, `createSessionTurnPageService({ fetchPage, maxScanPages?, maxScanMessages? })`
246
262
  - `reconcile.service.js` — `encodeReconcileContinuation`, `decodeReconcileContinuation`, `createSessionReconcileService({ fetchPage, runtimeKey?, page/total budgets?, continuationSecret?, clock?, continuationTtlMs? })`
247
- - `routes.js` — Express registration for both turn-page and reconcile; optional injected `sessionTurnPageService` / `sessionReconcileService` for tests; default wires SDK `session.messages` via `buildOpenCodeUrl` + `getOpenCodeAuthHeaders`
248
- - `service.test.js` / `routes.test.js` / `reconcile.service.test.js` / `reconcile.routes.test.js` unit contracts
263
+ - `changes.service.js` — `createSessionChangesService({ fetchMessage, fetchDiff })` for L2/L3; injectable for unit tests
264
+ - `routes.js` — Express registration for turn-page, reconcile, Changes, and exact message GET; optional injected `sessionTurnPageService` / `sessionReconcileService` / `sessionChangesService` / `fetchExactMessage` for tests; default wires SDK `session.messages` / `session.message` / `session.diff` via `buildOpenCodeUrl` + `getOpenCodeAuthHeaders`
265
+ - `service.test.js` / `routes.test.js` / `reconcile.service.test.js` / `reconcile.routes.test.js` / `changes.service.test.js` — unit contracts
249
266
 
250
267
  ## Registration
251
268
 
@@ -257,14 +274,17 @@ registerSessionTurnPageRoutes(app, {
257
274
  });
258
275
  ```
259
276
 
260
- Reconcile is registered on the more-specific path **before** the generic messages path inside `registerSessionTurnPageRoutes`.
277
+ Inside `registerSessionTurnPageRoutes`, registration order is: reconcile turn-page messages → exact `GET /api/session/:sessionID/message/:messageID` → Changes. All of these register before the generic OpenCode proxy in `feature-routes-runtime.js`. Electron reuses this same Host composition in-process.
261
278
 
262
279
  ## Error HTTP map
263
280
 
264
281
  | Condition | Status |
265
282
  |---|---|
266
283
  | Invalid turns / scanLimit / sessionID / cursor | 400 |
284
+ | Invalid messageID / file (Changes / exact message) | 400 |
267
285
  | Invalid anchor / continuation / reconcile params | 400 |
286
+ | Change file not found (`change_not_found`) | 404 |
287
+ | Exact message not found | 404 |
268
288
  | max_scan_pages / max_scan_messages | 413 |
269
289
  | Client abort | 499 |
270
290
  | Server exception (reconcile) | 500 (stack logged; safe client body) |
@@ -0,0 +1,156 @@
1
+ /**
2
+ * OpenChamber-owned session Changes API (L2 file list / L3 single-file patch).
3
+ *
4
+ * L2 (no `file`): read official `session.message`, project summary.diffs to
5
+ * `{ files: [{ file, status?, additions, deletions }] }` — never patch bodies
6
+ * or the full message envelope.
7
+ * L3 (with `file`): call official `session.diff({ sessionID, messageID })`,
8
+ * return `{ diff: SnapshotFileDiff }` for the exact file string match.
9
+ *
10
+ * Dependency-injected for unit tests (`fetchMessage`, `fetchDiff`).
11
+ */
12
+
13
+ /**
14
+ * Project a raw summary.diffs entry to L2 file-list scalars.
15
+ * @param {unknown} entry
16
+ * @returns {{ file: string, status?: string, additions: number, deletions: number } | null}
17
+ */
18
+ export const projectChangeFileEntry = (entry) => {
19
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
20
+ return null;
21
+ }
22
+ const file = typeof entry.file === 'string' ? entry.file : null;
23
+ if (!file || file.length === 0) {
24
+ return null;
25
+ }
26
+
27
+ /** Built from allowlist only — patch/before/after/from/to never copied. */
28
+ const out = { file };
29
+ if (typeof entry.status === 'string') {
30
+ out.status = entry.status;
31
+ }
32
+ out.additions = typeof entry.additions === 'number' && Number.isFinite(entry.additions)
33
+ ? entry.additions
34
+ : 0;
35
+ out.deletions = typeof entry.deletions === 'number' && Number.isFinite(entry.deletions)
36
+ ? entry.deletions
37
+ : 0;
38
+ return out;
39
+ };
40
+
41
+ /**
42
+ * Extract L2 file list from a session.message response shape.
43
+ * Accepts `{ info, parts }`, `{ data: { info, parts } }`, or bare info.
44
+ * @param {unknown} messagePayload
45
+ * @returns {{ files: Array<{ file: string, status?: string, additions: number, deletions: number }> }}
46
+ */
47
+ export const projectChangeFileList = (messagePayload) => {
48
+ const record = messagePayload && typeof messagePayload === 'object' && !Array.isArray(messagePayload)
49
+ ? messagePayload
50
+ : null;
51
+ const info = record?.info && typeof record.info === 'object' && !Array.isArray(record.info)
52
+ ? record.info
53
+ : (record?.summary ? record : null);
54
+ const summary = info?.summary && typeof info.summary === 'object' && !Array.isArray(info.summary)
55
+ ? info.summary
56
+ : null;
57
+ const diffs = Array.isArray(summary?.diffs) ? summary.diffs : [];
58
+
59
+ const files = [];
60
+ for (const entry of diffs) {
61
+ const projected = projectChangeFileEntry(entry);
62
+ if (projected) files.push(projected);
63
+ }
64
+ return { files };
65
+ };
66
+
67
+ /**
68
+ * Find exact file match in a session.diff array.
69
+ * @param {unknown} diffs
70
+ * @param {string} file
71
+ * @returns {unknown | null}
72
+ */
73
+ export const findChangeFileDiff = (diffs, file) => {
74
+ if (!Array.isArray(diffs) || typeof file !== 'string') {
75
+ return null;
76
+ }
77
+ for (const entry of diffs) {
78
+ if (entry && typeof entry === 'object' && !Array.isArray(entry) && entry.file === file) {
79
+ return entry;
80
+ }
81
+ }
82
+ return null;
83
+ };
84
+
85
+ /**
86
+ * @param {{
87
+ * fetchMessage: (args: { sessionID: string, messageID: string, directory?: string, signal?: AbortSignal }) => Promise<unknown>,
88
+ * fetchDiff: (args: { sessionID: string, messageID: string, directory?: string, signal?: AbortSignal }) => Promise<unknown>,
89
+ * logger?: { warn?: Function },
90
+ * }} deps
91
+ */
92
+ export const createSessionChangesService = (deps) => {
93
+ const {
94
+ fetchMessage,
95
+ fetchDiff,
96
+ logger = console,
97
+ } = deps;
98
+
99
+ if (typeof fetchMessage !== 'function' || typeof fetchDiff !== 'function') {
100
+ throw new Error('createSessionChangesService requires fetchMessage and fetchDiff');
101
+ }
102
+
103
+ /**
104
+ * @param {{
105
+ * sessionID: string,
106
+ * messageID: string,
107
+ * directory?: string,
108
+ * file?: string,
109
+ * signal?: AbortSignal,
110
+ * }} input
111
+ */
112
+ const loadChanges = async (input) => {
113
+ const sessionID = typeof input?.sessionID === 'string' ? input.sessionID : '';
114
+ const messageID = typeof input?.messageID === 'string' ? input.messageID : '';
115
+ const directory = typeof input?.directory === 'string' && input.directory.length > 0
116
+ ? input.directory
117
+ : undefined;
118
+ const file = typeof input?.file === 'string' && input.file.length > 0
119
+ ? input.file
120
+ : undefined;
121
+ const signal = input?.signal;
122
+
123
+ if (!sessionID || !messageID) {
124
+ return { ok: false, error: 'invalid_params' };
125
+ }
126
+
127
+ try {
128
+ if (!file) {
129
+ const payload = await fetchMessage({ sessionID, messageID, directory, signal });
130
+ return { ok: true, body: projectChangeFileList(payload) };
131
+ }
132
+
133
+ const diffs = await fetchDiff({ sessionID, messageID, directory, signal });
134
+ if (!Array.isArray(diffs)) {
135
+ logger?.warn?.('[session-changes] session.diff malformed payload');
136
+ return { ok: false, error: 'upstream' };
137
+ }
138
+ const match = findChangeFileDiff(diffs, file);
139
+ if (!match) {
140
+ return { ok: false, error: 'change_not_found' };
141
+ }
142
+ return { ok: true, body: { diff: match } };
143
+ } catch (error) {
144
+ if (error?.name === 'AbortError' || error?.code === 'aborted') {
145
+ return { ok: false, error: 'aborted' };
146
+ }
147
+ if (typeof error?.code === 'string' && error.code.length > 0) {
148
+ return { ok: false, error: error.code };
149
+ }
150
+ logger?.warn?.('[session-changes] upstream failure');
151
+ return { ok: false, error: 'upstream' };
152
+ }
153
+ };
154
+
155
+ return { loadChanges };
156
+ };
@@ -0,0 +1,188 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import {
4
+ createSessionChangesService,
5
+ findChangeFileDiff,
6
+ projectChangeFileEntry,
7
+ projectChangeFileList,
8
+ } from './changes.service.js';
9
+
10
+ describe('projectChangeFileEntry', () => {
11
+ it('keeps file/status/additions/deletions and drops patch bodies', () => {
12
+ expect(projectChangeFileEntry({
13
+ file: 'src/a.ts',
14
+ status: 'modified',
15
+ additions: 3,
16
+ deletions: 1,
17
+ patch: '@@',
18
+ before: 'old',
19
+ after: 'new',
20
+ from: 'f',
21
+ to: 't',
22
+ })).toEqual({
23
+ file: 'src/a.ts',
24
+ status: 'modified',
25
+ additions: 3,
26
+ deletions: 1,
27
+ });
28
+ });
29
+
30
+ it('returns null when file is missing', () => {
31
+ expect(projectChangeFileEntry({ additions: 1 })).toBeNull();
32
+ });
33
+ });
34
+
35
+ describe('projectChangeFileList', () => {
36
+ it('projects summary.diffs from a message envelope and never returns the message', () => {
37
+ const list = projectChangeFileList({
38
+ info: {
39
+ id: 'msg_1',
40
+ summary: {
41
+ diffs: [
42
+ { file: 'a.ts', status: 'added', additions: 2, deletions: 0, patch: 'big' },
43
+ { file: 'b.ts', additions: 1, deletions: 1, before: 'x' },
44
+ { additions: 9 },
45
+ ],
46
+ },
47
+ },
48
+ parts: [{ type: 'text', text: 'secret' }],
49
+ });
50
+
51
+ expect(list).toEqual({
52
+ files: [
53
+ { file: 'a.ts', status: 'added', additions: 2, deletions: 0 },
54
+ { file: 'b.ts', additions: 1, deletions: 1 },
55
+ ],
56
+ });
57
+ expect(list.info).toBeUndefined();
58
+ expect(list.parts).toBeUndefined();
59
+ expect(JSON.stringify(list)).not.toContain('patch');
60
+ expect(JSON.stringify(list)).not.toContain('secret');
61
+ });
62
+
63
+ it('returns empty files when summary.diffs is absent', () => {
64
+ expect(projectChangeFileList({ info: { id: 'msg_1' }, parts: [] })).toEqual({ files: [] });
65
+ });
66
+ });
67
+
68
+ describe('findChangeFileDiff', () => {
69
+ it('matches exact file string only', () => {
70
+ const diffs = [
71
+ { file: 'src/a.ts', patch: 'a' },
72
+ { file: 'src/b.ts', patch: 'b' },
73
+ ];
74
+ expect(findChangeFileDiff(diffs, 'src/b.ts')).toEqual({ file: 'src/b.ts', patch: 'b' });
75
+ expect(findChangeFileDiff(diffs, 'src/missing.ts')).toBeNull();
76
+ expect(findChangeFileDiff(diffs, 'src/A.ts')).toBeNull();
77
+ });
78
+ });
79
+
80
+ describe('createSessionChangesService', () => {
81
+ it('L2 path calls fetchMessage and returns projected files', async () => {
82
+ const fetchMessage = vi.fn(async () => ({
83
+ info: {
84
+ summary: {
85
+ diffs: [{
86
+ file: 'hot.ts',
87
+ status: 'modified',
88
+ additions: 4,
89
+ deletions: 2,
90
+ patch: 'HUGE',
91
+ }],
92
+ },
93
+ },
94
+ parts: [],
95
+ }));
96
+ const fetchDiff = vi.fn();
97
+ const service = createSessionChangesService({ fetchMessage, fetchDiff });
98
+
99
+ const result = await service.loadChanges({
100
+ sessionID: 'ses_1',
101
+ messageID: 'msg_1',
102
+ directory: '/repo',
103
+ });
104
+
105
+ expect(result.ok).toBe(true);
106
+ expect(result.body).toEqual({
107
+ files: [{
108
+ file: 'hot.ts',
109
+ status: 'modified',
110
+ additions: 4,
111
+ deletions: 2,
112
+ }],
113
+ });
114
+ expect(fetchMessage).toHaveBeenCalledOnce();
115
+ expect(fetchDiff).not.toHaveBeenCalled();
116
+ });
117
+
118
+ it('L3 path calls fetchDiff and returns the matched SnapshotFileDiff', async () => {
119
+ const fetchMessage = vi.fn();
120
+ const fetchDiff = vi.fn(async () => ([
121
+ { file: 'a.ts', patch: 'a-patch', additions: 1, deletions: 0 },
122
+ { file: 'b.ts', patch: 'b-patch', additions: 2, deletions: 1, status: 'modified' },
123
+ ]));
124
+ const service = createSessionChangesService({ fetchMessage, fetchDiff });
125
+
126
+ const result = await service.loadChanges({
127
+ sessionID: 'ses_1',
128
+ messageID: 'msg_1',
129
+ file: 'b.ts',
130
+ });
131
+
132
+ expect(result.ok).toBe(true);
133
+ expect(result.body).toEqual({
134
+ diff: {
135
+ file: 'b.ts',
136
+ patch: 'b-patch',
137
+ additions: 2,
138
+ deletions: 1,
139
+ status: 'modified',
140
+ },
141
+ });
142
+ expect(fetchDiff).toHaveBeenCalledOnce();
143
+ expect(fetchMessage).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it('returns change_not_found when file is absent from session.diff', async () => {
147
+ const service = createSessionChangesService({
148
+ fetchMessage: vi.fn(),
149
+ fetchDiff: vi.fn(async () => [{ file: 'other.ts', patch: 'x' }]),
150
+ });
151
+ const result = await service.loadChanges({
152
+ sessionID: 'ses_1',
153
+ messageID: 'msg_1',
154
+ file: 'missing.ts',
155
+ });
156
+ expect(result).toEqual({ ok: false, error: 'change_not_found' });
157
+ });
158
+
159
+ it('maps abort errors to aborted', async () => {
160
+ const service = createSessionChangesService({
161
+ fetchMessage: vi.fn(async () => {
162
+ const error = new Error('aborted');
163
+ error.name = 'AbortError';
164
+ throw error;
165
+ }),
166
+ fetchDiff: vi.fn(),
167
+ });
168
+ const result = await service.loadChanges({
169
+ sessionID: 'ses_1',
170
+ messageID: 'msg_1',
171
+ });
172
+ expect(result).toEqual({ ok: false, error: 'aborted' });
173
+ });
174
+
175
+ it('maps malformed/upstream failures to upstream', async () => {
176
+ const service = createSessionChangesService({
177
+ fetchMessage: vi.fn(),
178
+ fetchDiff: vi.fn(async () => ({ not: 'array' })),
179
+ logger: { warn: vi.fn() },
180
+ });
181
+ const result = await service.loadChanges({
182
+ sessionID: 'ses_1',
183
+ messageID: 'msg_1',
184
+ file: 'a.ts',
185
+ });
186
+ expect(result).toEqual({ ok: false, error: 'upstream' });
187
+ });
188
+ });
@@ -209,6 +209,54 @@ describe('registerSessionTurnPageRoutes — reconcile', () => {
209
209
  });
210
210
  });
211
211
 
212
+ it('summarizes message diff snapshots while preserving full reconcile parts', async () => {
213
+ const patch = 'diff-body-'.repeat(20_000);
214
+ const reconcile = vi.fn(async () => successPage({
215
+ records: [{
216
+ info: {
217
+ id: 'msg_u2',
218
+ role: 'user',
219
+ summary: {
220
+ diffs: [{
221
+ file: 'src/large.ts',
222
+ status: 'modified',
223
+ additions: 5,
224
+ deletions: 1,
225
+ patch,
226
+ }],
227
+ },
228
+ },
229
+ parts: [{ id: 'prt_1', type: 'text', text: 'full recovery text' }],
230
+ }],
231
+ }));
232
+ const { app, route } = registry();
233
+ registerSessionTurnPageRoutes(app, {
234
+ sessionTurnPageService: { loadPage: vi.fn() },
235
+ sessionReconcileService: { reconcile },
236
+ });
237
+ const res = response();
238
+
239
+ await route('GET', RECONCILE_ROUTE)({
240
+ params: { sessionID: 'ses_42' },
241
+ query: { anchor: 'msg_u2' },
242
+ headers: {},
243
+ }, res);
244
+
245
+ expect(res.statusCode).toBe(200);
246
+ expect(res.body.records[0].info.summary).toEqual({
247
+ diffCount: 1,
248
+ hasDiffs: true,
249
+ });
250
+ expect(res.body.records[0].info.summary.diffs).toBeUndefined();
251
+ expect(res.body.records[0].parts[0]).toEqual({
252
+ id: 'prt_1',
253
+ type: 'text',
254
+ text: 'full recovery text',
255
+ });
256
+ expect(JSON.stringify(res.body)).not.toContain(patch);
257
+ expect(JSON.stringify(res.body)).not.toContain('src/large.ts');
258
+ });
259
+
212
260
  it('returns HTTP 200 for resetRequired (anchor lost / budget rebuild)', async () => {
213
261
  const reconcile = vi.fn(async () => successPage({
214
262
  records: [],
@@ -14,6 +14,8 @@
14
14
 
15
15
  import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
16
16
 
17
+ import { projectMessageDiffSummaries } from './service.js';
18
+
17
19
  /** Host-owned signed reconcile continuation version prefix. */
18
20
  const CONTINUATION_PREFIX = 'ocr2.';
19
21
 
@@ -666,7 +668,7 @@ export const createSessionReconcileService = ({
666
668
  state.scannedRecords += records.length;
667
669
  state.scannedBytes += measureRecordsBytes(records);
668
670
 
669
- candidates.unshift(...prepend);
671
+ candidates.unshift(...projectMessageDiffSummaries(prepend));
670
672
 
671
673
  if (state.returnedThroughID) {
672
674
  applyReturnedBoundary();
@@ -481,6 +481,46 @@ describe('createSessionReconcileService', () => {
481
481
  expect(result.records.length).toBeLessThan(5);
482
482
  });
483
483
 
484
+ it('counts summarized message diff snapshots against the reconcile byte budget', async () => {
485
+ const patch = 'diff-body-'.repeat(20_000);
486
+ const fetchPage = vi.fn(async () => pageResult([
487
+ user('msg_u1', [], {
488
+ summary: {
489
+ diffs: [{
490
+ file: 'src/large.ts',
491
+ status: 'modified',
492
+ additions: 7,
493
+ deletions: 2,
494
+ patch,
495
+ }],
496
+ },
497
+ }),
498
+ assistant('msg_a1'),
499
+ ]));
500
+ const service = createSessionReconcileService(serviceOptions(fetchPage, {
501
+ pageRecordLimit: 10,
502
+ pageByteLimit: 1024,
503
+ totalPageLimit: 10,
504
+ totalByteLimit: 10_000,
505
+ }));
506
+
507
+ const result = await service.reconcile({
508
+ sessionID: 'ses_1',
509
+ directory: '/repo',
510
+ anchor: 'msg_u1',
511
+ });
512
+
513
+ expect(result.ok).toBe(true);
514
+ expect(result.records[0].info.summary).toEqual({
515
+ diffCount: 1,
516
+ hasDiffs: true,
517
+ });
518
+ expect(result.records[0].info.summary.diffs).toBeUndefined();
519
+ expect(result.responseBytes).toBeLessThanOrEqual(1024);
520
+ expect(JSON.stringify(result.records)).not.toContain(patch);
521
+ expect(JSON.stringify(result.records)).not.toContain('src/large.ts');
522
+ });
523
+
484
524
  it('rejects continuation that does not bind runtime/directory/session/anchor/head', async () => {
485
525
  const fetchPage = vi.fn(async () => pageResult([user('msg_u1')], null));
486
526
  const service = createSessionReconcileService(serviceOptions(fetchPage));