@aiworker/web 1.23.0 → 1.23.1

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 (152) hide show
  1. package/dist/assets/{CodeMirrorCommentWidgets-B1Gizi3a.js → CodeMirrorCommentWidgets-teB3HmBT.js} +1 -1
  2. package/dist/assets/DiffView-DC9RX3MF.js +4 -0
  3. package/dist/assets/{DirtyBranchSwitchDialog-aig6zJjg.js → DirtyBranchSwitchDialog-DwPkzXsS.js} +1 -1
  4. package/dist/assets/FilesView-BFZkuu4O.js +13 -0
  5. package/dist/assets/{GitView-BXsS3jG6.js → GitView-B_x52MHY.js} +4 -4
  6. package/dist/assets/{JsonTreeView-D_hpXBnF.js → JsonTreeView-cmAzUTiw.js} +1 -1
  7. package/dist/assets/{LinearIssuesView-BoUiLQG1.js → LinearIssuesView-Dehh5afm.js} +1 -1
  8. package/dist/assets/{MarkdownImageGallery-B_B_a9KS.js → MarkdownImageGallery-DgNOqqhE.js} +1 -1
  9. package/dist/assets/{MarkdownRendererImpl-xgyyLiBM.js → MarkdownRendererImpl-DusmMZ_p.js} +2 -2
  10. package/dist/assets/{OnboardingScreen-DIp84OWt.js → OnboardingScreen-B8fm1H4m.js} +1 -1
  11. package/dist/assets/{PatchDiff-8p6nLJaU.js → PatchDiff-BVr7gJU0.js} +1 -1
  12. package/dist/assets/{PierreDiffViewer-DjC8nTk9.js → PierreDiffViewer-DzLt-nel.js} +1 -1
  13. package/dist/assets/PlanView-BmQUYT_z.js +5 -0
  14. package/dist/assets/SettingsView-ChWmAcZT.js +28 -0
  15. package/dist/assets/{SettingsWindow-Cpb4hM6q.js → SettingsWindow-Dy3M6ZB9.js} +1 -1
  16. package/dist/assets/{ToolOutputDialog-DfaKZXNU.js → ToolOutputDialog-cTl_eLIH.js} +6 -6
  17. package/dist/assets/{ToolPartDiffPreview-DtZLzrSK.js → ToolPartDiffPreview-C_3iYNJQ.js} +2 -2
  18. package/dist/assets/{VirtualizedFileDiff-C8i9HpTB.js → VirtualizedFileDiff-CAWthkoy.js} +4 -4
  19. package/dist/assets/WalkthroughView-C7AHOpCA.js +4 -0
  20. package/dist/assets/{computeVirtualFileMetrics-BBI7ZwrC.js → computeVirtualFileMetrics-BkiDSlTW.js} +2 -2
  21. package/dist/assets/de-DJsQr-p7.js +10 -0
  22. package/dist/assets/es-BuYMlA9g.js +16 -0
  23. package/dist/assets/fr-ePDay33C.js +14 -0
  24. package/dist/assets/index-BD8ILbxF.js +40 -0
  25. package/dist/assets/{index-jXSK5RrB.js → index-n6O3hklM.js} +1 -1
  26. package/dist/assets/index-zhgg-SDz.css +1 -0
  27. package/dist/assets/{isDiffPlainText-B_x_rVon.js → isDiffPlainText-GMdPz-71.js} +1 -1
  28. package/dist/assets/{isFilePlainText-DW_xMer1.js → isFilePlainText-BVuH1zH9.js} +1 -1
  29. package/dist/assets/ja-BzR9xOY_.js +16 -0
  30. package/dist/assets/{ko-idhXGF8j.js → ko-BEjHAm14.js} +3 -3
  31. package/dist/assets/main-BydQ1tr_.js +930 -0
  32. package/dist/assets/{main-CzdPuL0s.js → main-D3ZDQglc.js} +2 -2
  33. package/dist/assets/{markdownCore-Cn4Tyejo.js → markdownCore-Cb2MdZnV.js} +3 -3
  34. package/dist/assets/{miniChat-CCkqKOLp.js → miniChat-d01k801U.js} +2 -2
  35. package/dist/assets/{mobile-mvxTo055.js → mobile-D7VV-Ufz.js} +2 -2
  36. package/dist/assets/patchFileDiff-CdztVMz0.js +13 -0
  37. package/dist/assets/{pl-CGZWUqZJ.js → pl-rQmpxNIF.js} +3 -3
  38. package/dist/assets/pt-BR-CorbsSW-.js +16 -0
  39. package/dist/assets/renderElectronMiniChatApp-BGG9UIlh.js +2 -0
  40. package/dist/assets/renderMobileApp-BRP3J1iv.js +25 -0
  41. package/dist/assets/runtimeEndpointReset-BhT_-c52.js +41 -0
  42. package/dist/assets/scheduledTasksApi-BEuUF_cj.js +1 -0
  43. package/dist/assets/{shikiHighlight-CRl0SUC9.js → shikiHighlight-BzXUjlby.js} +1 -1
  44. package/dist/assets/surface-COKb77Ua.js +1 -0
  45. package/dist/assets/tr-CoAMXPtI.js +7 -0
  46. package/dist/assets/uk-YlAX0rRU.js +16 -0
  47. package/dist/assets/{useAppFontEffects-CPKeOpUo.js → useAppFontEffects-Cwyhjfl5.js} +153 -155
  48. package/dist/assets/useGitComparison-BCZVefdE.js +1 -0
  49. package/dist/assets/{useInlineCommentController-BiWz4AVb.js → useInlineCommentController-DDkpBEU8.js} +2 -2
  50. package/dist/assets/{useRootScrollLock-BS4-kr7P.js → useRootScrollLock-CwjoWuC4.js} +1 -1
  51. package/dist/assets/{vendor-base-ui-DdV8vhHn.js → vendor-base-ui-BY_5qla_.js} +1 -1
  52. package/dist/assets/{vendor-codemirror-language-data-Bg4AEeCr.js → vendor-codemirror-language-data-Cb1YnICj.js} +2 -2
  53. package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
  54. package/dist/assets/{vendor-zod-BPAANqvH.js → vendor-zod-B6551dwG.js} +2 -2
  55. package/dist/assets/{web-components-z7hfnS8n.js → web-components-LV3vjTxy.js} +1 -1
  56. package/dist/assets/zh-CN-DDYg0Jl7.js +16 -0
  57. package/dist/assets/zh-TW-CQKaCayr.js +16 -0
  58. package/dist/index.html +4 -4
  59. package/dist/mini-chat.html +4 -4
  60. package/dist/mobile.html +4 -4
  61. package/package.json +1 -1
  62. package/server/index.js +3 -13
  63. package/server/lib/agent-memory/runtime.js +7 -2
  64. package/server/lib/agent-memory/runtime.test.js +22 -0
  65. package/server/lib/event-stream/DOCUMENTATION.md +3 -0
  66. package/server/lib/event-stream/global-hub.js +29 -4
  67. package/server/lib/event-stream/global-hub.test.js +22 -0
  68. package/server/lib/event-stream/global-ws-bridge.js +15 -18
  69. package/server/lib/event-stream/protocol.js +21 -7
  70. package/server/lib/event-stream/runtime.js +9 -6
  71. package/server/lib/event-stream/runtime.test.js +60 -0
  72. package/server/lib/git/DOCUMENTATION.md +10 -1
  73. package/server/lib/git/routes.js +21 -8
  74. package/server/lib/git/routes.test.js +78 -0
  75. package/server/lib/git/service.js +159 -4
  76. package/server/lib/git/service.test.js +74 -0
  77. package/server/lib/github/DOCUMENTATION.md +5 -0
  78. package/server/lib/github/routes.js +2 -2
  79. package/server/lib/message-queue/DOCUMENTATION.md +7 -0
  80. package/server/lib/message-queue/runtime.js +13 -0
  81. package/server/lib/message-queue/runtime.test.js +31 -1
  82. package/server/lib/notifications/DOCUMENTATION.md +1 -1
  83. package/server/lib/notifications/emitter-runtime.js +4 -3
  84. package/server/lib/opencode/DOCUMENTATION.md +18 -4
  85. package/server/lib/opencode/env-runtime.js +5 -1
  86. package/server/lib/opencode/env-runtime.test.js +72 -0
  87. package/server/lib/opencode/feature-routes-runtime.js +15 -1
  88. package/server/lib/opencode/lifecycle.js +4 -3
  89. package/server/lib/opencode/lifecycle.test.js +29 -0
  90. package/server/lib/opencode/managed-plugin-config.js +1 -1
  91. package/server/lib/opencode/proxy.js +33 -0
  92. package/server/lib/opencode/settings-helpers.js +3 -0
  93. package/server/lib/opencode/settings-helpers.test.js +11 -1
  94. package/server/lib/opencode/settings-registry.json +3 -0
  95. package/server/lib/opencode/settings-runtime.js +36 -9
  96. package/server/lib/opencode/settings-runtime.test.js +158 -2
  97. package/server/lib/project-context/DOCUMENTATION.md +13 -3
  98. package/server/lib/project-context/runtime.js +8 -2
  99. package/server/lib/project-context/runtime.test.js +54 -0
  100. package/server/lib/projects/DOCUMENTATION.md +2 -1
  101. package/server/lib/projects/project-config.js +38 -9
  102. package/server/lib/projects/project-config.test.js +97 -0
  103. package/server/lib/projects/project-id.js +26 -0
  104. package/server/lib/relay/DOCUMENTATION.md +20 -0
  105. package/server/lib/relay/host-client.js +7 -0
  106. package/server/lib/relay/host-client.test.js +14 -0
  107. package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
  108. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
  109. package/server/lib/scheduled-tasks/runtime.js +12 -2
  110. package/server/lib/scheduled-tasks/runtime.test.js +53 -0
  111. package/server/lib/session-assist/DOCUMENTATION.md +131 -66
  112. package/server/lib/session-assist/context.js +127 -0
  113. package/server/lib/session-assist/context.test.js +132 -0
  114. package/server/lib/session-assist/prompt.js +54 -0
  115. package/server/lib/session-assist/runtime.js +111 -257
  116. package/server/lib/session-assist/runtime.test.js +203 -0
  117. package/server/lib/walkthrough/DOCUMENTATION.md +15 -5
  118. package/server/lib/walkthrough/pr-source.test.js +21 -0
  119. package/server/lib/walkthrough/pull-request.js +7 -4
  120. package/server/lib/walkthrough/pull-request.test.js +16 -0
  121. package/server/lib/walkthrough/routes.js +19 -0
  122. package/server/lib/walkthrough/routes.test.js +26 -0
  123. package/server/lib/walkthrough/sources.js +13 -2
  124. package/server/opencode-worktree-readiness.test.js +130 -0
  125. package/dist/assets/DiffView-DXjVsEOw.js +0 -4
  126. package/dist/assets/FilesView-CJdG0-wk.js +0 -17
  127. package/dist/assets/PlanView-CJTmOF6I.js +0 -5
  128. package/dist/assets/SettingsView-DD-JqIJx.js +0 -28
  129. package/dist/assets/WalkthroughView-DeKMvZlL.js +0 -4
  130. package/dist/assets/de-DCrsQ5zS.js +0 -10
  131. package/dist/assets/es-Dwv4YfH6.js +0 -16
  132. package/dist/assets/fr-BCWg4Cnx.js +0 -14
  133. package/dist/assets/index-B68h5S5B.css +0 -1
  134. package/dist/assets/index-DVwMarHI.js +0 -39
  135. package/dist/assets/ja-Bruw3RYC.js +0 -16
  136. package/dist/assets/main-BkyaQhJX.js +0 -930
  137. package/dist/assets/patchFileDiff-Csk4iNUi.js +0 -13
  138. package/dist/assets/pt-BR-B8V7u7tA.js +0 -16
  139. package/dist/assets/renderElectronMiniChatApp-BxoyujA5.js +0 -2
  140. package/dist/assets/renderMobileApp-B48CsP4r.js +0 -25
  141. package/dist/assets/runtimeEndpointReset-Bg5gx18H.js +0 -17
  142. package/dist/assets/scheduledTasksApi-BW0Etzwz.js +0 -1
  143. package/dist/assets/surface-oMvhsog3.js +0 -1
  144. package/dist/assets/tr-BK_KiO2V.js +0 -7
  145. package/dist/assets/uk-CG74xV8N.js +0 -16
  146. package/dist/assets/useGitComparison-DH220WmO.js +0 -1
  147. package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
  148. package/dist/assets/zh-CN-T4AXofZI.js +0 -16
  149. package/dist/assets/zh-TW-Dte40sxp.js +0 -16
  150. package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
  151. package/server/lib/mcp-reconnect/runtime.js +0 -122
  152. package/server/lib/mcp-reconnect/runtime.test.js +0 -140
@@ -0,0 +1,203 @@
1
+ import { createServer } from 'node:http';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { createSessionAssistRuntime } from './runtime.js';
4
+
5
+ const resources = [];
6
+ const message = (id, role, text, extra = {}) => ({
7
+ info: { id, role, parentID: 'user', finish: 'stop', time: { completed: 1 }, providerID: 'test-provider', modelID: 'test-model', ...extra },
8
+ parts: [{ type: 'text', text }],
9
+ });
10
+ const output = (recap = 'Зміни готові', suggestion = '') => ({ text: JSON.stringify({ recap, suggestion }), providerID: 'test-provider', modelID: 'test-model' });
11
+ const pause = () => new Promise((resolve) => setTimeout(resolve, 15));
12
+
13
+ async function fixture(generate = async () => output()) {
14
+ const state = {
15
+ messages: [message('user', 'user', 'Виправ помилку'), message('answer', 'assistant', 'Виправлено')],
16
+ session: { id: 'session', directory: '/project', time: {}, metadata: { external: 'keep', aiworker: { note: 'keep' } } },
17
+ targets: { recap: true, suggestion: true },
18
+ gets: 0, failFresh: false, patches: [], requests: [], calls: [],
19
+ };
20
+ const server = createServer(async (request, response) => {
21
+ const url = new URL(request.url, 'http://localhost');
22
+ state.requests.push({ method: request.method, directory: url.searchParams.get('directory'), limit: url.searchParams.get('limit'), auth: request.headers['x-test-auth'] });
23
+ response.setHeader('content-type', 'application/json');
24
+ if (request.method === 'PATCH') {
25
+ const chunks = [];
26
+ for await (const chunk of request) chunks.push(chunk);
27
+ state.patches.push(JSON.parse(Buffer.concat(chunks).toString()));
28
+ response.end(JSON.stringify(state.session));
29
+ } else if (url.pathname.endsWith('/message')) {
30
+ response.end(JSON.stringify(url.searchParams.get('limit') === '1' ? state.messages.slice(-1) : state.messages));
31
+ } else {
32
+ state.gets++;
33
+ response.statusCode = state.failFresh && state.gets > 1 ? 500 : 200;
34
+ response.end(JSON.stringify(state.session));
35
+ }
36
+ });
37
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
38
+ const base = `http://127.0.0.1:${server.address().port}`;
39
+ state.base = base;
40
+ const runtime = createSessionAssistRuntime({
41
+ buildOpenCodeUrl: (route) => state.base + route,
42
+ getOpenCodeAuthHeaders: () => ({ 'x-test-auth': 'fixture' }),
43
+ getTargets: () => state.targets,
44
+ quietMs: 1,
45
+ getSmallModelService: async () => ({
46
+ describeSmallModel: async () => ({ inputCharBudget: 64_000 }),
47
+ generateSmallModelText: async (args) => { state.calls.push(args); return generate(args, state); },
48
+ }),
49
+ });
50
+ resources.push({ runtime, server });
51
+ const status = (type) => runtime.processPayload({ type: 'session.status', properties: { sessionID: 'session', status: { type } } }, '/project');
52
+ return { state, runtime, status };
53
+ }
54
+
55
+ afterEach(async () => {
56
+ for (const { runtime, server } of resources.splice(0)) {
57
+ runtime.stop();
58
+ server.closeAllConnections();
59
+ await new Promise((resolve) => server.close(resolve));
60
+ }
61
+ vi.restoreAllMocks();
62
+ });
63
+
64
+ describe('session assist runtime', () => {
65
+ it('uses bounded authenticated SDK reads and preserves metadata with an empty suggestion', async () => {
66
+ const { state, status } = await fixture(async (_args, current) => {
67
+ current.session.metadata.aiworker.concurrent = 'new';
68
+ return output();
69
+ });
70
+ status('idle');
71
+ await vi.waitFor(() => expect(state.patches).toHaveLength(1));
72
+ expect(state.requests.every((r) => r.directory === '/project' && r.auth === 'fixture')).toBe(true);
73
+ expect(state.requests.filter((r) => r.limit).map((r) => r.limit)).toEqual(['50', '1']);
74
+ expect(state.calls[0]).toMatchObject({ restrictToPreferredProvider: true, onOverflow: 'error', preferredProviderID: 'test-provider', preferredModelID: 'test-model', sessionID: 'session' });
75
+ expect(state.patches[0].metadata).toMatchObject({ external: 'keep', aiworker: {
76
+ note: 'keep', concurrent: 'new', assist: { recap: 'Зміни готові', suggestion: '', forMessageID: 'answer' },
77
+ } });
78
+ });
79
+
80
+ it('does no work with both settings off and skips child, archived, or reverted sessions', async () => {
81
+ const { state, status } = await fixture();
82
+ state.targets = { recap: false, suggestion: false };
83
+ status('idle');
84
+ await pause();
85
+ expect(state.requests).toHaveLength(0);
86
+ state.targets.recap = true;
87
+ state.session.parentID = 'parent';
88
+ status('idle');
89
+ await vi.waitFor(() => expect(state.gets).toBe(1));
90
+ delete state.session.parentID;
91
+ state.session.revert = { messageID: 'user' };
92
+ status('idle');
93
+ await vi.waitFor(() => expect(state.gets).toBe(2));
94
+ delete state.session.revert;
95
+ state.session.time.archived = 1;
96
+ status('idle');
97
+ await vi.waitFor(() => expect(state.gets).toBe(3));
98
+ expect(state.calls).toHaveLength(0);
99
+ });
100
+
101
+ it('keeps recent context for recap-only and performs no write for an empty suggestion-only result', async () => {
102
+ const { state, status } = await fixture();
103
+ state.targets.suggestion = false;
104
+ state.messages.unshift(message('previous-user', 'user', 'Попередня задача'), message('previous-answer', 'assistant', 'Зміст зробленого', { parentID: 'previous-user' }));
105
+ status('idle');
106
+ await vi.waitFor(() => expect(state.patches).toHaveLength(1));
107
+ expect(state.calls[0].prompt).toContain('Зміст зробленого');
108
+ expect(state.calls[0].system).not.toContain('suggestion');
109
+ state.targets = { recap: false, suggestion: true };
110
+ status('idle');
111
+ await vi.waitFor(() => expect(state.calls).toHaveLength(2));
112
+ await pause();
113
+ expect(state.patches).toHaveLength(1);
114
+ });
115
+
116
+ it('does not write a stale result when the tail moves during generation', async () => {
117
+ const { state, status } = await fixture(async (_args, current) => {
118
+ current.messages.push(message('new-user', 'user', 'Нова задача'));
119
+ return output();
120
+ });
121
+ status('idle');
122
+ await vi.waitFor(() => expect(state.requests.some((r) => r.limit === '1')).toBe(true));
123
+ await pause();
124
+ expect(state.patches).toHaveLength(0);
125
+ });
126
+
127
+ it('does not fall back to stale metadata when the fresh read fails', async () => {
128
+ const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
129
+ const { state, status } = await fixture();
130
+ state.failFresh = true;
131
+ status('idle');
132
+ await vi.waitFor(() => expect(warning).toHaveBeenCalled());
133
+ expect(state.gets).toBe(2);
134
+ expect(state.patches).toHaveLength(0);
135
+ });
136
+
137
+ it('cancels old work and retains an expired newer idle timer until it can run', async () => {
138
+ const releases = [];
139
+ const { state, status } = await fixture(() => new Promise((resolve) => releases.push(resolve)));
140
+ status('idle');
141
+ await vi.waitFor(() => expect(releases).toHaveLength(1));
142
+ status('busy');
143
+ expect(state.calls[0].signal.aborted).toBe(true);
144
+ state.messages.push(message('next-user', 'user', 'Далі'), message('next-answer', 'assistant', 'Готово', { parentID: 'next-user' }));
145
+ status('idle');
146
+ await pause();
147
+ releases[0](output('Старий результат'));
148
+ await vi.waitFor(() => expect(releases).toHaveLength(2));
149
+ expect(state.patches).toHaveLength(0);
150
+ releases[1](output('Новий результат'));
151
+ await vi.waitFor(() => expect(state.patches).toHaveLength(1));
152
+ expect(state.patches[0].metadata.aiworker.assist).toMatchObject({ recap: 'Новий результат', forMessageID: 'next-answer' });
153
+ });
154
+
155
+ it('aborts on stop and honors settings switched off during generation', async () => {
156
+ let release;
157
+ const { state, runtime, status } = await fixture(() => new Promise((resolve) => { release = resolve; }));
158
+ status('idle');
159
+ await vi.waitFor(() => expect(state.calls).toHaveLength(1));
160
+ runtime.stop();
161
+ expect(state.calls[0].signal.aborted).toBe(true);
162
+ release(output());
163
+ await pause();
164
+ expect(state.patches).toHaveLength(0);
165
+ const second = await fixture(async (_args, current) => {
166
+ current.targets = { recap: false, suggestion: false };
167
+ return output();
168
+ });
169
+ second.status('idle');
170
+ await vi.waitFor(() => expect(second.state.gets).toBe(2));
171
+ await pause();
172
+ expect(second.state.patches).toHaveLength(0);
173
+ });
174
+
175
+ it('ignores historical user updates but cancels a new request during generation', async () => {
176
+ let release;
177
+ const { state, runtime, status } = await fixture(() => new Promise((resolve) => { release = resolve; }));
178
+ status('idle');
179
+ await vi.waitFor(() => expect(state.calls).toHaveLength(1));
180
+ const userUpdate = (created) => runtime.processPayload({ type: 'message.updated', properties: {
181
+ info: { id: 'user', sessionID: 'session', role: 'user', time: { created } },
182
+ } });
183
+ userUpdate(1);
184
+ expect(state.calls[0].signal.aborted).toBe(false);
185
+ userUpdate(Date.now());
186
+ expect(state.calls[0].signal.aborted).toBe(true);
187
+ release(output());
188
+ await pause();
189
+ expect(state.patches).toHaveLength(0);
190
+ });
191
+
192
+ it('rejects endpoint changes before writing instead of carrying a session into a new runtime', async () => {
193
+ const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
194
+ const { state, status } = await fixture(async (_args, current) => {
195
+ current.base = 'http://unreachable.invalid';
196
+ return output();
197
+ });
198
+ status('idle');
199
+ await vi.waitFor(() => expect(warning).toHaveBeenCalled());
200
+ expect(state.patches).toHaveLength(0);
201
+ expect(state.gets).toBe(1);
202
+ });
203
+ });
@@ -86,11 +86,21 @@ its current hunk index while visible; regeneration remains user-initiated. The
86
86
  content-addressed cache continues to reuse an old review only when its hunks
87
87
  match, and otherwise reports stale anchors and uncovered current hunks.
88
88
 
89
- The panel offers the current branch's pull request on its own: it registers with
90
- the shared GitHub PR status store (`useGitHubPrStatusStore`) rather than waiting
91
- for the pull request panel to have been visited. That store already dedupes
92
- concurrent requests by signature and throttles by TTL, so several panels asking
93
- the same question produce one call to GitHub.
89
+ PR mode uses the same searchable, paginated selector as Changes. Its list loads
90
+ only while PR mode is visible. Selection ownership and handoff rules are in
91
+ `packages/ui/src/stores/DOCUMENTATION.md`.
92
+
93
+ PR sources may include `sourceRepo: { owner, repo }`. This qualifies both the
94
+ GitHub request and the cache/job key as `pr:<owner>/<repo>:<number>`. Existing
95
+ number-only sources retain `pr:<number>` and resolve the directory's repository.
96
+ The PR panel forwards its resolved repository when opening walkthrough.
97
+
98
+ `GET /api/walkthrough/pr-diff` accepts `directory` and a JSON `source` restricted
99
+ to PRs. It returns GitHub's complete published diff as text, with no model
100
+ readiness checks or generation. Successful empty patches return 200; auth,
101
+ GitHub and malformed-response failures remain errors. Walkthrough generation
102
+ keeps its existing empty-diff refusal. UI comparison behavior is documented in
103
+ `packages/ui/src/components/views/DOCUMENTATION.md`.
94
104
 
95
105
  ## No truncation
96
106
 
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseSource, sourceKey, loadSourceSections } from './sources.js';
3
+
4
+ describe('repository-qualified PR sources', () => {
5
+ it('keeps old cache keys and separates equal PR numbers in different repositories', () => {
6
+ expect(sourceKey(parseSource({ kind: 'pr', number: 42 }))).toBe('pr:42');
7
+ const upstream = parseSource({ kind: 'pr', number: 42, sourceRepo: { owner: 'upstream', repo: 'project' } });
8
+ const fork = parseSource({ kind: 'pr', number: 42, sourceRepo: { owner: 'fork', repo: 'project' } });
9
+ expect(sourceKey(upstream)).not.toBe(sourceKey(fork));
10
+ });
11
+
12
+ it('hands the selected repository to the walkthrough diff loader', async () => {
13
+ const source = parseSource({ kind: 'pr', number: 42, sourceRepo: { owner: 'upstream', repo: 'project' } });
14
+ let received;
15
+ await loadSourceSections('/repo', source, { getPullRequestDiff: async (...args) => {
16
+ received = args;
17
+ return { patch: 'published patch', meta: {} };
18
+ } });
19
+ expect(received).toEqual(['/repo', 42, source.sourceRepo]);
20
+ });
21
+ });
@@ -1,4 +1,5 @@
1
1
  import { getOctokitOrNull } from '../github/octokit.js';
2
+ import assert from 'node:assert/strict';
2
3
  import { resolveGitHubRepoFromDirectory } from '../github/repo/index.js';
3
4
 
4
5
  /**
@@ -8,7 +9,7 @@ import { resolveGitHubRepoFromDirectory } from '../github/repo/index.js';
8
9
  * three-dot semantics used for local branch reviews: work merged in from the
9
10
  * base branch is not part of it.
10
11
  */
11
- export async function getPullRequestDiff(directory, number) {
12
+ export async function getPullRequestDiff(directory, number, sourceRepo, { allowEmpty = false } = {}) {
12
13
  const octokit = getOctokitOrNull();
13
14
  if (!octokit) {
14
15
  throw Object.assign(new Error('Connect a GitHub account to review pull requests'), {
@@ -19,7 +20,8 @@ export async function getPullRequestDiff(directory, number) {
19
20
 
20
21
  // The resolver returns `{ repo, remoteUrl }`, not the repo itself. Reading
21
22
  // `.owner` off the wrapper made this check fail for every repository.
22
- const { repo } = await resolveGitHubRepoFromDirectory(directory);
23
+ const resolved = sourceRepo ? { repo: sourceRepo } : await resolveGitHubRepoFromDirectory(directory);
24
+ const { repo } = resolved;
23
25
  if (!repo?.owner || !repo?.repo) {
24
26
  throw Object.assign(new Error('This directory has no GitHub remote'), {
25
27
  statusCode: 400,
@@ -34,8 +36,9 @@ export async function getPullRequestDiff(directory, number) {
34
36
  headers: { accept: 'application/vnd.github.v3.diff' },
35
37
  });
36
38
 
37
- const patch = typeof response?.data === 'string' ? response.data : '';
38
- if (!patch.trim()) {
39
+ assert.match(response.data, /^(?:diff --git |\s*$)/, 'GitHub returned an invalid pull request diff');
40
+ const patch = response.data;
41
+ if (!allowEmpty && !patch.trim()) {
39
42
  throw Object.assign(new Error(`Pull request #${number} has no diff`), {
40
43
  statusCode: 404,
41
44
  code: 'empty-diff',
@@ -56,6 +56,22 @@ describe('getPullRequestDiff', () => {
56
56
  expect(request).not.toHaveBeenCalled();
57
57
  });
58
58
 
59
+ it('uses the selected upstream repository rather than the local fork', async () => {
60
+ const result = await getPullRequestDiff('/repo', 42, { owner: 'upstream', repo: 'project' });
61
+ expect(result.meta).toEqual({ owner: 'upstream', repo: 'project', number: 42 });
62
+ expect(resolveGitHubRepoFromDirectory).not.toHaveBeenCalled();
63
+ expect(request).toHaveBeenCalledWith('GET /repos/{owner}/{repo}/pulls/{pull_number}', {
64
+ owner: 'upstream', repo: 'project', pull_number: 42, headers: { accept: 'application/vnd.github.v3.diff' },
65
+ });
66
+ });
67
+
68
+ it('allows an empty comparison but rejects malformed GitHub bodies', async () => {
69
+ request.mockResolvedValue({ data: '' });
70
+ expect((await getPullRequestDiff('/repo', 42, undefined, { allowEmpty: true })).patch).toBe('');
71
+ request.mockResolvedValue({ data: { message: 'Not a diff' } });
72
+ await expect(getPullRequestDiff('/repo', 42)).rejects.toThrow();
73
+ });
74
+
59
75
  it('asks the user to connect GitHub before anything else', async () => {
60
76
  getOctokitOrNull.mockReturnValue(null);
61
77
 
@@ -1,3 +1,5 @@
1
+ import { parseSource } from './sources.js';
2
+
1
3
  // `req.destroyed` is true for every healthy request once the body parser has
2
4
  // consumed the stream, so using it as a disconnect check silently swallows every
3
5
  // response. The response socket is the one that actually reflects whether the
@@ -51,6 +53,23 @@ export function registerWalkthroughRoutes(app, { getWalkthroughService }) {
51
53
  }
52
54
  });
53
55
 
56
+ // The comparison view needs the complete published patch, without model
57
+ // readiness checks, generated-file filtering, or local working-tree reads.
58
+ app.get('/api/walkthrough/pr-diff', async (req, res) => {
59
+ try {
60
+ const query = new URL(req.originalUrl, 'http://localhost').searchParams;
61
+ const directory = query.get('directory')?.trim() ?? '';
62
+ if (!directory) return res.status(400).json({ error: 'directory parameter is required' });
63
+ const source = parseSource(readSource(query.get('source')));
64
+ if (source.kind !== 'pr') return res.status(400).json({ error: 'A pull request source is required' });
65
+ const { getPullRequestDiff } = await getWalkthroughService();
66
+ const { patch } = await getPullRequestDiff(directory, source.number, source.sourceRepo, { allowEmpty: true });
67
+ res.type('text/plain').send(patch);
68
+ } catch (error) {
69
+ respondWithError(res, error, 'Failed to load pull request diff');
70
+ }
71
+ });
72
+
54
73
  // Deliberately not aborted when the client disconnects: generation runs for
55
74
  // minutes and a refresh must not throw the work away. Leaving detaches the
56
75
  // client; the job finishes and caches its result. Stopping is an explicit
@@ -18,6 +18,11 @@ describe('walkthrough routes', () => {
18
18
  let generateCalls = 0;
19
19
 
20
20
  const service = {
21
+ async getPullRequestDiff(directory, number, sourceRepo, options) {
22
+ lastArgs = { directory, number, sourceRepo, options };
23
+ if (number === 99) throw Object.assign(new Error('GitHub unavailable'), { statusCode: 503 });
24
+ return { patch: number === 1 ? '' : 'diff --git a/a.ts b/a.ts\n' };
25
+ },
21
26
  async getWalkthrough(args) {
22
27
  lastArgs = args;
23
28
  return { walkthrough: null, hunks: [], hunkCount: 0, generating: Boolean(job) };
@@ -84,6 +89,27 @@ describe('walkthrough routes', () => {
84
89
  expect(body.walkthrough).toEqual({ title: 'DONE' });
85
90
  });
86
91
 
92
+ it('serves the published PR snapshot with its repository, without generating', async () => {
93
+ const source = { kind: 'pr', number: 42, sourceRepo: { owner: 'upstream', repo: 'project' } };
94
+ const before = generateCalls;
95
+ const response = await fetch(`${base}/api/walkthrough/pr-diff?directory=/repo&source=${encodeURIComponent(JSON.stringify(source))}`);
96
+ expect(response.headers.get('content-type')).toContain('text/plain');
97
+ expect(await response.text()).toBe('diff --git a/a.ts b/a.ts\n');
98
+ expect(lastArgs).toEqual({ directory: '/repo', number: 42, sourceRepo: source.sourceRepo, options: { allowEmpty: true } });
99
+ expect(generateCalls).toBe(before);
100
+ });
101
+
102
+ it('distinguishes empty PRs, upstream failure, and invalid sources', async () => {
103
+ const request = (source) => fetch(`${base}/api/walkthrough/pr-diff?directory=/repo&source=${encodeURIComponent(JSON.stringify(source))}`);
104
+ const empty = await request({ kind: 'pr', number: 1 });
105
+ expect(empty.status).toBe(200);
106
+ expect(await empty.text()).toBe('');
107
+ expect((await request({ kind: 'pr', number: 99 })).status).toBe(503);
108
+ for (const source of [{ kind: 'pr', number: -1 }, { kind: 'branch', baseRef: 'main', headRef: 'feature' }, { kind: 'pr', number: 1, sourceRepo: { owner: '../bad', repo: 'repo' } }]) {
109
+ expect((await request(source)).status).toBe(400);
110
+ }
111
+ });
112
+
87
113
  it('delivers the result to a client that reconnected after a refresh', async () => {
88
114
  const controller = new AbortController();
89
115
  const seen = generateCalls;
@@ -1,4 +1,5 @@
1
1
  import { getDiff, getRangeDiff, getCommitDiff, getUntrackedDiffs, listUntrackedPaths } from '../git/service.js';
2
+ import assert from 'node:assert/strict';
2
3
 
3
4
  // A walkthrough source resolves to one or more diff *sections*. A section is a
4
5
  // patch plus the scope its hunk ids live in; keeping staged and working-tree
@@ -45,6 +46,16 @@ export function parseSource(raw) {
45
46
  if (!Number.isInteger(number) || number <= 0) {
46
47
  throw new WalkthroughSourceError('pr sources require a positive number');
47
48
  }
49
+ if (raw.sourceRepo !== undefined) {
50
+ const { owner, repo } = raw.sourceRepo ?? {};
51
+ try {
52
+ assert.match(owner, /^[a-zA-Z0-9-]+$/);
53
+ assert.match(repo, /^[a-zA-Z0-9_.-]+$/);
54
+ } catch {
55
+ throw new WalkthroughSourceError('pr sources require a valid repository');
56
+ }
57
+ return { kind: 'pr', number, sourceRepo: { owner, repo } };
58
+ }
48
59
  return { kind: 'pr', number };
49
60
  }
50
61
 
@@ -71,7 +82,7 @@ export function sourceKey(source) {
71
82
  if (source.kind === 'working-tree') return `working-tree:${source.scope}`;
72
83
  if (source.kind === 'branch') return `branch:${source.baseRef}...${source.headRef}`;
73
84
  if (source.kind === 'commit') return `commit:${source.hash}`;
74
- return `pr:${source.number}`;
85
+ return source.sourceRepo ? `pr:${source.sourceRepo.owner}/${source.sourceRepo.repo}:${source.number}` : `pr:${source.number}`;
75
86
  }
76
87
 
77
88
  // `git diff` never reports untracked files, so a brand-new file would be
@@ -129,7 +140,7 @@ export async function loadSourceSections(directory, source, { getPullRequestDiff
129
140
  throw new WalkthroughSourceError('Pull request diffs are unavailable', 500);
130
141
  }
131
142
 
132
- const { patch, meta } = await getPullRequestDiff(directory, source.number);
143
+ const { patch, meta } = await getPullRequestDiff(directory, source.number, source.sourceRepo);
133
144
  return {
134
145
  sections: patch && patch.trim() ? [{ scope: `pr:${source.number}`, patch }] : [],
135
146
  meta: meta || {},
@@ -0,0 +1,130 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import express from 'express';
3
+ import path from 'node:path';
4
+ import { registerOpenCodeProxy } from './lib/opencode/proxy.js';
5
+
6
+ const servers = [];
7
+ const listen = async (app) => {
8
+ const server = await new Promise((resolve) => {
9
+ const listening = app.listen(0, '127.0.0.1', () => resolve(listening));
10
+ });
11
+ servers.push(server);
12
+ return `http://127.0.0.1:${server.address().port}`;
13
+ };
14
+
15
+ afterEach(async () => {
16
+ await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => {
17
+ server.closeAllConnections();
18
+ server.close(resolve);
19
+ })));
20
+ });
21
+
22
+ const worktree = '/repo/worktree with spaces';
23
+ const projectConfig = { mcp: { projectOnly: { type: 'local', command: ['test-mcp'], enabled: false } } };
24
+
25
+ async function createFixture({ timeoutMs = 1000 } = {}) {
26
+ let bootstrap = { status: 'pending', phase: 'directory-created', error: null };
27
+ let instance;
28
+ const forwarded = [];
29
+ const probed = [];
30
+ const upstream = express();
31
+ upstream.use(express.json());
32
+ upstream.use((req, res) => {
33
+ forwarded.push(req.path);
34
+ // Model OpenCode's instance cache: even a GET can permanently capture the
35
+ // empty directory, and a later session inherits that project's identity.
36
+ instance ??= bootstrap.phase === 'directory-created'
37
+ ? { projectID: 'global', config: {} }
38
+ : { projectID: 'project-3464', config: projectConfig };
39
+ if (req.path === '/config') return res.json(instance.config);
40
+ if (req.path === '/session' && req.method === 'POST') {
41
+ return res.json({ projectID: instance.projectID, directory: req.query.directory, title: req.body.title });
42
+ }
43
+ res.json([]);
44
+ });
45
+ const upstreamUrl = await listen(upstream);
46
+ const app = express();
47
+ app.use(express.json());
48
+ registerOpenCodeProxy(app, {
49
+ fs: {}, os: {}, path,
50
+ OPEN_CODE_READY_GRACE_MS: 0,
51
+ WORKTREE_READY_TIMEOUT_MS: timeoutMs,
52
+ getRuntime: () => ({ openCodePort: new URL(upstreamUrl).port, isOpenCodeReady: true }),
53
+ getOpenCodeAuthHeaders: () => ({}),
54
+ buildOpenCodeUrl: (requestPath) => `${upstreamUrl}${requestPath}`,
55
+ ensureOpenCodeApiPrefix: () => {},
56
+ readWorktreeBootstrapStatus: async (directory) => {
57
+ probed.push(directory);
58
+ return directory === worktree ? bootstrap : { status: 'ready', phase: 'setup-ready' };
59
+ },
60
+ });
61
+ return {
62
+ url: await listen(app), forwarded, probed,
63
+ setBootstrap: (next) => { bootstrap = next; },
64
+ };
65
+ }
66
+
67
+ describe('issue #3464 worktree OpenCode initialization', () => {
68
+ it('holds config and session creation until Git is ready, while setup remains pending', async () => {
69
+ const fixture = await createFixture();
70
+ const config = fetch(`${fixture.url}/api/config?directory=${encodeURIComponent(worktree)}`);
71
+ const session = fetch(`${fixture.url}/api/session?directory=${encodeURIComponent(worktree)}`, {
72
+ method: 'POST', headers: { 'content-type': 'application/json' },
73
+ body: JSON.stringify({ title: 'First worktree session' }),
74
+ });
75
+ await expect.poll(() => fixture.probed.length).toBeGreaterThanOrEqual(2);
76
+ expect(fixture.forwarded).toEqual([]);
77
+ fixture.setBootstrap({ status: 'pending', phase: 'git-ready', error: null });
78
+ expect(await (await config).json()).toEqual(projectConfig);
79
+ expect(await (await session).json()).toEqual({
80
+ projectID: 'project-3464', directory: worktree, title: 'First worktree session',
81
+ });
82
+ });
83
+
84
+ it('gates encoded directory headers and the separately forwarded session list', async () => {
85
+ const fixture = await createFixture();
86
+ const response = fetch(`${fixture.url}/api/session`, { headers: {
87
+ 'x-opencode-directory': encodeURIComponent(worktree), 'x-opencode-directory-encoding': 'uri',
88
+ } });
89
+ await expect.poll(() => fixture.probed.length).toBeGreaterThan(0);
90
+ expect(fixture.probed[0]).toBe(worktree);
91
+ expect(fixture.forwarded).toEqual([]);
92
+ fixture.setBootstrap({ status: 'ready', phase: 'setup-ready', error: null });
93
+ expect((await response).status).toBe(200);
94
+ expect(fixture.forwarded).toEqual(['/session']);
95
+ });
96
+
97
+ it('does not block unrelated directories while a worktree is pending', async () => {
98
+ const fixture = await createFixture();
99
+ expect((await fetch(`${fixture.url}/api/session?directory=/other-project`)).status).toBe(200);
100
+ expect(fixture.probed).toEqual(['/other-project']);
101
+ });
102
+
103
+ it('does not forward an abandoned request when checkout later finishes', async () => {
104
+ const fixture = await createFixture();
105
+ const controller = new AbortController();
106
+ const response = fetch(`${fixture.url}/api/config?directory=${encodeURIComponent(worktree)}`, {
107
+ signal: controller.signal,
108
+ });
109
+ const rejected = expect(response).rejects.toThrow();
110
+ await expect.poll(() => fixture.probed.length).toBeGreaterThan(0);
111
+ controller.abort();
112
+ await rejected;
113
+ // Allow the server to observe the closed client before releasing checkout.
114
+ await new Promise((resolve) => setTimeout(resolve, 100));
115
+ fixture.setBootstrap({ status: 'ready', phase: 'setup-ready', error: null });
116
+ await new Promise((resolve) => setTimeout(resolve, 100));
117
+ expect(fixture.forwarded).toEqual([]);
118
+ });
119
+
120
+ it('fails closed on bootstrap failure or timeout without initializing OpenCode', async () => {
121
+ const fixture = await createFixture({ timeoutMs: 0 });
122
+ const timedOut = await fetch(`${fixture.url}/api/config?directory=${encodeURIComponent(worktree)}`);
123
+ expect(timedOut.status).toBe(503);
124
+ fixture.setBootstrap({ status: 'failed', phase: 'directory-created', error: 'checkout failed' });
125
+ const failed = await fetch(`${fixture.url}/api/session?directory=${encodeURIComponent(worktree)}`, { method: 'POST' });
126
+ expect(failed.status).toBe(503);
127
+ expect(await failed.json()).toEqual({ error: 'checkout failed' });
128
+ expect(fixture.forwarded).toEqual([]);
129
+ });
130
+ });