@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
@@ -320,6 +320,122 @@ describe('registerSessionTurnPageRoutes', () => {
320
320
  expect(part.state.output).toBeUndefined();
321
321
  });
322
322
 
323
+ it('projects message summary.diffs to L1 slim list + markers before serializing a turn-page response', async () => {
324
+ const patch = 'diff-body-'.repeat(20_000);
325
+ const loadPage = vi.fn(async () => ({
326
+ ok: true,
327
+ records: [{
328
+ info: {
329
+ id: 'msg_u1',
330
+ role: 'user',
331
+ summary: {
332
+ diffs: [{
333
+ file: 'src/large.ts',
334
+ status: 'modified',
335
+ additions: 9,
336
+ deletions: 3,
337
+ patch,
338
+ before: 'old',
339
+ after: 'new',
340
+ }],
341
+ },
342
+ },
343
+ parts: [],
344
+ }],
345
+ turnCount: 1,
346
+ cursor: null,
347
+ complete: true,
348
+ }));
349
+ const { app, route } = registry();
350
+ registerSessionTurnPageRoutes(app, { sessionTurnPageService: { loadPage } });
351
+ const res = response();
352
+
353
+ await route('GET', ROUTE)({
354
+ params: { sessionID: 'ses_1' },
355
+ query: { turns: '3' },
356
+ headers: {},
357
+ }, res);
358
+
359
+ expect(res.statusCode).toBe(200);
360
+ expect(res.body.records[0].info.summary).toEqual({
361
+ diffs: [{
362
+ file: 'src/large.ts',
363
+ status: 'modified',
364
+ additions: 9,
365
+ deletions: 3,
366
+ }],
367
+ diffCount: 1,
368
+ hasDiffs: true,
369
+ });
370
+ expect(JSON.stringify(res.body)).not.toContain(patch);
371
+ expect(JSON.stringify(res.body)).not.toContain('"before"');
372
+ expect(JSON.stringify(res.body)).not.toContain('"after"');
373
+ });
374
+
375
+ it('keeps L1 turn-page serialization under 256KiB for a 463-file / ~14MB patch summary', async () => {
376
+ const patchBody = 'P'.repeat(Math.floor((14 * 1024 * 1024) / 463));
377
+ const diffs = Array.from({ length: 463 }, (_, i) => ({
378
+ file: `src/generated/file-${i}.ts`,
379
+ status: 'modified',
380
+ additions: i % 7,
381
+ deletions: i % 3,
382
+ patch: `${patchBody}-${i}`,
383
+ before: `before-${i}`,
384
+ after: `after-${i}`,
385
+ from: `from-${i}`,
386
+ to: `to-${i}`,
387
+ }));
388
+ const loadPage = vi.fn(async () => ({
389
+ ok: true,
390
+ records: [{
391
+ info: {
392
+ id: 'msg_huge',
393
+ role: 'user',
394
+ summary: { title: 'huge', diffs },
395
+ },
396
+ parts: [{ id: 'prt_1', type: 'text', text: 'ok' }],
397
+ }],
398
+ turnCount: 1,
399
+ cursor: null,
400
+ complete: true,
401
+ }));
402
+ const { app, route } = registry();
403
+ registerSessionTurnPageRoutes(app, { sessionTurnPageService: { loadPage } });
404
+ const res = response();
405
+
406
+ await route('GET', ROUTE)({
407
+ params: { sessionID: 'ses_huge' },
408
+ query: { turns: '1' },
409
+ headers: {},
410
+ }, res);
411
+
412
+ expect(res.statusCode).toBe(200);
413
+ const serialized = JSON.stringify(res.body);
414
+ const summary = res.body.records[0].info.summary;
415
+ expect(summary.diffCount).toBe(463);
416
+ expect(summary.hasDiffs).toBe(true);
417
+ expect(summary.diffs).toHaveLength(463);
418
+ expect(summary.diffs[0]).toEqual({
419
+ file: 'src/generated/file-0.ts',
420
+ status: 'modified',
421
+ additions: 0,
422
+ deletions: 0,
423
+ });
424
+ expect(summary.diffs[462]).toEqual({
425
+ file: 'src/generated/file-462.ts',
426
+ status: 'modified',
427
+ additions: 462 % 7,
428
+ deletions: 462 % 3,
429
+ });
430
+ expect(serialized).not.toContain(patchBody.slice(0, 64));
431
+ expect(serialized).not.toContain('"patch"');
432
+ expect(serialized).not.toContain('"before"');
433
+ expect(serialized).not.toContain('"after"');
434
+ expect(serialized).not.toContain('"from"');
435
+ expect(serialized).not.toContain('"to"');
436
+ expect(Buffer.byteLength(serialized, 'utf8')).toBeLessThan(256 * 1024);
437
+ });
438
+
323
439
  it('projects file parts on first packet and prepend the same way', async () => {
324
440
  const png = Buffer.concat([
325
441
  Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
@@ -479,3 +595,274 @@ describe('registerSessionTurnPageRoutes', () => {
479
595
  await pending;
480
596
  });
481
597
  });
598
+
599
+ const EXACT_MESSAGE_ROUTE = '/api/session/:sessionID/message/:messageID';
600
+ const CHANGES_ROUTE = '/api/openchamber/sessions/:sessionID/changes';
601
+
602
+ describe('registerSessionTurnPageRoutes — exact message GET', () => {
603
+ it('registers GET /api/session/:sessionID/message/:messageID', () => {
604
+ const { app, route } = registry();
605
+ registerSessionTurnPageRoutes(app, {
606
+ sessionTurnPageService: { loadPage: vi.fn() },
607
+ fetchExactMessage: vi.fn(),
608
+ });
609
+ expect(route('GET', EXACT_MESSAGE_ROUTE)).toEqual(expect.any(Function));
610
+ });
611
+
612
+ it('returns original message shape with L1 summary projection only', async () => {
613
+ const patch = '@@ huge @@\n' + 'x'.repeat(2000);
614
+ const fetchExactMessage = vi.fn(async () => ({
615
+ info: {
616
+ id: 'msg_1',
617
+ role: 'user',
618
+ summary: {
619
+ title: 'turn',
620
+ diffs: [{
621
+ file: 'a.ts',
622
+ status: 'modified',
623
+ additions: 2,
624
+ deletions: 1,
625
+ patch,
626
+ }],
627
+ },
628
+ },
629
+ parts: [{ id: 'prt_1', type: 'text', text: 'hello' }],
630
+ }));
631
+ const { app, route } = registry();
632
+ registerSessionTurnPageRoutes(app, {
633
+ sessionTurnPageService: { loadPage: vi.fn() },
634
+ fetchExactMessage,
635
+ });
636
+ const res = response();
637
+
638
+ await route('GET', EXACT_MESSAGE_ROUTE)({
639
+ params: { sessionID: 'ses_1', messageID: 'msg_1' },
640
+ query: { directory: '/repo' },
641
+ headers: {},
642
+ }, res);
643
+
644
+ expect(res.statusCode).toBe(200);
645
+ expect(fetchExactMessage).toHaveBeenCalledWith(expect.objectContaining({
646
+ sessionID: 'ses_1',
647
+ messageID: 'msg_1',
648
+ directory: '/repo',
649
+ }));
650
+ expect(res.body).toEqual({
651
+ info: {
652
+ id: 'msg_1',
653
+ role: 'user',
654
+ summary: {
655
+ title: 'turn',
656
+ diffs: [{
657
+ file: 'a.ts',
658
+ status: 'modified',
659
+ additions: 2,
660
+ deletions: 1,
661
+ }],
662
+ diffCount: 1,
663
+ hasDiffs: true,
664
+ },
665
+ },
666
+ parts: [{ id: 'prt_1', type: 'text', text: 'hello' }],
667
+ });
668
+ expect(JSON.stringify(res.body)).not.toContain(patch);
669
+ });
670
+
671
+ it('rejects oversize messageID / sessionID with 400', async () => {
672
+ const fetchExactMessage = vi.fn();
673
+ const { app, route } = registry();
674
+ registerSessionTurnPageRoutes(app, {
675
+ sessionTurnPageService: { loadPage: vi.fn() },
676
+ fetchExactMessage,
677
+ });
678
+ const handler = route('GET', EXACT_MESSAGE_ROUTE);
679
+
680
+ const oversize = 'm'.repeat(513);
681
+ const res = response();
682
+ await handler({
683
+ params: { sessionID: 'ses_1', messageID: oversize },
684
+ query: {},
685
+ headers: {},
686
+ }, res);
687
+ expect(res.statusCode).toBe(400);
688
+ expect(res.body.code).toBe('invalid_message');
689
+ expect(fetchExactMessage).not.toHaveBeenCalled();
690
+ });
691
+
692
+ it('maps not_found to HTTP 404', async () => {
693
+ const fetchExactMessage = vi.fn(async () => {
694
+ const error = new Error('not_found');
695
+ error.code = 'not_found';
696
+ throw error;
697
+ });
698
+ const { app, route } = registry();
699
+ registerSessionTurnPageRoutes(app, {
700
+ sessionTurnPageService: { loadPage: vi.fn() },
701
+ fetchExactMessage,
702
+ });
703
+ const res = response();
704
+ await route('GET', EXACT_MESSAGE_ROUTE)({
705
+ params: { sessionID: 'ses_1', messageID: 'msg_missing' },
706
+ query: {},
707
+ headers: {},
708
+ }, res);
709
+ expect(res.statusCode).toBe(404);
710
+ expect(res.body.code).toBe('not_found');
711
+ });
712
+ });
713
+
714
+ describe('registerSessionTurnPageRoutes — changes API', () => {
715
+ it('registers GET /api/openchamber/sessions/:sessionID/changes', () => {
716
+ const { app, route } = registry();
717
+ registerSessionTurnPageRoutes(app, {
718
+ sessionTurnPageService: { loadPage: vi.fn() },
719
+ sessionChangesService: { loadChanges: vi.fn() },
720
+ });
721
+ expect(route('GET', CHANGES_ROUTE)).toEqual(expect.any(Function));
722
+ });
723
+
724
+ it('returns L2 file list without patch bodies when file is omitted', async () => {
725
+ const loadChanges = vi.fn(async () => ({
726
+ ok: true,
727
+ body: {
728
+ files: [{
729
+ file: 'src/a.ts',
730
+ status: 'modified',
731
+ additions: 3,
732
+ deletions: 1,
733
+ }],
734
+ },
735
+ }));
736
+ const { app, route } = registry();
737
+ registerSessionTurnPageRoutes(app, {
738
+ sessionTurnPageService: { loadPage: vi.fn() },
739
+ sessionChangesService: { loadChanges },
740
+ });
741
+ const res = response();
742
+
743
+ await route('GET', CHANGES_ROUTE)({
744
+ params: { sessionID: 'ses_1' },
745
+ query: { messageID: 'msg_1', directory: '/repo' },
746
+ headers: {},
747
+ }, res);
748
+
749
+ expect(res.statusCode).toBe(200);
750
+ expect(loadChanges).toHaveBeenCalledWith(expect.objectContaining({
751
+ sessionID: 'ses_1',
752
+ messageID: 'msg_1',
753
+ directory: '/repo',
754
+ file: undefined,
755
+ }));
756
+ expect(res.body).toEqual({
757
+ files: [{
758
+ file: 'src/a.ts',
759
+ status: 'modified',
760
+ additions: 3,
761
+ deletions: 1,
762
+ }],
763
+ });
764
+ expect(JSON.stringify(res.body)).not.toContain('patch');
765
+ expect(res.body.info).toBeUndefined();
766
+ expect(res.body.parts).toBeUndefined();
767
+ });
768
+
769
+ it('returns L3 single-file diff when file is provided', async () => {
770
+ const loadChanges = vi.fn(async () => ({
771
+ ok: true,
772
+ body: {
773
+ diff: {
774
+ file: 'src/a.ts',
775
+ status: 'modified',
776
+ additions: 3,
777
+ deletions: 1,
778
+ patch: '@@ -1 +1 @@\n+line',
779
+ },
780
+ },
781
+ }));
782
+ const { app, route } = registry();
783
+ registerSessionTurnPageRoutes(app, {
784
+ sessionTurnPageService: { loadPage: vi.fn() },
785
+ sessionChangesService: { loadChanges },
786
+ });
787
+ const res = response();
788
+
789
+ await route('GET', CHANGES_ROUTE)({
790
+ params: { sessionID: 'ses_1' },
791
+ query: { messageID: 'msg_1', file: 'src/a.ts' },
792
+ headers: {},
793
+ }, res);
794
+
795
+ expect(res.statusCode).toBe(200);
796
+ expect(loadChanges).toHaveBeenCalledWith(expect.objectContaining({
797
+ file: 'src/a.ts',
798
+ }));
799
+ expect(res.body.diff.file).toBe('src/a.ts');
800
+ expect(res.body.diff.patch).toContain('+line');
801
+ expect(res.body.files).toBeUndefined();
802
+ });
803
+
804
+ it('rejects missing messageID and control-character file with 400', async () => {
805
+ const loadChanges = vi.fn();
806
+ const { app, route } = registry();
807
+ registerSessionTurnPageRoutes(app, {
808
+ sessionTurnPageService: { loadPage: vi.fn() },
809
+ sessionChangesService: { loadChanges },
810
+ });
811
+ const handler = route('GET', CHANGES_ROUTE);
812
+
813
+ const missing = response();
814
+ await handler({
815
+ params: { sessionID: 'ses_1' },
816
+ query: {},
817
+ headers: {},
818
+ }, missing);
819
+ expect(missing.statusCode).toBe(400);
820
+ expect(missing.body.code).toBe('invalid_message');
821
+
822
+ const badFile = response();
823
+ await handler({
824
+ params: { sessionID: 'ses_1' },
825
+ query: { messageID: 'msg_1', file: 'src/\0evil.ts' },
826
+ headers: {},
827
+ }, badFile);
828
+ expect(badFile.statusCode).toBe(400);
829
+ expect(badFile.body.code).toBe('invalid_file');
830
+ expect(loadChanges).not.toHaveBeenCalled();
831
+ });
832
+
833
+ it('maps change_not_found to HTTP 404 with stable code', async () => {
834
+ const loadChanges = vi.fn(async () => ({ ok: false, error: 'change_not_found' }));
835
+ const { app, route } = registry();
836
+ registerSessionTurnPageRoutes(app, {
837
+ sessionTurnPageService: { loadPage: vi.fn() },
838
+ sessionChangesService: { loadChanges },
839
+ });
840
+ const res = response();
841
+ await route('GET', CHANGES_ROUTE)({
842
+ params: { sessionID: 'ses_1' },
843
+ query: { messageID: 'msg_1', file: 'missing.ts' },
844
+ headers: {},
845
+ }, res);
846
+ expect(res.statusCode).toBe(404);
847
+ expect(res.body).toEqual({
848
+ error: 'change file not found',
849
+ code: 'change_not_found',
850
+ });
851
+ });
852
+
853
+ it('maps aborted to HTTP 499', async () => {
854
+ const loadChanges = vi.fn(async () => ({ ok: false, error: 'aborted' }));
855
+ const { app, route } = registry();
856
+ registerSessionTurnPageRoutes(app, {
857
+ sessionTurnPageService: { loadPage: vi.fn() },
858
+ sessionChangesService: { loadChanges },
859
+ });
860
+ const res = response();
861
+ await route('GET', CHANGES_ROUTE)({
862
+ params: { sessionID: 'ses_1' },
863
+ query: { messageID: 'msg_1' },
864
+ headers: {},
865
+ }, res);
866
+ expect(res.statusCode).toBe(499);
867
+ });
868
+ });
@@ -16,6 +16,8 @@
16
16
  * (true only when nothing older was trimmed from the scanned window).
17
17
  */
18
18
 
19
+ import { projectMessageSummaryDiffCounts } from '../event-stream/diff-summary.js';
20
+
19
21
  const ASSISTANT_SESSION_DIVIDER_PREFIX = 'oc_asst_session_divider:';
20
22
 
21
23
  const DEFAULT_MAX_SCAN_PAGES = 50;
@@ -457,22 +459,44 @@ const isUserRecord = (record) => {
457
459
  };
458
460
 
459
461
  /**
460
- * Summarize tool/reasoning parts so a first packet does not have to carry the
461
- * bodies of a long tool-heavy turn.
462
+ * L1: project message `summary.diffs` to a slim file list
463
+ * `{ file, status?, additions, deletions }` plus additive `diffCount` /
464
+ * `hasDiffs`. Never retains patch/before/after/from/to. Preserves every other
465
+ * message / summary field.
466
+ */
467
+ export const projectMessageDiffSummaries = (records) => {
468
+ if (!Array.isArray(records) || records.length === 0) return records;
469
+
470
+ let changedAny = false;
471
+ const projected = records.map((record) => {
472
+ if (!record || typeof record !== 'object') return record;
473
+ const info = projectMessageSummaryDiffCounts(record.info);
474
+ if (info === record.info) return record;
475
+ changedAny = true;
476
+ return { ...record, info };
477
+ });
478
+
479
+ return changedAny ? projected : records;
480
+ };
481
+
482
+ /**
483
+ * Summarize message diff snapshots and tool/reasoning parts so a first packet
484
+ * does not have to carry bodies from a long tool-heavy turn.
462
485
  *
463
486
  * Runs after `selectTurnRecords`, so `turnCount`, `complete`, and cursor
464
- * encoding are all derived from unprojected records and cannot shift. User rows
465
- * and assistant text pass through untouched, and records that gain nothing from
487
+ * encoding are all derived from unprojected records and cannot shift. User and
488
+ * assistant text pass through untouched, and records that gain nothing from
466
489
  * projection keep their original object reference.
467
490
  *
468
491
  * @param {unknown[]} records
469
492
  * @returns {unknown[]}
470
493
  */
471
494
  export const projectSlimParts = (records) => {
472
- if (!Array.isArray(records) || records.length === 0) return records;
495
+ const summaryProjected = projectMessageDiffSummaries(records);
496
+ if (!Array.isArray(summaryProjected) || summaryProjected.length === 0) return summaryProjected;
473
497
 
474
498
  let changedAny = false;
475
- const projected = records.map((record) => {
499
+ const projected = summaryProjected.map((record) => {
476
500
  if (!record || typeof record !== 'object') return record;
477
501
  const userRow = isUserRecord(record);
478
502
 
@@ -503,7 +527,7 @@ export const projectSlimParts = (records) => {
503
527
  return { ...record, parts: nextParts };
504
528
  });
505
529
 
506
- return changedAny ? projected : records;
530
+ return changedAny ? projected : summaryProjected;
507
531
  };
508
532
 
509
533
  const countAuthoredBoundaries = (records) =>
@@ -172,6 +172,46 @@ describe('projectSlimParts', () => {
172
172
  },
173
173
  });
174
174
 
175
+ it('projects message summary.diffs to L1 slim list + markers even when parts are empty', () => {
176
+ const patch = 'diff-body-'.repeat(20_000);
177
+ const [projected] = projectSlimParts([{
178
+ info: {
179
+ id: 'msg_diff',
180
+ role: 'user',
181
+ summary: {
182
+ title: 'kept',
183
+ diffs: [{
184
+ file: 'src/large.ts',
185
+ status: 'modified',
186
+ additions: 12,
187
+ deletions: 4,
188
+ patch,
189
+ before: 'old-body',
190
+ after: 'new-body',
191
+ from: 'from-blob',
192
+ to: 'to-blob',
193
+ }],
194
+ },
195
+ },
196
+ parts: [],
197
+ }]);
198
+
199
+ expect(projected.info.summary).toEqual({
200
+ title: 'kept',
201
+ diffs: [{
202
+ file: 'src/large.ts',
203
+ status: 'modified',
204
+ additions: 12,
205
+ deletions: 4,
206
+ }],
207
+ diffCount: 1,
208
+ hasDiffs: true,
209
+ });
210
+ expect(JSON.stringify(projected)).not.toContain(patch);
211
+ expect(JSON.stringify(projected)).not.toContain('old-body');
212
+ expect(JSON.stringify(projected)).not.toContain('from-blob');
213
+ });
214
+
175
215
  it('drops tool output and metadata but keeps identity and status', () => {
176
216
  const [record] = projectSlimParts([
177
217
  { info: { id: 'msg_a1', role: 'assistant' }, parts: [toolPart('prt_1', 'x'.repeat(5000))] },