@cnrai/pave 0.3.33 → 0.3.34
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.
- package/MARKETPLACE.md +406 -0
- package/README.md +218 -21
- package/build-binary.js +591 -0
- package/build-npm.js +537 -0
- package/build.js +230 -0
- package/check-binary.js +26 -0
- package/deploy.sh +95 -0
- package/index.js +5775 -0
- package/lib/agent-registry.js +1037 -0
- package/lib/args-parser.js +837 -0
- package/lib/blessed-widget-patched.js +93 -0
- package/lib/cli-markdown.js +590 -0
- package/lib/compaction.js +153 -0
- package/lib/duration.js +94 -0
- package/lib/hash.js +22 -0
- package/lib/marketplace.js +866 -0
- package/lib/memory-config.js +166 -0
- package/lib/skill-manager.js +891 -0
- package/lib/soul.js +31 -0
- package/lib/tool-output-formatter.js +180 -0
- package/package.json +35 -32
- package/start-pave.sh +149 -0
- package/status.js +271 -0
- package/test/abort-stream.test.js +445 -0
- package/test/agent-auto-compaction.test.js +552 -0
- package/test/agent-comm-abort.test.js +95 -0
- package/test/agent-comm.test.js +598 -0
- package/test/agent-inbox.test.js +576 -0
- package/test/agent-init.test.js +264 -0
- package/test/agent-interrupt.test.js +314 -0
- package/test/agent-lifecycle.test.js +520 -0
- package/test/agent-log-files.test.js +349 -0
- package/test/agent-mode.manual-test.js +392 -0
- package/test/agent-parsing.test.js +228 -0
- package/test/agent-post-stream-idle.test.js +762 -0
- package/test/agent-registry.test.js +359 -0
- package/test/agent-rm.test.js +442 -0
- package/test/agent-spawn.test.js +933 -0
- package/test/agent-status-api.test.js +624 -0
- package/test/agent-update.test.js +435 -0
- package/test/args-parser.test.js +391 -0
- package/test/auto-compaction-chat.manual-test.js +227 -0
- package/test/auto-compaction.test.js +941 -0
- package/test/build-config.test.js +120 -0
- package/test/build-npm.test.js +388 -0
- package/test/chat-command.test.js +137 -0
- package/test/chat-leading-lines.test.js +159 -0
- package/test/config-flag.test.js +272 -0
- package/test/cursor-drift.test.js +135 -0
- package/test/debug-require.js +23 -0
- package/test/dir-migration.test.js +323 -0
- package/test/duration.test.js +229 -0
- package/test/ghostty-term.test.js +202 -0
- package/test/http500-backoff.test.js +854 -0
- package/test/integration.test.js +86 -0
- package/test/memory-guard-env.test.js +220 -0
- package/test/pr233-fixes.test.js +259 -0
- package/test/run-agent-init.js +297 -0
- package/test/run-all.js +64 -0
- package/test/run-config-flag.js +159 -0
- package/test/run-cursor-drift.js +82 -0
- package/test/run-session-path.js +154 -0
- package/test/run-tests.js +643 -0
- package/test/sandbox-redirect.test.js +202 -0
- package/test/session-path.test.js +132 -0
- package/test/shebang-strip.test.js +241 -0
- package/test/soul-reinject.test.js +1027 -0
- package/test/soul-reread.test.js +281 -0
- package/test/tool-output-formatter.test.js +486 -0
- package/test/tool-output-gating.test.js +143 -0
- package/test/tool-states.test.js +167 -0
- package/test/tools-flag.test.js +65 -0
- package/test/tui-attach.test.js +1255 -0
- package/test/tui-compaction.test.js +354 -0
- package/test/tui-wrap.test.js +568 -0
- package/test-binary.js +52 -0
- package/test-binary2.js +36 -0
- package/LICENSE +0 -21
- package/pave.js +0 -3
- package/sandbox/SandboxRunner.js +0 -1
- package/sandbox/pave-run.js +0 -2
- package/sandbox/permission.js +0 -1
- package/sandbox/utils/yaml.js +0 -1
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
// Tests for Issue #249: Ctrl+U abort signal wiring to LLM stream
|
|
2
|
+
// Verifies that abort signal is properly passed through the call chain
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const assert = require('assert');
|
|
7
|
+
|
|
8
|
+
let passed = 0;
|
|
9
|
+
let failed = 0;
|
|
10
|
+
|
|
11
|
+
function runTest(name, fn) {
|
|
12
|
+
try {
|
|
13
|
+
fn();
|
|
14
|
+
console.log('\u2705 ' + name);
|
|
15
|
+
passed++;
|
|
16
|
+
} catch (e) {
|
|
17
|
+
console.log('\u274C ' + name + ': ' + e.message);
|
|
18
|
+
failed++;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ============================================================
|
|
23
|
+
// Source inspection tests: verify signal wiring is in place
|
|
24
|
+
// ============================================================
|
|
25
|
+
|
|
26
|
+
const handlerSource = fs.readFileSync(
|
|
27
|
+
path.join(__dirname, '..', '..', 'opencode-lite', 'src', 'prompt', 'handler.js'), 'utf8');
|
|
28
|
+
const providerSource = fs.readFileSync(
|
|
29
|
+
path.join(__dirname, '..', '..', 'opencode-lite', 'src', 'provider', 'index.js'), 'utf8');
|
|
30
|
+
|
|
31
|
+
// --- handler.js tests ---
|
|
32
|
+
|
|
33
|
+
runTest('handler.js: passes signal to streamResponse()', () => {
|
|
34
|
+
// Find the streamResponse call and verify it includes signal
|
|
35
|
+
const streamCallIdx = handlerSource.indexOf('response = await streamResponse({');
|
|
36
|
+
assert(streamCallIdx !== -1, 'should find streamResponse call');
|
|
37
|
+
const callSection = handlerSource.substring(streamCallIdx, streamCallIdx + 500);
|
|
38
|
+
// Match shorthand `signal,` or explicit `signal: signal`
|
|
39
|
+
assert(callSection.indexOf('signal') !== -1,
|
|
40
|
+
'streamResponse() call should include signal parameter');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
runTest('handler.js: breaks on aborted finish_reason', () => {
|
|
44
|
+
const pattern = /finishReason\s*===\s*["']aborted["']/;
|
|
45
|
+
assert(pattern.test(handlerSource),
|
|
46
|
+
'should check for "aborted" finish reason and break the iteration loop');
|
|
47
|
+
assert(handlerSource.indexOf('LLM stream was aborted') !== -1,
|
|
48
|
+
'should log when stream is aborted');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
runTest('handler.js: checks signal in executeOneTool', () => {
|
|
52
|
+
// Find executeOneTool function
|
|
53
|
+
const fnIdx = handlerSource.indexOf('async function executeOneTool');
|
|
54
|
+
assert(fnIdx !== -1, 'should find executeOneTool function');
|
|
55
|
+
const fnSection = handlerSource.substring(fnIdx, fnIdx + 300);
|
|
56
|
+
assert(fnSection.indexOf('signal && signal.aborted') !== -1,
|
|
57
|
+
'executeOneTool should check signal.aborted before executing');
|
|
58
|
+
assert(fnSection.indexOf('Tool execution skipped') !== -1,
|
|
59
|
+
'should return skip message when aborted');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// --- provider/index.js tests ---
|
|
63
|
+
|
|
64
|
+
runTest('provider: processStreamingResponse accepts signal parameter', () => {
|
|
65
|
+
const pattern = /function\s+processStreamingResponse\s*\([^)]*signal[^)]*\)/;
|
|
66
|
+
assert(pattern.test(providerSource),
|
|
67
|
+
'processStreamingResponse should accept signal as a parameter');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
runTest('provider: processStreamingResponse checks signal.aborted before starting', () => {
|
|
71
|
+
// Find processStreamingResponse function
|
|
72
|
+
const fnIdx = providerSource.indexOf('async function processStreamingResponse');
|
|
73
|
+
assert(fnIdx !== -1, 'should find processStreamingResponse');
|
|
74
|
+
const fnSection = providerSource.substring(fnIdx, fnIdx + 2500);
|
|
75
|
+
assert(fnSection.indexOf("signal && signal.aborted") !== -1,
|
|
76
|
+
'should check signal.aborted early');
|
|
77
|
+
assert(fnSection.indexOf("finish_reason: 'aborted'") !== -1,
|
|
78
|
+
'should return aborted finish_reason when signal is aborted');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
runTest('provider: processStreamingResponse listens for abort event', () => {
|
|
82
|
+
const fnIdx = providerSource.indexOf('async function processStreamingResponse');
|
|
83
|
+
const fnSection = providerSource.substring(fnIdx, fnIdx + 3000);
|
|
84
|
+
assert(fnSection.indexOf("signal.addEventListener('abort'") !== -1,
|
|
85
|
+
'should add event listener for abort signal');
|
|
86
|
+
assert(fnSection.indexOf("signal.removeEventListener('abort'") !== -1,
|
|
87
|
+
'should clean up event listener to prevent memory leaks');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
runTest('provider: processStreamingResponse destroys reader on abort', () => {
|
|
91
|
+
const fnIdx = providerSource.indexOf('async function processStreamingResponse');
|
|
92
|
+
const fnSection = providerSource.substring(fnIdx, fnIdx + 3000);
|
|
93
|
+
// The onAbort handler should destroy the reader
|
|
94
|
+
const abortHandlerIdx = fnSection.indexOf('onAbort = function ()');
|
|
95
|
+
assert(abortHandlerIdx !== -1, 'should have onAbort handler');
|
|
96
|
+
const abortSection = fnSection.substring(abortHandlerIdx, abortHandlerIdx + 500);
|
|
97
|
+
assert(abortSection.indexOf('reader.destroy()') !== -1,
|
|
98
|
+
'abort handler should destroy the reader stream');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
runTest('provider: streamGitHubCopilotResponseInternal early abort return', () => {
|
|
102
|
+
const fnIdx = providerSource.indexOf('function streamGitHubCopilotResponseInternal');
|
|
103
|
+
assert(fnIdx !== -1, 'should find streamGitHubCopilotResponseInternal');
|
|
104
|
+
const fnSection = providerSource.substring(fnIdx, fnIdx + 400);
|
|
105
|
+
assert(fnSection.indexOf('opts.signal && opts.signal.aborted') !== -1,
|
|
106
|
+
'should check for already-aborted signal before making fetch call');
|
|
107
|
+
assert(fnSection.indexOf("finish_reason: 'aborted'") !== -1,
|
|
108
|
+
'should return aborted result when signal is already aborted');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
runTest('provider: fetch() receives signal option', () => {
|
|
112
|
+
// Find the fetch call in streamGitHubCopilotResponseInternal
|
|
113
|
+
const fetchIdx = providerSource.indexOf("return fetch(provider.baseUrl");
|
|
114
|
+
assert(fetchIdx !== -1, 'should find fetch call');
|
|
115
|
+
const fetchSection = providerSource.substring(fetchIdx, fetchIdx + 500);
|
|
116
|
+
assert(fetchSection.indexOf('signal: opts.signal') !== -1,
|
|
117
|
+
'fetch() should receive signal from opts to cancel HTTP request');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
runTest('provider: fetch AbortError is caught gracefully', () => {
|
|
121
|
+
// The .catch() handler should handle AbortError
|
|
122
|
+
const catchIdx = providerSource.indexOf("err.name === 'AbortError'");
|
|
123
|
+
assert(catchIdx !== -1, 'should catch AbortError from fetch()');
|
|
124
|
+
const catchSection = providerSource.substring(catchIdx - 50, catchIdx + 300);
|
|
125
|
+
assert(catchSection.indexOf("finish_reason: 'aborted'") !== -1,
|
|
126
|
+
'catch handler should return aborted result instead of throwing');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
runTest('provider: processStreamingResponse passes signal to fetch result handler', () => {
|
|
130
|
+
// Verify the call to processStreamingResponse includes the signal
|
|
131
|
+
const callPattern = /processStreamingResponse\s*\(\s*response\s*,\s*opts\.onTextChunk\s*,\s*opts\.signal\s*\)/;
|
|
132
|
+
assert(callPattern.test(providerSource),
|
|
133
|
+
'processStreamingResponse call should pass opts.signal as third argument');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ============================================================
|
|
137
|
+
// Functional tests with mock AbortController
|
|
138
|
+
// ============================================================
|
|
139
|
+
|
|
140
|
+
runTest('functional: AbortController signal.aborted starts false', () => {
|
|
141
|
+
// Verify AbortController works in this environment
|
|
142
|
+
if (typeof AbortController === 'undefined') {
|
|
143
|
+
console.log(' (AbortController not available, skipping)');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const ac = new AbortController();
|
|
147
|
+
assert(ac.signal.aborted === false, 'signal should start not aborted');
|
|
148
|
+
ac.abort();
|
|
149
|
+
assert(ac.signal.aborted === true, 'signal should be aborted after abort()');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
runTest('functional: abort event fires on AbortController.abort()', () => {
|
|
153
|
+
if (typeof AbortController === 'undefined') {
|
|
154
|
+
console.log(' (AbortController not available, skipping)');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const ac = new AbortController();
|
|
158
|
+
let fired = false;
|
|
159
|
+
ac.signal.addEventListener('abort', () => { fired = true; });
|
|
160
|
+
ac.abort();
|
|
161
|
+
assert(fired === true, 'abort event should fire synchronously');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ============================================================
|
|
165
|
+
// Copilot review round 1: resource cleanup tests
|
|
166
|
+
// ============================================================
|
|
167
|
+
|
|
168
|
+
runTest('provider: early-abort path destroys response body stream', () => {
|
|
169
|
+
const fnIdx = providerSource.indexOf('async function processStreamingResponse');
|
|
170
|
+
assert(fnIdx !== -1, 'should find processStreamingResponse');
|
|
171
|
+
const fnSection = providerSource.substring(fnIdx, fnIdx + 2000);
|
|
172
|
+
// Find the early abort section (signal && signal.aborted)
|
|
173
|
+
const earlyAbortIdx = fnSection.indexOf('signal && signal.aborted');
|
|
174
|
+
assert(earlyAbortIdx !== -1, 'should find early abort check');
|
|
175
|
+
const earlyAbortSection = fnSection.substring(earlyAbortIdx, earlyAbortIdx + 300);
|
|
176
|
+
// Must destroy reader before resolving to release HTTP connection
|
|
177
|
+
assert(earlyAbortSection.indexOf('reader.destroy()') !== -1,
|
|
178
|
+
'early-abort path should destroy response body stream to release socket');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
runTest('provider: onAbort handler cleans up signal listener before resolving', () => {
|
|
182
|
+
const fnIdx = providerSource.indexOf('async function processStreamingResponse');
|
|
183
|
+
const fnSection = providerSource.substring(fnIdx, fnIdx + 3000);
|
|
184
|
+
const onAbortIdx = fnSection.indexOf('onAbort = function ()');
|
|
185
|
+
assert(onAbortIdx !== -1, 'should find onAbort handler');
|
|
186
|
+
const onAbortSection = fnSection.substring(onAbortIdx, onAbortIdx + 400);
|
|
187
|
+
// cleanupSignalListener must be called inside onAbort, before resolve
|
|
188
|
+
assert(onAbortSection.indexOf('cleanupSignalListener()') !== -1,
|
|
189
|
+
'onAbort handler should call cleanupSignalListener() to prevent leak');
|
|
190
|
+
// Verify cleanup happens before resolve
|
|
191
|
+
const cleanupIdx = onAbortSection.indexOf('cleanupSignalListener()');
|
|
192
|
+
const resolveIdx = onAbortSection.indexOf('resolve(');
|
|
193
|
+
assert(cleanupIdx < resolveIdx,
|
|
194
|
+
'cleanupSignalListener should be called before resolve in onAbort');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
runTest('provider: close event also cleans up signal listener', () => {
|
|
198
|
+
const fnIdx = providerSource.indexOf('async function processStreamingResponse');
|
|
199
|
+
const fnSection = providerSource.substring(fnIdx, fnIdx + 12000);
|
|
200
|
+
// reader.destroy() emits 'close' not 'end', so we need a close handler
|
|
201
|
+
assert(fnSection.indexOf("reader.on('close'") !== -1,
|
|
202
|
+
'should listen for close event on reader');
|
|
203
|
+
// The close handler should call cleanupSignalListener
|
|
204
|
+
const closeIdx = fnSection.indexOf("reader.on('close'");
|
|
205
|
+
const closeSection = fnSection.substring(closeIdx, closeIdx + 200);
|
|
206
|
+
assert(closeSection.indexOf('cleanupSignalListener()') !== -1,
|
|
207
|
+
'close event handler should clean up signal listener');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ============================================================
|
|
211
|
+
// Integration: verify signal flow from handler to provider
|
|
212
|
+
// ============================================================
|
|
213
|
+
|
|
214
|
+
runTest('integration: handler.js signal flows to streamResponse then to fetch', () => {
|
|
215
|
+
// Trace the signal through the call chain:
|
|
216
|
+
// 1. handler.js: signal -> streamResponse({...signal})
|
|
217
|
+
// 2. provider/index.js: streamResponse -> streamGitHubCopilotResponse(opts) -> streamGitHubCopilotResponseInternal(opts)
|
|
218
|
+
// 3. streamGitHubCopilotResponseInternal: opts.signal -> fetch({signal}) AND processStreamingResponse(response, cb, signal)
|
|
219
|
+
|
|
220
|
+
// Check streamResponse passes opts through to provider
|
|
221
|
+
const streamResponseIdx = providerSource.indexOf('function streamResponse(opts)');
|
|
222
|
+
assert(streamResponseIdx !== -1, 'should find streamResponse function');
|
|
223
|
+
const streamResponseSection = providerSource.substring(streamResponseIdx, streamResponseIdx + 300);
|
|
224
|
+
// streamResponse calls streamGitHubCopilotResponse(opts) which calls streamGitHubCopilotResponseInternal(opts)
|
|
225
|
+
// so signal in opts is preserved through the chain
|
|
226
|
+
assert(streamResponseSection.indexOf('streamGitHubCopilotResponse(opts)') !== -1,
|
|
227
|
+
'streamResponse should pass full opts to provider function');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ============================================================
|
|
231
|
+
// Copilot review round 2: definition order + runtime tests
|
|
232
|
+
// ============================================================
|
|
233
|
+
|
|
234
|
+
runTest('provider: cleanupSignalListener defined before onAbort references it', () => {
|
|
235
|
+
const fnIdx = providerSource.indexOf('async function processStreamingResponse');
|
|
236
|
+
const fnSection = providerSource.substring(fnIdx, fnIdx + 3000);
|
|
237
|
+
const cleanupDefIdx = fnSection.indexOf('const cleanupSignalListener = function ()');
|
|
238
|
+
const onAbortDefIdx = fnSection.indexOf('onAbort = function ()');
|
|
239
|
+
assert(cleanupDefIdx !== -1, 'should find cleanupSignalListener definition');
|
|
240
|
+
assert(onAbortDefIdx !== -1, 'should find onAbort definition');
|
|
241
|
+
assert(cleanupDefIdx < onAbortDefIdx,
|
|
242
|
+
'cleanupSignalListener must be defined before onAbort to avoid calling undefined');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
runTest('runtime: AbortController signal + listener lifecycle simulation', () => {
|
|
246
|
+
if (typeof AbortController === 'undefined') {
|
|
247
|
+
console.log(' (AbortController not available, skipping)');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// We need to test processStreamingResponse directly
|
|
252
|
+
// It's not exported, so we'll test the behavior by requiring the module
|
|
253
|
+
// and testing through the exported streamResponse, but that requires
|
|
254
|
+
// full provider setup. Instead, let's create a mock test:
|
|
255
|
+
|
|
256
|
+
const Readable = require('stream').Readable;
|
|
257
|
+
const ac = new AbortController();
|
|
258
|
+
|
|
259
|
+
// Create a mock readable stream that emits SSE data
|
|
260
|
+
const stream = new Readable({
|
|
261
|
+
read() {}, // no-op, we push manually
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Simulate a response object with body
|
|
265
|
+
const _mockResponse = { body: stream };
|
|
266
|
+
|
|
267
|
+
// Track events
|
|
268
|
+
let destroyed = false; // eslint-disable-line no-unused-vars
|
|
269
|
+
const originalDestroy = stream.destroy.bind(stream);
|
|
270
|
+
stream.destroy = function () {
|
|
271
|
+
destroyed = true;
|
|
272
|
+
originalDestroy();
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// Load processStreamingResponse by extracting it from the module
|
|
276
|
+
// Since it's not exported, we verify the behavior through the source
|
|
277
|
+
// inspection tests above and test AbortController mechanics here
|
|
278
|
+
|
|
279
|
+
// Test: abort fires synchronously and the signal state changes
|
|
280
|
+
assert(ac.signal.aborted === false, 'signal starts not aborted');
|
|
281
|
+
|
|
282
|
+
let listenerCalled = false;
|
|
283
|
+
let listenerRemoved = false;
|
|
284
|
+
|
|
285
|
+
// Simulate what processStreamingResponse does:
|
|
286
|
+
const onAbort = function () { listenerCalled = true; };
|
|
287
|
+
const cleanupSignalListener = function () {
|
|
288
|
+
ac.signal.removeEventListener('abort', onAbort);
|
|
289
|
+
listenerRemoved = true;
|
|
290
|
+
};
|
|
291
|
+
ac.signal.addEventListener('abort', onAbort);
|
|
292
|
+
|
|
293
|
+
// Abort should fire listener synchronously
|
|
294
|
+
ac.abort();
|
|
295
|
+
assert(listenerCalled === true, 'abort listener should fire');
|
|
296
|
+
assert(ac.signal.aborted === true, 'signal should be aborted');
|
|
297
|
+
|
|
298
|
+
// Cleanup should work
|
|
299
|
+
cleanupSignalListener();
|
|
300
|
+
assert(listenerRemoved === true, 'cleanup should work after abort');
|
|
301
|
+
|
|
302
|
+
// Verify listener was actually removed (second abort doesn't re-fire)
|
|
303
|
+
listenerCalled = false;
|
|
304
|
+
// Can't abort twice, but we can verify removeEventListener was called
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
runTest('runtime: pre-aborted signal returns immediately', () => {
|
|
308
|
+
if (typeof AbortController === 'undefined') {
|
|
309
|
+
console.log(' (AbortController not available, skipping)');
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const ac = new AbortController();
|
|
314
|
+
ac.abort(); // Pre-abort
|
|
315
|
+
|
|
316
|
+
// Simulate the early-abort check from processStreamingResponse
|
|
317
|
+
let result = null;
|
|
318
|
+
if (ac.signal.aborted) {
|
|
319
|
+
result = { content: [], tool_calls: [], finish_reason: 'aborted' };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
assert(result !== null, 'should produce result immediately');
|
|
323
|
+
assert(result.finish_reason === 'aborted', 'finish_reason should be aborted');
|
|
324
|
+
assert(Array.isArray(result.content), 'content should be array');
|
|
325
|
+
assert(result.content.length === 0, 'content should be empty for pre-aborted');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
runTest('runtime: mock stream destroy + abort lifecycle', () => {
|
|
329
|
+
if (typeof AbortController === 'undefined') {
|
|
330
|
+
console.log(' (AbortController not available, skipping)');
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const Readable = require('stream').Readable;
|
|
335
|
+
const ac = new AbortController();
|
|
336
|
+
|
|
337
|
+
// Create mock stream
|
|
338
|
+
const stream = new Readable({ read() {} });
|
|
339
|
+
let destroyCalled = false;
|
|
340
|
+
const origDestroy = stream.destroy.bind(stream);
|
|
341
|
+
stream.destroy = function () { destroyCalled = true; origDestroy(); };
|
|
342
|
+
|
|
343
|
+
// Simulate the full abort handler lifecycle
|
|
344
|
+
let aborted = false;
|
|
345
|
+
const fullContent = 'partial content here';
|
|
346
|
+
let resolvedResult = null;
|
|
347
|
+
|
|
348
|
+
let onAbort = null;
|
|
349
|
+
const cleanupSignalListener = function () {
|
|
350
|
+
ac.signal.removeEventListener('abort', onAbort);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
onAbort = function () {
|
|
354
|
+
if (aborted) return;
|
|
355
|
+
aborted = true;
|
|
356
|
+
cleanupSignalListener();
|
|
357
|
+
stream.destroy();
|
|
358
|
+
resolvedResult = {
|
|
359
|
+
content: fullContent ? [{ type: 'text', text: fullContent }] : [],
|
|
360
|
+
tool_calls: [],
|
|
361
|
+
finish_reason: 'aborted',
|
|
362
|
+
};
|
|
363
|
+
};
|
|
364
|
+
ac.signal.addEventListener('abort', onAbort);
|
|
365
|
+
|
|
366
|
+
// Push some data (simulating partial stream)
|
|
367
|
+
stream.push('data: {"choices":[{"delta":{"content":"hello"}}]}\n\n');
|
|
368
|
+
|
|
369
|
+
// Abort mid-stream
|
|
370
|
+
ac.abort();
|
|
371
|
+
|
|
372
|
+
assert(destroyCalled === true, 'stream should be destroyed on abort');
|
|
373
|
+
assert(aborted === true, 'aborted flag should be set');
|
|
374
|
+
assert(resolvedResult !== null, 'should have resolved result');
|
|
375
|
+
assert(resolvedResult.finish_reason === 'aborted', 'finish_reason should be aborted');
|
|
376
|
+
assert(resolvedResult.content[0].text === 'partial content here',
|
|
377
|
+
'should preserve partial content accumulated before abort');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// ============================================================
|
|
381
|
+
// Copilot review round 3 tests
|
|
382
|
+
// ============================================================
|
|
383
|
+
|
|
384
|
+
runTest('handler.js: aborted path persists partial text before breaking', () => {
|
|
385
|
+
const abortIdx = handlerSource.indexOf("finishReason === \"aborted\"");
|
|
386
|
+
assert(abortIdx !== -1, 'should find aborted check');
|
|
387
|
+
const abortSection = handlerSource.substring(abortIdx, abortIdx + 1000);
|
|
388
|
+
// Should extract text from response.content before breaking
|
|
389
|
+
assert(abortSection.indexOf('response.content') !== -1,
|
|
390
|
+
'aborted path should access response.content to extract partial text');
|
|
391
|
+
assert(abortSection.indexOf('partManager.add') !== -1,
|
|
392
|
+
'aborted path should persist partial text via partManager.add');
|
|
393
|
+
// break should come after persisting
|
|
394
|
+
const addIdx = abortSection.indexOf('partManager.add');
|
|
395
|
+
const breakIdx = abortSection.indexOf('break', addIdx);
|
|
396
|
+
assert(addIdx !== -1 && breakIdx !== -1 && addIdx < breakIdx,
|
|
397
|
+
'partManager.add should happen before break');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
runTest('handler.js: aborted path emits message.part.updated event', () => {
|
|
401
|
+
const abortIdx = handlerSource.indexOf("finishReason === \"aborted\"");
|
|
402
|
+
assert(abortIdx !== -1, 'should find aborted check');
|
|
403
|
+
const abortSection = handlerSource.substring(abortIdx, abortIdx + 900);
|
|
404
|
+
// After partManager.add, the abort path should emit onEvent so SSE clients reconcile
|
|
405
|
+
assert(abortSection.indexOf('message.part.updated') !== -1,
|
|
406
|
+
'aborted path should emit message.part.updated event');
|
|
407
|
+
assert(abortSection.indexOf('onEvent') !== -1,
|
|
408
|
+
'aborted path should call onEvent to notify SSE clients');
|
|
409
|
+
// Verify the pattern: partManager.add stores to abortedPart, then emits it
|
|
410
|
+
assert(abortSection.indexOf('abortedPart') !== -1,
|
|
411
|
+
'should capture returned part from partManager.add as abortedPart');
|
|
412
|
+
const partAddIdx = abortSection.indexOf('partManager.add');
|
|
413
|
+
const eventIdx = abortSection.indexOf('message.part.updated');
|
|
414
|
+
assert(partAddIdx < eventIdx,
|
|
415
|
+
'partManager.add should come before onEvent emission');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
runTest('provider: log messages use generic AbortSignal wording', () => {
|
|
419
|
+
// Should NOT contain "Ctrl+U" in log messages provider layer is UI-agnostic
|
|
420
|
+
const abortLogIdx = providerSource.indexOf('Stream aborted via AbortSignal');
|
|
421
|
+
assert(abortLogIdx !== -1, 'should have generic abort log message');
|
|
422
|
+
const fetchAbortIdx = providerSource.indexOf('Fetch aborted via AbortSignal');
|
|
423
|
+
assert(fetchAbortIdx !== -1, 'should have generic fetch abort log message');
|
|
424
|
+
// Verify no Ctrl+U references remain in provider layer
|
|
425
|
+
const ctrlUCount = (providerSource.match(/Ctrl\+U/g) || []).length;
|
|
426
|
+
assert(ctrlUCount === 0, 'provider layer should not reference Ctrl+U (found ' + ctrlUCount + ')');
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
runTest('handler.js: abort comment and log are UI-agnostic', () => {
|
|
430
|
+
// The abort comment and log should not reference any specific keybinding
|
|
431
|
+
const ctrlUCount = (handlerSource.match(/Ctrl\+U/g) || []).length;
|
|
432
|
+
assert(ctrlUCount === 0, 'handler.js should not reference Ctrl+U (found ' + ctrlUCount + ')');
|
|
433
|
+
// Should use generic wording mentioning AbortSignal or request abort
|
|
434
|
+
assert(handlerSource.indexOf('via AbortSignal/request abort') !== -1,
|
|
435
|
+
'abort comment should mention AbortSignal/request abort');
|
|
436
|
+
assert(handlerSource.indexOf('aborted via request abort') !== -1,
|
|
437
|
+
'abort log should use generic request abort wording');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// ============================================================
|
|
441
|
+
// Summary
|
|
442
|
+
// ============================================================
|
|
443
|
+
|
|
444
|
+
console.log('\nTotal: ' + (passed + failed) + ', Passed: ' + passed + ', Failed: ' + failed);
|
|
445
|
+
if (failed > 0) process.exitCode = 1;
|