@openchambery/web 1.18.2-beta.2 → 1.18.2-beta.21

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 (148) hide show
  1. package/dist/assets/{AgentAvatar-B2kZjgaf.js → AgentAvatar-B4fj9PIe.js} +1 -1
  2. package/dist/assets/{AssistantView-DvoyOGkx.js → AssistantView-C00aiEHK.js} +1 -1
  3. package/dist/assets/{ChatContainer-D6yfuigV.js → ChatContainer-BDFeDKu-.js} +39 -39
  4. package/dist/assets/{DiagramView-MGGo9iME.js → DiagramView-CUfSFI3e.js} +1 -1
  5. package/dist/assets/{FadeInOnReveal-aeWlaqVB.js → FadeInOnReveal-CKKU6fKI.js} +1 -1
  6. package/dist/assets/{Icon-AXqa-fYB.js → Icon-DiIuWOuC.js} +1 -1
  7. package/dist/assets/{MarkdownRendererImpl-jVOkZkyX.js → MarkdownRendererImpl-Cen6E9Lj.js} +2 -2
  8. package/dist/assets/{MobileDetailNavigation-Q2IzHqGA.js → MobileDetailNavigation-DILi0tN7.js} +1 -1
  9. package/dist/assets/{MobileShareBridge-apXM34Lb.js → MobileShareBridge-PZMRiieI.js} +1 -1
  10. package/dist/assets/MobileSurface-BLYcR0tO.js +2 -0
  11. package/dist/assets/{MultiRunWindow-B-l_O-bd.js → MultiRunWindow-D_2mWZyv.js} +1 -1
  12. package/dist/assets/{OnboardingScreen-CUsgwt4y.js → OnboardingScreen-CYeRZznl.js} +1 -1
  13. package/dist/assets/{OverlayScrollbar-D6NySAxi.js → OverlayScrollbar-BgMLtMIq.js} +1 -1
  14. package/dist/assets/SettingsView-CbDBfQp1.js +25 -0
  15. package/dist/assets/{SettingsWindow-DfVgTQ4I.js → SettingsWindow-Br7XLcSd.js} +1 -1
  16. package/dist/assets/{TerminalView-DNyjfkvT.js → TerminalView-CM5jRKlj.js} +1 -1
  17. package/dist/assets/{ToolOutputDialog-DLG1iThm.js → ToolOutputDialog-D3U6aTZ4.js} +1 -1
  18. package/dist/assets/appThemeRegistry-CDxBJNNB.js +2 -0
  19. package/dist/assets/assistant-guide-hero-BeUHQcxE.jpg +0 -0
  20. package/dist/assets/{button-CDvMVaiO.js → button-DaAoVF4X.js} +1 -1
  21. package/dist/assets/{clipboard-BW7nk_Cx.js → clipboard-BnElHSnO.js} +1 -1
  22. package/dist/assets/{collapsible-C2aamDvD.js → collapsible-BmuQu0dq.js} +1 -1
  23. package/dist/assets/{context-menu-BaxmodD8.js → context-menu-DBOMervd.js} +1 -1
  24. package/dist/assets/{desktopHosts-CBoBpJ1Z.js → desktopHosts-ZK_vkdzL.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-l2R9muux.js → desktopRelayRestore-CewM6-g5.js} +1 -1
  26. package/dist/assets/{diagram-CEon0BQs.js → diagram-DAE4CZy2.js} +1 -1
  27. package/dist/assets/{dialog-DjYfj1Sn.js → dialog-COrLBm9O.js} +1 -1
  28. package/dist/assets/{es-5Lhudh0n.js → es-DIVYdoxi.js} +1 -1
  29. package/dist/assets/{fileQueries-CE_DaZWQ.js → fileQueries-CmWYnRVz.js} +1 -1
  30. package/dist/assets/{fr-Q_UhFmKT.js → fr-CNTlVypq.js} +2 -2
  31. package/dist/assets/{gitApi-BYxg_qGJ.js → gitApi-BQT3QXD2.js} +1 -1
  32. package/dist/assets/gitApiHttp-BxGAFLTf.js +2 -0
  33. package/dist/assets/{input-k9DGGyZ6.js → input-XiL2-2gG.js} +1 -1
  34. package/dist/assets/insertionBoundaries-9a0RuIDc.js +1 -0
  35. package/dist/assets/{ja-CzMPz7bT.js → ja-C_eW5ZM8.js} +1 -1
  36. package/dist/assets/{ko-B-EslUsR.js → ko-CE3cOv7v.js} +1 -1
  37. package/dist/assets/{main-LDphyc-v.js → main-BGfX81Y9.js} +20 -20
  38. package/dist/assets/{main-CO6z5yRB.js → main-CEqSDnE2.js} +2 -2
  39. package/dist/assets/{markdown-worker-DkeWiER6.js → markdown-worker-BDyY0vt6.js} +1 -1
  40. package/dist/assets/{markdownTheme-DNT93i5L.js → markdownTheme-D-aLQZaM.js} +1 -1
  41. package/dist/assets/{miniChat-BZQwTZpx.js → miniChat-CKucSAhg.js} +2 -2
  42. package/dist/assets/mobile-C4bTWKry.js +2 -0
  43. package/dist/assets/{multirun-ZlwlkIy5.js → multirun-DeNtSkgb.js} +1 -1
  44. package/dist/assets/{persistence-DPNMIbF5.js → persistence-D1lIuyqO.js} +1 -1
  45. package/dist/assets/{pl-Dnea6Ppc.js → pl-BZKZOE99.js} +1 -1
  46. package/dist/assets/projectMeta-BunN9pH5.js +3 -0
  47. package/dist/assets/{pt-BR-Ca6QTem7.js → pt-BR-DHDgp8S3.js} +1 -1
  48. package/dist/assets/{radio-CJuhbQ5a.js → radio-Bziafaje.js} +1 -1
  49. package/dist/assets/renderElectronMiniChatApp-D8-FDgWE.js +2 -0
  50. package/dist/assets/renderMobileApp-CdU0aJ-e.js +23 -0
  51. package/dist/assets/{runtimeConfig-BtWi74Pm.js → runtimeConfig-BXCt7ZsQ.js} +1 -1
  52. package/dist/assets/runtimeEndpointReset-CK1GhIsm.js +145 -0
  53. package/dist/assets/{sortable-tabs-strip-D8JHUI0z.js → sortable-tabs-strip-pWlBorwQ.js} +1 -1
  54. package/dist/assets/src-8kQFmtjw.css +2 -0
  55. package/dist/assets/{store-wa1ersRf.js → store-CUWqzRt7.js} +2 -2
  56. package/dist/assets/{terminalApi-tB3qLcfa.js → terminalApi-DpiAJlil.js} +1 -1
  57. package/dist/assets/{tooltip-DMNR5rN0.js → tooltip-BLugzWq-.js} +1 -1
  58. package/dist/assets/{uk-xGc9Pzge.js → uk-CfQK6cz1.js} +1 -1
  59. package/dist/assets/{url-7MqoLs9X.js → url-DMs_PgIC.js} +1 -1
  60. package/dist/assets/useAppFontEffects-IEUQDvE8.js +7 -0
  61. package/dist/assets/useDesktopWindowControlsLayout-MlqJX89k.js +1 -0
  62. package/dist/assets/{useEffectiveDirectory-aUC3zzl_.js → useEffectiveDirectory-GbhkdO6m.js} +1 -1
  63. package/dist/assets/{useFontPreferences-CIurCeNv.js → useFontPreferences-92Qg-98H.js} +1 -1
  64. package/dist/assets/useMobileNavigationStore-DQHF_ETu.js +476 -0
  65. package/dist/assets/useSessionAutoCleanup-BuU_S_Sh.js +1 -0
  66. package/dist/assets/{useWorkerHighlightedLines-CiYQjBe6.js → useWorkerHighlightedLines-Ca6OgNHF.js} +1 -1
  67. package/dist/assets/{utils-Co19EJEL.js → utils-BxJEIWv5.js} +1 -1
  68. package/dist/assets/{zh-CN-sL4ktGB3.js → zh-CN-DvY2fKWp.js} +1 -1
  69. package/dist/assets/{zh-TW-Cfxps4gq.js → zh-TW-eWe0lm3-.js} +1 -1
  70. package/dist/index.html +8 -8
  71. package/dist/mini-chat.html +8 -8
  72. package/dist/mobile.html +8 -8
  73. package/package.json +1 -1
  74. package/server/index.d.ts +6 -0
  75. package/server/index.js +13 -0
  76. package/server/lib/config-sync/DOCUMENTATION.md +70 -0
  77. package/server/lib/config-sync/config-sync.test.js +197 -0
  78. package/server/lib/config-sync/constants.js +39 -0
  79. package/server/lib/config-sync/contract.js +130 -0
  80. package/server/lib/config-sync/credential-auth.js +39 -0
  81. package/server/lib/config-sync/engine.js +108 -0
  82. package/server/lib/config-sync/index.js +67 -0
  83. package/server/lib/config-sync/local-backup.js +171 -0
  84. package/server/lib/config-sync/plan.js +318 -0
  85. package/server/lib/config-sync/receiver.js +330 -0
  86. package/server/lib/config-sync/routes.js +141 -0
  87. package/server/lib/config-sync/routes.test.js +154 -0
  88. package/server/lib/config-sync/scripts.js +264 -0
  89. package/server/lib/config-sync/selections.js +112 -0
  90. package/server/lib/config-sync/tar.js +35 -0
  91. package/server/lib/config-sync/target-id.js +24 -0
  92. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  93. package/server/lib/event-stream/diff-summary.js +39 -11
  94. package/server/lib/event-stream/diff-summary.test.js +73 -6
  95. package/server/lib/event-stream/protocol.test.js +70 -7
  96. package/server/lib/notifications/DOCUMENTATION.md +2 -2
  97. package/server/lib/notifications/runtime.js +68 -27
  98. package/server/lib/notifications/runtime.test.js +176 -0
  99. package/server/lib/opencode/DOCUMENTATION.md +4 -1
  100. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  101. package/server/lib/opencode/core-routes.js +1 -0
  102. package/server/lib/opencode/feature-routes-runtime.test.js +10 -1
  103. package/server/lib/opencode/openchamber-routes.js +7 -0
  104. package/server/lib/opencode/settings-runtime.js +82 -51
  105. package/server/lib/opencode/settings-runtime.test.js +42 -0
  106. package/server/lib/relay/DOCUMENTATION.md +1 -1
  107. package/server/lib/session-index/DOCUMENTATION.md +12 -6
  108. package/server/lib/session-index/routes.js +34 -0
  109. package/server/lib/session-index/routes.test.js +76 -0
  110. package/server/lib/session-index/service.js +53 -4
  111. package/server/lib/session-index/service.test.js +72 -2
  112. package/server/lib/session-title/DOCUMENTATION.md +19 -14
  113. package/server/lib/session-title/runtime.js +8 -8
  114. package/server/lib/session-title/runtime.test.js +231 -0
  115. package/server/lib/session-turn-pages/DOCUMENTATION.md +26 -6
  116. package/server/lib/session-turn-pages/changes.service.js +156 -0
  117. package/server/lib/session-turn-pages/changes.service.test.js +188 -0
  118. package/server/lib/session-turn-pages/reconcile.routes.test.js +52 -0
  119. package/server/lib/session-turn-pages/reconcile.service.js +3 -1
  120. package/server/lib/session-turn-pages/reconcile.service.test.js +44 -0
  121. package/server/lib/session-turn-pages/routes.js +273 -12
  122. package/server/lib/session-turn-pages/routes.test.js +387 -0
  123. package/server/lib/session-turn-pages/service.js +31 -7
  124. package/server/lib/session-turn-pages/service.test.js +40 -0
  125. package/server/lib/small-model/DOCUMENTATION.md +93 -38
  126. package/server/lib/small-model/catalog.js +10 -2
  127. package/server/lib/small-model/catalog.test.js +3 -1
  128. package/server/lib/small-model/index.js +52 -28
  129. package/server/lib/small-model/opencode-session.js +339 -0
  130. package/server/lib/small-model/opencode-session.test.js +177 -0
  131. package/server/lib/small-model/resolve.js +116 -5
  132. package/server/lib/small-model/resolve.test.js +76 -10
  133. package/server/lib/small-model/summary-settings.test.js +60 -3
  134. package/dist/assets/MobileSurface-htGkf7Ua.js +0 -2
  135. package/dist/assets/SettingsView-DxMpVLlt.js +0 -25
  136. package/dist/assets/appThemeRegistry-CgfOR_0L.js +0 -2
  137. package/dist/assets/gitApiHttp-D8j1qRH4.js +0 -2
  138. package/dist/assets/insertionBoundaries-DzXMTleb.js +0 -1
  139. package/dist/assets/mobile-BktO3_4R.js +0 -2
  140. package/dist/assets/projectMeta-v-LP2mzz.js +0 -3
  141. package/dist/assets/renderElectronMiniChatApp-Db8uO-6q.js +0 -2
  142. package/dist/assets/renderMobileApp-BCV81Ryl.js +0 -23
  143. package/dist/assets/runtimeEndpointReset-Dg3DMy9v.js +0 -145
  144. package/dist/assets/src-oHrYIqHF.css +0 -2
  145. package/dist/assets/useAppFontEffects-CM0VKsdo.js +0 -7
  146. package/dist/assets/useDesktopWindowControlsLayout-Cq-r4Hcb.js +0 -1
  147. package/dist/assets/useMobileNavigationStore-DnzT4jO2.js +0 -475
  148. package/dist/assets/useSessionAutoCleanup-DxZeceKO.js +0 -1
@@ -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,58 @@ 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
+ diffs: [{
248
+ file: 'src/large.ts',
249
+ status: 'modified',
250
+ additions: 5,
251
+ deletions: 1,
252
+ }],
253
+ diffCount: 1,
254
+ hasDiffs: true,
255
+ });
256
+ expect(res.body.records[0].parts[0]).toEqual({
257
+ id: 'prt_1',
258
+ type: 'text',
259
+ text: 'full recovery text',
260
+ });
261
+ expect(JSON.stringify(res.body)).not.toContain(patch);
262
+ });
263
+
212
264
  it('returns HTTP 200 for resetRequired (anchor lost / budget rebuild)', async () => {
213
265
  const reconcile = vi.fn(async () => successPage({
214
266
  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,50 @@ 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
+ diffs: [{
516
+ file: 'src/large.ts',
517
+ status: 'modified',
518
+ additions: 7,
519
+ deletions: 2,
520
+ }],
521
+ diffCount: 1,
522
+ hasDiffs: true,
523
+ });
524
+ expect(result.responseBytes).toBeLessThanOrEqual(1024);
525
+ expect(JSON.stringify(result.records)).not.toContain(patch);
526
+ });
527
+
484
528
  it('rejects continuation that does not bind runtime/directory/session/anchor/head', async () => {
485
529
  const fetchPage = vi.fn(async () => pageResult([user('msg_u1')], null));
486
530
  const service = createSessionReconcileService(serviceOptions(fetchPage));
@@ -1,4 +1,8 @@
1
1
  import { createOpencodeClient } from '@opencode-ai/sdk/v2';
2
+ import { projectMessageSummaryDiffCounts } from '../event-stream/diff-summary.js';
3
+ import {
4
+ createSessionChangesService,
5
+ } from './changes.service.js';
2
6
  import {
3
7
  createSessionReconcileService,
4
8
  MAX_ANCHOR_LENGTH,
@@ -6,6 +10,7 @@ import {
6
10
  } from './reconcile.service.js';
7
11
  import {
8
12
  createSessionTurnPageService,
13
+ projectMessageDiffSummaries,
9
14
  projectSlimParts,
10
15
  SLIM_PARTS_PROJECTION,
11
16
  } from './service.js';
@@ -76,15 +81,27 @@ const RECONCILE_TOTAL_BYTE_LIMIT = (() => {
76
81
  /** Test/inspect helper — resolved host-local scan chunk. */
77
82
  export const getInnerSessionTurnScanLimit = () => _inner_scanLimit;
78
83
 
84
+ const MESSAGE_ID_MIN = 1;
85
+ const MESSAGE_ID_MAX = 512;
86
+ const SESSION_ID_MIN = 1;
87
+ const SESSION_ID_MAX = 512;
88
+ const FILE_PATH_MIN = 1;
89
+ const FILE_PATH_MAX = 4096;
90
+ const EXACT_MESSAGE_TIMEOUT_MS = 45_000;
91
+ const CHANGES_TIMEOUT_MS = 45_000;
92
+
79
93
  /** Safe client-facing error strings — no paths, tokens, or upstream bodies. */
80
94
  const SAFE_ERRORS = {
81
95
  invalid_turns: 'turns must be an integer between 1 and 10',
82
96
  invalid_scan_limit: 'scanLimit must be an integer between 10 and 200',
83
97
  invalid_session: 'sessionID is required',
98
+ invalid_message: 'messageID is required',
99
+ invalid_file: 'file is invalid',
84
100
  invalid_cursor: 'invalid cursor',
85
101
  invalid_anchor: 'anchor is required and must be a non-empty message id',
86
102
  invalid_continuation: 'invalid continuation',
87
103
  invalid_reconcile_params: 'provide exactly one of anchor or continuation',
104
+ change_not_found: 'change file not found',
88
105
  upstream: 'upstream',
89
106
  unavailable: 'upstream unavailable',
90
107
  aborted: 'aborted',
@@ -97,6 +114,23 @@ const SAFE_ERRORS = {
97
114
  internal: 'internal error',
98
115
  };
99
116
 
117
+ /** Reject control characters / NUL in change file selectors. */
118
+ const hasControlOrNul = (value) => {
119
+ for (let i = 0; i < value.length; i += 1) {
120
+ const code = value.charCodeAt(i);
121
+ if (code === 0 || (code >= 1 && code <= 31) || code === 127) {
122
+ return true;
123
+ }
124
+ }
125
+ return false;
126
+ };
127
+
128
+ const isBoundedId = (value, { min = MESSAGE_ID_MIN, max = MESSAGE_ID_MAX } = {}) => (
129
+ typeof value === 'string'
130
+ && value.length >= min
131
+ && value.length <= max
132
+ );
133
+
100
134
  const parsePositiveInt = (value) => {
101
135
  if (value === undefined || value === null || value === '') return null;
102
136
  if (typeof value === 'number' && Number.isFinite(value)) {
@@ -172,11 +206,22 @@ const requestSignal = (req, res) => {
172
206
  return controller.signal;
173
207
  };
174
208
 
209
+ const createSdkClient = ({ buildOpenCodeUrl, getOpenCodeAuthHeaders }) => {
210
+ const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
211
+ const headers = typeof getOpenCodeAuthHeaders === 'function' ? getOpenCodeAuthHeaders() : {};
212
+ return createOpencodeClient({ baseUrl, headers });
213
+ };
214
+
215
+ const throwUpstream = (logger, label) => {
216
+ logger?.warn?.(label);
217
+ const error = new Error('upstream');
218
+ error.code = 'upstream';
219
+ throw error;
220
+ };
221
+
175
222
  const createSdkFetchPage = ({ buildOpenCodeUrl, getOpenCodeAuthHeaders, logger }) => {
176
223
  return async ({ sessionID, directory, before, limit, signal }) => {
177
- const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
178
- const headers = typeof getOpenCodeAuthHeaders === 'function' ? getOpenCodeAuthHeaders() : {};
179
- const client = createOpencodeClient({ baseUrl, headers });
224
+ const client = createSdkClient({ buildOpenCodeUrl, getOpenCodeAuthHeaders });
180
225
 
181
226
  const result = await client.session.messages({
182
227
  sessionID,
@@ -186,10 +231,7 @@ const createSdkFetchPage = ({ buildOpenCodeUrl, getOpenCodeAuthHeaders, logger }
186
231
  }, { signal });
187
232
 
188
233
  if (result?.error) {
189
- logger?.warn?.('[session-turn-pages] session.messages SDK error');
190
- const error = new Error('upstream');
191
- error.code = 'upstream';
192
- throw error;
234
+ throwUpstream(logger, '[session-turn-pages] session.messages SDK error');
193
235
  }
194
236
 
195
237
  const data = result?.data;
@@ -199,10 +241,7 @@ const createSdkFetchPage = ({ buildOpenCodeUrl, getOpenCodeAuthHeaders, logger }
199
241
  ? data.items
200
242
  : null;
201
243
  if (!records) {
202
- logger?.warn?.('[session-turn-pages] session.messages malformed payload');
203
- const error = new Error('upstream');
204
- error.code = 'upstream';
205
- throw error;
244
+ throwUpstream(logger, '[session-turn-pages] session.messages malformed payload');
206
245
  }
207
246
 
208
247
  const headerCursor = result?.response?.headers?.get?.('x-next-cursor');
@@ -218,6 +257,70 @@ const createSdkFetchPage = ({ buildOpenCodeUrl, getOpenCodeAuthHeaders, logger }
218
257
  };
219
258
  };
220
259
 
260
+ const createSdkFetchMessage = ({ buildOpenCodeUrl, getOpenCodeAuthHeaders, logger }) => {
261
+ return async ({ sessionID, messageID, directory, signal }) => {
262
+ const client = createSdkClient({ buildOpenCodeUrl, getOpenCodeAuthHeaders });
263
+ const result = await client.session.message({
264
+ sessionID,
265
+ messageID,
266
+ ...(typeof directory === 'string' && directory.length > 0 ? { directory } : {}),
267
+ }, { signal });
268
+
269
+ if (result?.error) {
270
+ const status = result?.response?.status;
271
+ if (status === 404) {
272
+ const error = new Error('not_found');
273
+ error.code = 'not_found';
274
+ throw error;
275
+ }
276
+ throwUpstream(logger, '[session-turn-pages] session.message SDK error');
277
+ }
278
+
279
+ const data = result?.data;
280
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
281
+ throwUpstream(logger, '[session-turn-pages] session.message malformed payload');
282
+ }
283
+ return data;
284
+ };
285
+ };
286
+
287
+ const createSdkFetchDiff = ({ buildOpenCodeUrl, getOpenCodeAuthHeaders, logger }) => {
288
+ return async ({ sessionID, messageID, directory, signal }) => {
289
+ const client = createSdkClient({ buildOpenCodeUrl, getOpenCodeAuthHeaders });
290
+ const result = await client.session.diff({
291
+ sessionID,
292
+ messageID,
293
+ ...(typeof directory === 'string' && directory.length > 0 ? { directory } : {}),
294
+ }, { signal });
295
+
296
+ if (result?.error) {
297
+ throwUpstream(logger, '[session-changes] session.diff SDK error');
298
+ }
299
+
300
+ const data = result?.data;
301
+ if (!Array.isArray(data)) {
302
+ throwUpstream(logger, '[session-changes] session.diff malformed payload');
303
+ }
304
+ return data;
305
+ };
306
+ };
307
+
308
+ /**
309
+ * Project exact message GET payload: keep original `{ info, parts }` shape,
310
+ * only L1-project `info.summary.diffs` → slim file list + diffCount/hasDiffs.
311
+ */
312
+ export const projectExactMessagePayload = (payload) => {
313
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
314
+ return payload;
315
+ }
316
+ if (payload.info && typeof payload.info === 'object' && !Array.isArray(payload.info)) {
317
+ const nextInfo = projectMessageSummaryDiffCounts(payload.info);
318
+ if (nextInfo === payload.info) return payload;
319
+ return { ...payload, info: nextInfo };
320
+ }
321
+ return projectMessageSummaryDiffCounts(payload);
322
+ };
323
+
221
324
  const mapServiceError = (error) => {
222
325
  const code = typeof error === 'string' ? error : String(error ?? 'upstream');
223
326
  if (
@@ -265,6 +368,21 @@ const mapServiceError = (error) => {
265
368
  if (code === 'invalid_reconcile_params') {
266
369
  return { status: 400, body: { error: SAFE_ERRORS.invalid_reconcile_params } };
267
370
  }
371
+ if (code === 'invalid_message' || code === 'invalid_params') {
372
+ return { status: 400, body: { error: SAFE_ERRORS.invalid_message, code: 'invalid_message' } };
373
+ }
374
+ if (code === 'invalid_file') {
375
+ return { status: 400, body: { error: SAFE_ERRORS.invalid_file, code: 'invalid_file' } };
376
+ }
377
+ if (code === 'change_not_found') {
378
+ return {
379
+ status: 404,
380
+ body: { error: SAFE_ERRORS.change_not_found, code: 'change_not_found' },
381
+ };
382
+ }
383
+ if (code === 'not_found') {
384
+ return { status: 404, body: { error: 'not found', code: 'not_found' } };
385
+ }
268
386
  return { status: 502, body: { error: SAFE_ERRORS.upstream } };
269
387
  };
270
388
 
@@ -291,6 +409,8 @@ const logInternalError = (logger, error, context = {}) => {
291
409
  * Register OpenChamber-owned session message routes:
292
410
  * - GET /api/openchamber/sessions/:sessionID/messages
293
411
  * - GET /api/openchamber/sessions/:sessionID/messages/reconcile
412
+ * - GET /api/openchamber/sessions/:sessionID/changes
413
+ * - GET /api/session/:sessionID/message/:messageID (L1 exact message; before proxy)
294
414
  *
295
415
  * Global /api auth is enforced by core-routes requireApiAuth before feature
296
416
  * routes. This module does not add redundant auth middleware.
@@ -303,6 +423,8 @@ export const registerSessionTurnPageRoutes = (app, dependencies = {}) => {
303
423
  const {
304
424
  sessionTurnPageService: injectedService,
305
425
  sessionReconcileService: injectedReconcileService,
426
+ sessionChangesService: injectedChangesService,
427
+ fetchExactMessage: injectedFetchExactMessage,
306
428
  buildOpenCodeUrl,
307
429
  getOpenCodeAuthHeaders,
308
430
  logger = console,
@@ -332,6 +454,15 @@ export const registerSessionTurnPageRoutes = (app, dependencies = {}) => {
332
454
  scanLimit: _inner_scanLimit,
333
455
  });
334
456
 
457
+ const fetchExactMessage = injectedFetchExactMessage
458
+ ?? createSdkFetchMessage({ buildOpenCodeUrl, getOpenCodeAuthHeaders, logger });
459
+
460
+ const changesService = injectedChangesService ?? createSessionChangesService({
461
+ fetchMessage: fetchExactMessage,
462
+ fetchDiff: createSdkFetchDiff({ buildOpenCodeUrl, getOpenCodeAuthHeaders, logger }),
463
+ logger,
464
+ });
465
+
335
466
  // More-specific path first (before generic messages route).
336
467
  app.get('/api/openchamber/sessions/:sessionID/messages/reconcile', async (req, res) => {
337
468
  const sessionID = req.params?.sessionID;
@@ -379,7 +510,7 @@ export const registerSessionTurnPageRoutes = (app, dependencies = {}) => {
379
510
  }
380
511
 
381
512
  return res.status(200).json({
382
- records: Array.isArray(result.records) ? result.records : [],
513
+ records: projectMessageDiffSummaries(Array.isArray(result.records) ? result.records : []),
383
514
  anchorFound: result.anchorFound === true,
384
515
  capturedHeadMessageID: result.capturedHeadMessageID ?? null,
385
516
  latestHeadMessageID: result.latestHeadMessageID ?? null,
@@ -491,4 +622,134 @@ export const registerSessionTurnPageRoutes = (app, dependencies = {}) => {
491
622
  timed.clear();
492
623
  }
493
624
  });
625
+
626
+ /**
627
+ * Exact message GET — registered before generic `/api` proxy so materialize /
628
+ * recovery never relay raw multi-megabyte patch bodies in `summary.diffs`.
629
+ * Returns the official `{ info, parts }` shape with L1 slim-list projection.
630
+ */
631
+ app.get('/api/session/:sessionID/message/:messageID', async (req, res) => {
632
+ const sessionID = req.params?.sessionID;
633
+ const messageID = req.params?.messageID;
634
+ if (!isBoundedId(sessionID, { min: SESSION_ID_MIN, max: SESSION_ID_MAX })) {
635
+ return res.status(400).json({ error: SAFE_ERRORS.invalid_session, code: 'invalid_session' });
636
+ }
637
+ if (!isBoundedId(messageID, { min: MESSAGE_ID_MIN, max: MESSAGE_ID_MAX })) {
638
+ return res.status(400).json({ error: SAFE_ERRORS.invalid_message, code: 'invalid_message' });
639
+ }
640
+
641
+ const directory = typeof req.query?.directory === 'string' && req.query.directory.length > 0
642
+ ? req.query.directory
643
+ : undefined;
644
+
645
+ const parentSignal = requestSignal(req, res);
646
+ const timed = timeoutSignal(EXACT_MESSAGE_TIMEOUT_MS, parentSignal);
647
+
648
+ try {
649
+ const payload = await fetchExactMessage({
650
+ sessionID,
651
+ messageID,
652
+ directory,
653
+ signal: timed.signal,
654
+ });
655
+ return res.status(200).json(projectExactMessagePayload(payload));
656
+ } catch (error) {
657
+ if (error?.name === 'AbortError' || error?.code === 'aborted') {
658
+ if (!res.headersSent) {
659
+ return res.status(499).json({ error: SAFE_ERRORS.aborted });
660
+ }
661
+ return undefined;
662
+ }
663
+ if (error?.code === 'not_found') {
664
+ if (!res.headersSent) {
665
+ return res.status(404).json({ error: 'not found', code: 'not_found' });
666
+ }
667
+ return undefined;
668
+ }
669
+ logger?.warn?.('[session-turn-pages] exact message failed', {
670
+ sessionID,
671
+ messageID,
672
+ hasDirectory: directory != null,
673
+ });
674
+ if (!res.headersSent) {
675
+ return res.status(502).json({ error: SAFE_ERRORS.upstream });
676
+ }
677
+ return undefined;
678
+ } finally {
679
+ timed.clear();
680
+ }
681
+ });
682
+
683
+ /**
684
+ * Changes API — L2 file list (no `file`) or L3 single-file patch (`file=`).
685
+ * Never returns the full message envelope or unrelated file patches.
686
+ */
687
+ app.get('/api/openchamber/sessions/:sessionID/changes', async (req, res) => {
688
+ const sessionID = req.params?.sessionID;
689
+ if (!isBoundedId(sessionID, { min: SESSION_ID_MIN, max: SESSION_ID_MAX })) {
690
+ return res.status(400).json({ error: SAFE_ERRORS.invalid_session, code: 'invalid_session' });
691
+ }
692
+
693
+ const messageIDRaw = req.query?.messageID;
694
+ if (!isBoundedId(messageIDRaw, { min: MESSAGE_ID_MIN, max: MESSAGE_ID_MAX })) {
695
+ return res.status(400).json({ error: SAFE_ERRORS.invalid_message, code: 'invalid_message' });
696
+ }
697
+
698
+ const fileRaw = req.query?.file;
699
+ let file;
700
+ if (fileRaw !== undefined && fileRaw !== null && fileRaw !== '') {
701
+ if (
702
+ typeof fileRaw !== 'string'
703
+ || fileRaw.length < FILE_PATH_MIN
704
+ || fileRaw.length > FILE_PATH_MAX
705
+ || hasControlOrNul(fileRaw)
706
+ ) {
707
+ return res.status(400).json({ error: SAFE_ERRORS.invalid_file, code: 'invalid_file' });
708
+ }
709
+ file = fileRaw;
710
+ }
711
+
712
+ const directory = typeof req.query?.directory === 'string' && req.query.directory.length > 0
713
+ ? req.query.directory
714
+ : undefined;
715
+
716
+ const parentSignal = requestSignal(req, res);
717
+ const timed = timeoutSignal(CHANGES_TIMEOUT_MS, parentSignal);
718
+
719
+ try {
720
+ const result = await changesService.loadChanges({
721
+ sessionID,
722
+ messageID: messageIDRaw,
723
+ directory,
724
+ file,
725
+ signal: timed.signal,
726
+ });
727
+
728
+ if (!result?.ok) {
729
+ const mapped = mapServiceError(result?.error);
730
+ return res.status(mapped.status).json(mapped.body);
731
+ }
732
+
733
+ return res.status(200).json(result.body);
734
+ } catch (error) {
735
+ if (error?.name === 'AbortError') {
736
+ if (!res.headersSent) {
737
+ return res.status(499).json({ error: SAFE_ERRORS.aborted });
738
+ }
739
+ return undefined;
740
+ }
741
+ logger?.warn?.('[session-changes] loadChanges failed', {
742
+ sessionID,
743
+ messageID: messageIDRaw,
744
+ hasDirectory: directory != null,
745
+ hasFile: file != null,
746
+ });
747
+ if (!res.headersSent) {
748
+ return res.status(502).json({ error: SAFE_ERRORS.upstream });
749
+ }
750
+ return undefined;
751
+ } finally {
752
+ timed.clear();
753
+ }
754
+ });
494
755
  };