agentgui 1.0.388 → 1.0.390

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.388",
3
+ "version": "1.0.390",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -1447,6 +1447,56 @@ const server = http.createServer(async (req, res) => {
1447
1447
  return;
1448
1448
  }
1449
1449
 
1450
+ // POST /runs/stream - Create stateless run and stream output (MUST be before generic /runs/:id route)
1451
+ if (pathOnly === '/api/runs/stream' && req.method === 'POST') {
1452
+ const body = await parseBody(req);
1453
+ const { agent_id, input, config } = body;
1454
+ if (!agent_id) {
1455
+ sendJSON(req, res, 422, { error: 'agent_id is required' });
1456
+ return;
1457
+ }
1458
+ const agent = discoveredAgents.find(a => a.id === agent_id);
1459
+ if (!agent) {
1460
+ sendJSON(req, res, 404, { error: 'Agent not found' });
1461
+ return;
1462
+ }
1463
+ const run = queries.createRun(agent_id, null, input, config);
1464
+ const sseManager = new SSEStreamManager(res, run.run_id);
1465
+ sseManager.start();
1466
+ sseManager.sendProgress({ type: 'run_created', run_id: run.run_id });
1467
+
1468
+ const eventHandler = (eventData) => {
1469
+ if (eventData.sessionId === run.run_id || eventData.conversationId === run.thread_id) {
1470
+ if (eventData.type === 'streaming_progress' && eventData.block) {
1471
+ sseManager.sendProgress(eventData.block);
1472
+ } else if (eventData.type === 'streaming_error') {
1473
+ sseManager.sendError(eventData.error || 'Execution error');
1474
+ } else if (eventData.type === 'streaming_complete') {
1475
+ sseManager.sendComplete({ eventCount: eventData.eventCount }, { timestamp: eventData.timestamp });
1476
+ sseManager.cleanup();
1477
+ }
1478
+ }
1479
+ };
1480
+
1481
+ sseStreamHandlers.set(run.run_id, eventHandler);
1482
+ req.on('close', () => {
1483
+ sseStreamHandlers.delete(run.run_id);
1484
+ sseManager.cleanup();
1485
+ });
1486
+
1487
+ const statelessThreadId = queries.getRun(run.run_id)?.thread_id;
1488
+ if (statelessThreadId) {
1489
+ const conv = queries.getConversation(statelessThreadId);
1490
+ if (conv && input?.content) {
1491
+ runClaudeWithStreaming(agent_id, statelessThreadId, input.content, config?.model || null).catch((err) => {
1492
+ sseManager.sendError(err.message);
1493
+ sseManager.cleanup();
1494
+ });
1495
+ }
1496
+ }
1497
+ return;
1498
+ }
1499
+
1450
1500
  const oldRunByIdMatch = pathOnly.match(/^\/api\/runs\/([^/]+)$/);
1451
1501
  if (oldRunByIdMatch) {
1452
1502
  const runId = oldRunByIdMatch[1];
@@ -1918,59 +1968,6 @@ const server = http.createServer(async (req, res) => {
1918
1968
  return;
1919
1969
  }
1920
1970
 
1921
- if (pathOnly === '/api/runs/stream' && req.method === 'POST') {
1922
- const body = await parseBody(req);
1923
- const { agent_id, input, config } = body;
1924
- if (!agent_id) {
1925
- sendJSON(req, res, 422, { error: 'agent_id is required' });
1926
- return;
1927
- }
1928
- const agent = discoveredAgents.find(a => a.id === agent_id);
1929
- if (!agent) {
1930
- sendJSON(req, res, 404, { error: 'Agent not found' });
1931
- return;
1932
- }
1933
- const run = queries.createRun(agent_id, null, input, config);
1934
- const sseManager = new SSEStreamManager(res, run.run_id);
1935
- sseManager.start();
1936
- sseManager.sendProgress({ type: 'run_created', run_id: run.run_id });
1937
-
1938
- const eventHandler = (eventData) => {
1939
- if (eventData.sessionId === run.run_id || eventData.conversationId === run.thread_id) {
1940
- if (eventData.type === 'streaming_progress' && eventData.block) {
1941
- sseManager.sendProgress(eventData.block);
1942
- } else if (eventData.type === 'streaming_error') {
1943
- sseManager.sendError(eventData.error || 'Execution error');
1944
- } else if (eventData.type === 'streaming_complete') {
1945
- sseManager.sendComplete({ eventCount: eventData.eventCount }, { timestamp: eventData.timestamp });
1946
- sseManager.cleanup();
1947
- }
1948
- }
1949
- };
1950
-
1951
- sseStreamHandlers.set(run.run_id, eventHandler);
1952
- req.on('close', () => {
1953
- sseStreamHandlers.delete(run.run_id);
1954
- sseManager.cleanup();
1955
- });
1956
-
1957
- const statelessThreadId = queries.getRun(run.run_id)?.thread_id;
1958
- if (statelessThreadId) {
1959
- const conv = queries.getConversation(statelessThreadId);
1960
- if (conv && input?.content) {
1961
- const session = queries.createSession(statelessThreadId);
1962
- acpQueries.updateRunStatus(run.run_id, 'active');
1963
- activeExecutions.set(statelessThreadId, { pid: null, startTime: Date.now(), sessionId: session.id, lastActivity: Date.now() });
1964
- activeProcessesByRunId.set(run.run_id, { threadId: statelessThreadId, sessionId: session.id });
1965
- queries.setIsStreaming(statelessThreadId, true);
1966
- processMessageWithStreaming(statelessThreadId, null, session.id, input.content, agent_id, config?.model || null)
1967
- .then(() => { acpQueries.updateRunStatus(run.run_id, 'success'); activeProcessesByRunId.delete(run.run_id); })
1968
- .catch((err) => { acpQueries.updateRunStatus(run.run_id, 'error'); activeProcessesByRunId.delete(run.run_id); sseManager.sendError(err.message); sseManager.cleanup(); });
1969
- }
1970
- }
1971
- return;
1972
- }
1973
-
1974
1971
  if (pathOnly === '/api/runs/wait' && req.method === 'POST') {
1975
1972
  const body = await parseBody(req);
1976
1973
  const { agent_id, input, config } = body;
@@ -2234,15 +2231,11 @@ const server = http.createServer(async (req, res) => {
2234
2231
  }
2235
2232
 
2236
2233
  activeExecutions.delete(threadId);
2234
+ activeProcessesByRunId.delete(runId);
2237
2235
  queries.setIsStreaming(threadId, false);
2238
2236
 
2239
- broadcastSync({
2240
- type: 'streaming_cancelled',
2241
- sessionId: execution?.sessionId || runId,
2242
- conversationId: threadId,
2243
- runId: runId,
2244
- timestamp: Date.now()
2245
- });
2237
+ broadcastSync({ type: 'run_cancelled', runId, threadId, sessionId: execution?.sessionId, timestamp: Date.now() });
2238
+ broadcastSync({ type: 'streaming_cancelled', sessionId: execution?.sessionId || runId, conversationId: threadId, runId, timestamp: Date.now() });
2246
2239
 
2247
2240
  sendJSON(req, res, 200, cancelledRun);
2248
2241
  } catch (err) {
@@ -2276,9 +2269,14 @@ const server = http.createServer(async (req, res) => {
2276
2269
  const startTime = Date.now();
2277
2270
  const pollInterval = setInterval(() => {
2278
2271
  const currentRun = queries.getRun(runId);
2279
- if (!currentRun || ['success', 'error', 'cancelled'].includes(currentRun.status) || (Date.now() - startTime) > 30000) {
2272
+ const elapsed = Date.now() - startTime;
2273
+ const done = currentRun && ['success', 'error', 'cancelled'].includes(currentRun.status);
2274
+ if (done) {
2280
2275
  clearInterval(pollInterval);
2281
- sendJSON(req, res, 200, currentRun || run);
2276
+ sendJSON(req, res, 200, currentRun);
2277
+ } else if (elapsed > 30000) {
2278
+ clearInterval(pollInterval);
2279
+ sendJSON(req, res, 408, { error: 'Run still pending after 30s', run_id: runId, status: currentRun?.status || run.status });
2282
2280
  }
2283
2281
  }, 500);
2284
2282
  req.on('close', () => clearInterval(pollInterval));
@@ -3037,70 +3035,6 @@ const server = http.createServer(async (req, res) => {
3037
3035
  return;
3038
3036
  }
3039
3037
 
3040
- // POST /threads/{thread_id}/runs/{run_id}/cancel - Cancel a run on a thread
3041
- const threadRunCancelMatch = pathOnly.match(/^\/api\/threads\/([a-f0-9-]{36})\/runs\/([a-f0-9-]{36})\/cancel$/);
3042
- if (threadRunCancelMatch && req.method === 'POST') {
3043
- const threadId = threadRunCancelMatch[1];
3044
- const runId = threadRunCancelMatch[2];
3045
- try {
3046
- const run = queries.getRun(runId);
3047
- if (!run || run.thread_id !== threadId) {
3048
- sendJSON(req, res, 404, { error: 'Run not found on thread', type: 'not_found' });
3049
- return;
3050
- }
3051
- if (['success', 'error', 'cancelled'].includes(run.status)) {
3052
- sendJSON(req, res, 409, { error: 'Run already completed or cancelled', type: 'conflict' });
3053
- return;
3054
- }
3055
- const cancelledRun = queries.cancelRun(runId);
3056
- const execution = activeExecutions.get(threadId);
3057
- if (execution?.pid) {
3058
- try { process.kill(-execution.pid, 'SIGTERM'); } catch { try { process.kill(execution.pid, 'SIGTERM'); } catch (e) {} }
3059
- setTimeout(() => {
3060
- try { process.kill(-execution.pid, 'SIGKILL'); } catch { try { process.kill(execution.pid, 'SIGKILL'); } catch (e) {} }
3061
- }, 3000);
3062
- }
3063
- if (execution?.sessionId) {
3064
- queries.updateSession(execution.sessionId, { status: 'error', error: 'Cancelled by user', completed_at: Date.now() });
3065
- }
3066
- activeExecutions.delete(threadId);
3067
- activeProcessesByRunId.delete(runId);
3068
- queries.setIsStreaming(threadId, false);
3069
- broadcastSync({ type: 'run_cancelled', runId, threadId, sessionId: execution?.sessionId, timestamp: Date.now() });
3070
- sendJSON(req, res, 200, cancelledRun);
3071
- } catch (err) {
3072
- sendJSON(req, res, 500, { error: err.message, type: 'internal_error' });
3073
- }
3074
- return;
3075
- }
3076
-
3077
- // GET /threads/{thread_id}/runs/{run_id}/wait - Long-poll for run completion on thread
3078
- const threadRunWaitMatch = pathOnly.match(/^\/api\/threads\/([a-f0-9-]{36})\/runs\/([a-f0-9-]{36})\/wait$/);
3079
- if (threadRunWaitMatch && req.method === 'GET') {
3080
- const threadId = threadRunWaitMatch[1];
3081
- const runId = threadRunWaitMatch[2];
3082
- const run = queries.getRun(runId);
3083
- if (!run || run.thread_id !== threadId) {
3084
- sendJSON(req, res, 404, { error: 'Run not found on thread', type: 'not_found' });
3085
- return;
3086
- }
3087
- const startTime = Date.now();
3088
- const pollInterval = setInterval(() => {
3089
- const currentRun = queries.getRun(runId);
3090
- const elapsed = Date.now() - startTime;
3091
- const done = currentRun && ['success', 'error', 'cancelled'].includes(currentRun.status);
3092
- if (done) {
3093
- clearInterval(pollInterval);
3094
- sendJSON(req, res, 200, currentRun);
3095
- } else if (elapsed > 30000) {
3096
- clearInterval(pollInterval);
3097
- sendJSON(req, res, 408, { error: 'Run still pending after 30s', run_id: runId, status: currentRun?.status || run.status });
3098
- }
3099
- }, 500);
3100
- req.on('close', () => clearInterval(pollInterval));
3101
- return;
3102
- }
3103
-
3104
3038
  if (routePath.startsWith('/api/image/')) {
3105
3039
  const imagePath = routePath.slice('/api/image/'.length);
3106
3040
  const decodedPath = decodeURIComponent(imagePath);
package/test-cancel.mjs DELETED
@@ -1,185 +0,0 @@
1
- // Integration test for run cancellation and control
2
- import http from 'http';
3
- import { randomUUID } from 'crypto';
4
- import Database from 'better-sqlite3';
5
- import path from 'path';
6
- import os from 'os';
7
- import { createACPQueries } from './acp-queries.js';
8
-
9
- const dbPath = path.join(os.homedir(), '.gmgui', 'data.db');
10
- const db = new Database(dbPath);
11
- const prep = (sql) => db.prepare(sql);
12
- const acpQueries = createACPQueries(db, prep);
13
-
14
- const BASE_URL = 'http://localhost:3000/gm';
15
- const testResults = {
16
- passed: [],
17
- failed: []
18
- };
19
-
20
- function testPass(name) {
21
- testResults.passed.push(name);
22
- console.log(`✓ ${name}`);
23
- }
24
-
25
- function testFail(name, error) {
26
- testResults.failed.push({ name, error });
27
- console.log(`✗ ${name}: ${error}`);
28
- }
29
-
30
- async function makeRequest(method, path, body = null) {
31
- return new Promise((resolve, reject) => {
32
- const fullPath = `/gm${path}`;
33
- const options = {
34
- method,
35
- hostname: 'localhost',
36
- port: 3000,
37
- path: fullPath,
38
- headers: {
39
- 'Content-Type': 'application/json'
40
- }
41
- };
42
-
43
- const req = http.request(options, (res) => {
44
- let data = '';
45
- res.on('data', chunk => data += chunk);
46
- res.on('end', () => {
47
- try {
48
- const parsed = data ? JSON.parse(data) : null;
49
- resolve({ status: res.statusCode, data: parsed, headers: res.headers });
50
- } catch {
51
- resolve({ status: res.statusCode, data: data, headers: res.headers });
52
- }
53
- });
54
- });
55
-
56
- req.on('error', reject);
57
- if (body) req.write(JSON.stringify(body));
58
- req.end();
59
- });
60
- }
61
-
62
- async function runTests() {
63
- console.log('=== RUNNING INTEGRATION TESTS ===\n');
64
-
65
- try {
66
- // Test 1: Create a thread
67
- console.log('[Test 1] Creating thread...');
68
- const threadResp = await makeRequest('POST', '/api/threads', {});
69
- if ((threadResp.status === 200 || threadResp.status === 201) && threadResp.data.thread_id) {
70
- testPass('Thread creation');
71
- } else {
72
- testFail('Thread creation', `Status ${threadResp.status}`);
73
- return;
74
- }
75
-
76
- const threadId = threadResp.data.thread_id;
77
-
78
- // Test 2: Create a run (stateless, without thread)
79
- console.log('[Test 2] Creating stateless run...');
80
- const runResp = await makeRequest('POST', '/api/runs', {
81
- agent_id: 'claude-code',
82
- input: 'test input'
83
- });
84
- if (runResp.status === 200 && runResp.data.run_id) {
85
- testPass('Stateless run creation');
86
- } else {
87
- testFail('Stateless run creation', `Status ${runResp.status}`);
88
- return;
89
- }
90
-
91
- const runId = runResp.data.run_id;
92
-
93
- // Test 3: Verify run status is pending
94
- console.log('[Test 3] Verifying run status...');
95
- const run = acpQueries.getRun(runId);
96
- if (run && run.status === 'pending') {
97
- testPass('Run status is pending');
98
- } else {
99
- testFail('Run status is pending', `Status is ${run?.status}`);
100
- }
101
-
102
- // Test 4: Cancel the run using /api/runs/{run_id}/cancel
103
- console.log('[Test 4] Cancelling run via /api/runs/{run_id}/cancel...');
104
- const cancelResp = await makeRequest('POST', `/api/runs/${runId}/cancel`);
105
- if (cancelResp.status === 200 && cancelResp.data.status === 'cancelled') {
106
- testPass('Run cancellation via /api/runs');
107
- } else {
108
- testFail('Run cancellation via /api/runs', `Status ${cancelResp.status}, run status ${cancelResp.data?.status}`);
109
- }
110
-
111
- // Test 5: Verify run status is cancelled in database
112
- console.log('[Test 5] Verifying cancelled status in DB...');
113
- const cancelledRun = acpQueries.getRun(runId);
114
- if (cancelledRun && cancelledRun.status === 'cancelled') {
115
- testPass('Cancelled status persisted in database');
116
- } else {
117
- testFail('Cancelled status persisted in database', `Status is ${cancelledRun?.status}`);
118
- }
119
-
120
- // Test 6: Try to cancel again - should get 409 conflict
121
- console.log('[Test 6] Testing 409 conflict on re-cancel...');
122
- const recancel = await makeRequest('POST', `/api/runs/${runId}/cancel`);
123
- if (recancel.status === 409) {
124
- testPass('409 conflict on already-cancelled run');
125
- } else {
126
- testFail('409 conflict on already-cancelled run', `Got status ${recancel.status}`);
127
- }
128
-
129
- // Test 7: Test wait endpoint with already-completed run
130
- console.log('[Test 7] Testing wait endpoint with completed run...');
131
- const waitStart = Date.now();
132
- const waitResp = await makeRequest('GET', `/api/runs/${runId}/wait`);
133
- const waitDuration = Date.now() - waitStart;
134
- if (waitResp.status === 200 && waitDuration < 5000) {
135
- testPass('Wait endpoint returns immediately for completed run');
136
- } else {
137
- testFail('Wait endpoint returns immediately for completed run', `Took ${waitDuration}ms`);
138
- }
139
-
140
- // Test 8: Test cancellation of non-existent run
141
- console.log('[Test 8] Testing 404 on non-existent run...');
142
- const fakeRunId = randomUUID();
143
- const notFound = await makeRequest('POST', `/api/runs/${fakeRunId}/cancel`);
144
- if (notFound.status === 404) {
145
- testPass('404 on non-existent run');
146
- } else {
147
- testFail('404 on non-existent run', `Got status ${notFound.status}`);
148
- }
149
-
150
- // Cleanup
151
- console.log('\n[Cleanup] Deleting test thread...');
152
- try {
153
- acpQueries.deleteThread(threadId);
154
- console.log('Cleanup complete');
155
- } catch (e) {
156
- console.log('Cleanup warning:', e.message);
157
- }
158
-
159
- } catch (error) {
160
- console.error('Test suite error:', error);
161
- testFail('Test suite execution', error.message);
162
- }
163
-
164
- db.close();
165
-
166
- // Summary
167
- console.log('\n=== TEST SUMMARY ===');
168
- console.log(`Passed: ${testResults.passed.length}`);
169
- console.log(`Failed: ${testResults.failed.length}`);
170
- if (testResults.failed.length > 0) {
171
- console.log('\nFailed tests:');
172
- testResults.failed.forEach(f => console.log(` - ${f.name}: ${f.error}`));
173
- }
174
-
175
- return testResults.passed.length > 0 && testResults.failed.length === 0;
176
- }
177
-
178
- // Run the tests
179
- runTests().then(success => {
180
- console.log(`\n${success ? '✓ ALL TESTS PASSED' : '✗ SOME TESTS FAILED'}`);
181
- process.exit(success ? 0 : 1);
182
- }).catch(err => {
183
- console.error('Fatal test error:', err);
184
- process.exit(1);
185
- });
@@ -1,119 +0,0 @@
1
- // Verify run cancellation implementation without needing running server
2
- import fs from 'fs';
3
-
4
- const serverContent = fs.readFileSync('/config/workspace/agentgui/server.js', 'utf-8');
5
-
6
- console.log('=== VERIFYING RUN CANCELLATION IMPLEMENTATION ===\n');
7
-
8
- const checks = [
9
- {
10
- name: 'Enhanced /api/runs/{run_id}/cancel endpoint',
11
- test: () => {
12
- const hasGetRun = serverContent.includes('acpQueries.getRun(runId)') || serverContent.includes('queries.getRun(runId)');
13
- const hasStatusCheck = serverContent.includes("['success', 'error', 'cancelled'].includes");
14
- const has409 = serverContent.includes('sendJSON(req, res, 409');
15
- return hasGetRun && hasStatusCheck && has409;
16
- }
17
- },
18
- {
19
- name: 'Process termination with SIGTERM then SIGKILL',
20
- test: () => {
21
- const hasSigterm = serverContent.includes("process.kill(-execution.pid, 'SIGTERM')") ||
22
- serverContent.includes("process.kill(execution.pid, 'SIGTERM')");
23
- const hasSigkill = serverContent.includes("'SIGKILL'");
24
- const hasTimeout = serverContent.includes('setTimeout') && serverContent.includes('3000');
25
- return hasSigterm && hasSigkill && hasTimeout;
26
- }
27
- },
28
- {
29
- name: 'WebSocket broadcast on cancellation',
30
- test: () => {
31
- return serverContent.includes("type: 'streaming_cancelled'") &&
32
- serverContent.includes('broadcastSync');
33
- }
34
- },
35
- {
36
- name: 'Active executions cleanup',
37
- test: () => {
38
- return serverContent.includes('activeExecutions.delete(threadId)') &&
39
- serverContent.includes('queries.setIsStreaming(threadId, false)');
40
- }
41
- },
42
- {
43
- name: 'Thread-based cancel endpoint /api/threads/{thread_id}/runs/{run_id}/cancel',
44
- test: () => {
45
- return serverContent.includes('threadRunCancelMatch') &&
46
- serverContent.includes('/api/threads/([^/]+)/runs/([^/]+)/cancel');
47
- }
48
- },
49
- {
50
- name: 'Thread-based wait endpoint /api/threads/{thread_id}/runs/{run_id}/wait',
51
- test: () => {
52
- return serverContent.includes('threadRunWaitMatch') &&
53
- serverContent.includes('/api/threads/([^/]+)/runs/([^/]+)/wait');
54
- }
55
- },
56
- {
57
- name: 'Wait endpoint long-polling (30s timeout, 500ms poll)',
58
- test: () => {
59
- const hasWait = serverContent.includes('/wait') && serverContent.includes('GET');
60
- const hasPoll = serverContent.includes('setInterval') && serverContent.includes('500');
61
- const hasTimeout = serverContent.includes('30000');
62
- return hasWait && hasPoll && hasTimeout;
63
- }
64
- },
65
- {
66
- name: 'Thread validation in thread-based endpoints',
67
- test: () => {
68
- return serverContent.includes('run.thread_id !== threadId') &&
69
- serverContent.includes('Run does not belong to specified thread');
70
- }
71
- },
72
- {
73
- name: 'Session status update on cancellation',
74
- test: () => {
75
- return serverContent.includes("status: 'error'") &&
76
- serverContent.includes("error: 'Cancelled by user'");
77
- }
78
- },
79
- {
80
- name: 'Database run status update to cancelled',
81
- test: () => {
82
- const hasCancelRun = serverContent.includes('cancelRun(runId)') ||
83
- serverContent.includes('cancelledRun');
84
- const hasUpdateStatus = serverContent.includes('updateRunStatus');
85
- return hasCancelRun || hasUpdateStatus;
86
- }
87
- }
88
- ];
89
-
90
- let passed = 0;
91
- let failed = 0;
92
-
93
- checks.forEach(check => {
94
- try {
95
- const result = check.test();
96
- if (result) {
97
- console.log(`✓ ${check.name}`);
98
- passed++;
99
- } else {
100
- console.log(`✗ ${check.name}`);
101
- failed++;
102
- }
103
- } catch (e) {
104
- console.log(`✗ ${check.name} (error: ${e.message})`);
105
- failed++;
106
- }
107
- });
108
-
109
- console.log(`\n=== SUMMARY ===`);
110
- console.log(`Passed: ${passed}/${checks.length}`);
111
- console.log(`Failed: ${failed}/${checks.length}`);
112
-
113
- if (passed === checks.length) {
114
- console.log('\n✓ ALL IMPLEMENTATION CHECKS PASSED');
115
- process.exit(0);
116
- } else {
117
- console.log('\n✗ SOME IMPLEMENTATION CHECKS FAILED');
118
- process.exit(1);
119
- }