@openchambery/web 1.18.1 → 1.18.2-beta.10

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 (126) hide show
  1. package/dist/assets/{AgentAvatar-B2kZjgaf.js → AgentAvatar-BzRb9sP6.js} +1 -1
  2. package/dist/assets/{AssistantView-f5eK9QED.js → AssistantView-Bcx9ER-E.js} +1 -1
  3. package/dist/assets/{ChatContainer-BvJoxcjJ.js → ChatContainer-DaBnpayh.js} +38 -38
  4. package/dist/assets/{DiagramView-Vu2JnmC-.js → DiagramView-DaZSw3PM.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-CSxVZ_eA.js → MarkdownRendererImpl-CoMjP1t6.js} +1 -1
  8. package/dist/assets/{MobileDetailNavigation-3NIRdxpt.js → MobileDetailNavigation-Bsi54FsV.js} +1 -1
  9. package/dist/assets/{MobileShareBridge-B7jqfJ-S.js → MobileShareBridge-BQd5tCJN.js} +1 -1
  10. package/dist/assets/MobileSurface-C0bqzAcb.js +2 -0
  11. package/dist/assets/{MultiRunWindow-DkYTqcPq.js → MultiRunWindow-CJLa67M0.js} +1 -1
  12. package/dist/assets/{OnboardingScreen-CCfdEtW6.js → OnboardingScreen-BZHN7C2r.js} +1 -1
  13. package/dist/assets/{OverlayScrollbar-DeL1aO59.js → OverlayScrollbar-BLbgO_QF.js} +1 -1
  14. package/dist/assets/SettingsView-pHa9Y38q.js +25 -0
  15. package/dist/assets/{SettingsWindow--nLXOo-L.js → SettingsWindow-q6zT03-N.js} +1 -1
  16. package/dist/assets/{TerminalView-B80SmYHn.js → TerminalView-BFb17E29.js} +1 -1
  17. package/dist/assets/{ToolOutputDialog-CtbnU35x.js → ToolOutputDialog-sVkggrhW.js} +1 -1
  18. package/dist/assets/appThemeRegistry-Bh6gmBoH.js +2 -0
  19. package/dist/assets/{button-CDvMVaiO.js → button-DCUMIXDC.js} +1 -1
  20. package/dist/assets/{clipboard-Jdqe05db.js → clipboard-DF5tFLDb.js} +1 -1
  21. package/dist/assets/{collapsible-C2aamDvD.js → collapsible-DNg5bLKV.js} +1 -1
  22. package/dist/assets/{context-menu-D16ocNrk.js → context-menu-VD4qvZ6C.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-BzZ3cKxG.js → dialog-DaSIYgrj.js} +1 -1
  27. package/dist/assets/{es-C25PRSjo.js → es-DCoGZljP.js} +1 -1
  28. package/dist/assets/{fileQueries-CE_DaZWQ.js → fileQueries-DXvhIL_U.js} +1 -1
  29. package/dist/assets/{fr-Cv8ZWQdW.js → fr-dkEoZpPz.js} +2 -2
  30. package/dist/assets/{gitApi-B1CIqazO.js → gitApi-cPhxx3NZ.js} +1 -1
  31. package/dist/assets/gitApiHttp-SLrnr8a5.js +2 -0
  32. package/dist/assets/{input-k9DGGyZ6.js → input-QS3IWgY5.js} +1 -1
  33. package/dist/assets/{insertionBoundaries-B9cwRNac.js → insertionBoundaries-XUyczmFs.js} +1 -1
  34. package/dist/assets/{ja-Ue9m9eSe.js → ja-bQwSsvRr.js} +1 -1
  35. package/dist/assets/{ko-DWBUCCV6.js → ko-Kq5C3hsv.js} +1 -1
  36. package/dist/assets/{main-BSVnX01h.js → main-D4CTy7xa.js} +2 -2
  37. package/dist/assets/{main-DzZumJ48.js → main-Mkx9wBnO.js} +23 -23
  38. package/dist/assets/{markdown-worker-DkeWiER6.js → markdown-worker-D-OR2e_G.js} +1 -1
  39. package/dist/assets/{markdownTheme-DNT93i5L.js → markdownTheme-y_Rf6mLa.js} +1 -1
  40. package/dist/assets/{miniChat-CrEtOV4u.js → miniChat-Bq6uelzq.js} +2 -2
  41. package/dist/assets/mobile-C1L9OCdh.js +2 -0
  42. package/dist/assets/{multirun-G-ZwKVUq.js → multirun-9OIhLOWP.js} +1 -1
  43. package/dist/assets/{persistence-DPNMIbF5.js → persistence-oSFWyKgV.js} +1 -1
  44. package/dist/assets/{pl-DaBaPCyE.js → pl-CYpMAgVn.js} +2 -2
  45. package/dist/assets/projectMeta-BXPWMXjv.js +3 -0
  46. package/dist/assets/{pt-BR-C2cJZ43o.js → pt-BR-MSPTLfTm.js} +1 -1
  47. package/dist/assets/{radio-BtilB34p.js → radio-CqpSp0pL.js} +1 -1
  48. package/dist/assets/{renderElectronMiniChatApp-D1HWiWMn.js → renderElectronMiniChatApp-B8cSB7U4.js} +2 -2
  49. package/dist/assets/renderMobileApp-DmBjzy65.js +23 -0
  50. package/dist/assets/{runtimeConfig-BtWi74Pm.js → runtimeConfig-n4QAk1lU.js} +1 -1
  51. package/dist/assets/runtimeEndpointReset-Bw-RBy5a.js +145 -0
  52. package/dist/assets/{sortable-tabs-strip-BpvOof2G.js → sortable-tabs-strip-B4wI7VAP.js} +1 -1
  53. package/dist/assets/src-BhvNwAr-.css +2 -0
  54. package/dist/assets/{store-SqV3zfE8.js → store-OURVnLSN.js} +2 -2
  55. package/dist/assets/{terminalApi-tB3qLcfa.js → terminalApi-_NnfkxgP.js} +1 -1
  56. package/dist/assets/{tooltip-DMNR5rN0.js → tooltip-xEnco9xY.js} +1 -1
  57. package/dist/assets/{uk-DJz_P8OZ.js → uk-cSkRAYFY.js} +1 -1
  58. package/dist/assets/{url-7MqoLs9X.js → url-DIZgk80M.js} +1 -1
  59. package/dist/assets/{useAppFontEffects-BBxqFIta.js → useAppFontEffects-BHoHqFhx.js} +3 -3
  60. package/dist/assets/{useDesktopWindowControlsLayout-D8fjPyqI.js → useDesktopWindowControlsLayout-DwmEdp3O.js} +1 -1
  61. package/dist/assets/{useEffectiveDirectory-C5OxWqUH.js → useEffectiveDirectory-Bjo9xE9q.js} +1 -1
  62. package/dist/assets/{useFontPreferences-CIurCeNv.js → useFontPreferences-B-QHyR3C.js} +1 -1
  63. package/dist/assets/useMobileNavigationStore-SCw9NohC.js +476 -0
  64. package/dist/assets/useSessionAutoCleanup-KwCKRF_d.js +1 -0
  65. package/dist/assets/{useWorkerHighlightedLines-CJozblYF.js → useWorkerHighlightedLines-Cm3UQxhi.js} +1 -1
  66. package/dist/assets/{utils-Co19EJEL.js → utils-Dxsi3J3l.js} +1 -1
  67. package/dist/assets/{zh-CN-DLAKDOM9.js → zh-CN-BKqeT_uz.js} +1 -1
  68. package/dist/assets/{zh-TW-s3Jjx1_n.js → zh-TW-BaNjJ8Y4.js} +1 -1
  69. package/dist/index.html +8 -8
  70. package/dist/mini-chat.html +8 -8
  71. package/dist/mobile.html +8 -8
  72. package/package.json +1 -1
  73. package/server/index.d.ts +6 -0
  74. package/server/index.js +6 -0
  75. package/server/lib/config-sync/DOCUMENTATION.md +70 -0
  76. package/server/lib/config-sync/config-sync.test.js +197 -0
  77. package/server/lib/config-sync/constants.js +39 -0
  78. package/server/lib/config-sync/contract.js +130 -0
  79. package/server/lib/config-sync/credential-auth.js +39 -0
  80. package/server/lib/config-sync/engine.js +108 -0
  81. package/server/lib/config-sync/index.js +67 -0
  82. package/server/lib/config-sync/local-backup.js +171 -0
  83. package/server/lib/config-sync/plan.js +318 -0
  84. package/server/lib/config-sync/receiver.js +330 -0
  85. package/server/lib/config-sync/routes.js +141 -0
  86. package/server/lib/config-sync/routes.test.js +154 -0
  87. package/server/lib/config-sync/scripts.js +264 -0
  88. package/server/lib/config-sync/selections.js +112 -0
  89. package/server/lib/config-sync/tar.js +35 -0
  90. package/server/lib/config-sync/target-id.js +24 -0
  91. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  92. package/server/lib/event-stream/diff-summary.js +35 -16
  93. package/server/lib/event-stream/diff-summary.test.js +46 -6
  94. package/server/lib/event-stream/protocol.test.js +7 -7
  95. package/server/lib/git/DOCUMENTATION.md +2 -1
  96. package/server/lib/git/service.js +24 -5
  97. package/server/lib/git/service.test.js +51 -0
  98. package/server/lib/opencode/DOCUMENTATION.md +4 -1
  99. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  100. package/server/lib/opencode/core-routes.js +1 -0
  101. package/server/lib/opencode/feature-routes-runtime.test.js +10 -1
  102. package/server/lib/opencode/openchamber-routes.js +7 -0
  103. package/server/lib/opencode/settings-runtime.js +82 -51
  104. package/server/lib/opencode/settings-runtime.test.js +42 -0
  105. package/server/lib/relay/DOCUMENTATION.md +1 -1
  106. package/server/lib/session-turn-pages/DOCUMENTATION.md +26 -6
  107. package/server/lib/session-turn-pages/changes.service.js +156 -0
  108. package/server/lib/session-turn-pages/changes.service.test.js +188 -0
  109. package/server/lib/session-turn-pages/reconcile.routes.test.js +48 -0
  110. package/server/lib/session-turn-pages/reconcile.service.js +3 -1
  111. package/server/lib/session-turn-pages/reconcile.service.test.js +40 -0
  112. package/server/lib/session-turn-pages/routes.js +273 -12
  113. package/server/lib/session-turn-pages/routes.test.js +359 -0
  114. package/server/lib/session-turn-pages/service.js +29 -7
  115. package/server/lib/session-turn-pages/service.test.js +30 -0
  116. package/dist/assets/MobileSurface-BL3hired.js +0 -2
  117. package/dist/assets/SettingsView-CyrIDyDl.js +0 -25
  118. package/dist/assets/appThemeRegistry-D2ZUcQmr.js +0 -2
  119. package/dist/assets/gitApiHttp-D8j1qRH4.js +0 -2
  120. package/dist/assets/mobile-DMjO-f5u.js +0 -2
  121. package/dist/assets/projectMeta-owiDYjF9.js +0 -3
  122. package/dist/assets/renderMobileApp-Cqxwb3Ub.js +0 -23
  123. package/dist/assets/runtimeEndpointReset-C0oDuy2R.js +0 -145
  124. package/dist/assets/src-oHrYIqHF.css +0 -2
  125. package/dist/assets/useMobileNavigationStore-U3b_hG4m.js +0 -475
  126. package/dist/assets/useSessionAutoCleanup-CDYDm76L.js +0 -1
@@ -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));