onbuzz 5.4.0 → 5.4.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.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "onbuzz",
|
|
3
|
-
"version": "5.4.
|
|
3
|
+
"version": "5.4.1",
|
|
4
4
|
"description": "Loxia OnBuzz - Your AI Fleet",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"electron:build:win": "npm run build:web-ui && electron-builder --win",
|
|
32
32
|
"electron:build:mac": "npm run build:web-ui && electron-builder --mac",
|
|
33
33
|
"electron:build:linux": "npm run build:web-ui && electron-builder --linux",
|
|
34
|
-
"electron:build:onbuzz:win": "cross-env VITE_BRAND=onbuzz npm run build:web-ui && cross-env LOXIA_BRAND=onbuzz electron-builder --win -c.productName=\"Loxia OnBuzz\" -c.appId=com.loxia.onbuzz",
|
|
35
|
-
"electron:build:onbuzz:mac": "cross-env VITE_BRAND=onbuzz npm run build:web-ui && cross-env LOXIA_BRAND=onbuzz electron-builder --mac -c.productName=\"Loxia OnBuzz\" -c.appId=com.loxia.onbuzz",
|
|
36
|
-
"electron:build:onbuzz:linux": "cross-env VITE_BRAND=onbuzz npm run build:web-ui && cross-env LOXIA_BRAND=onbuzz electron-builder --linux -c.productName=\"Loxia OnBuzz\" -c.appId=com.loxia.onbuzz"
|
|
34
|
+
"electron:build:onbuzz:win": "cross-env VITE_BRAND=onbuzz npm run build:web-ui && cross-env LOXIA_BRAND=onbuzz electron-builder --win -c.productName=\"Loxia OnBuzz\" -c.appId=com.loxia.onbuzz -c.executableName=loxia-onbuzz -c.win.icon=electron/icon-onbuzz.png",
|
|
35
|
+
"electron:build:onbuzz:mac": "cross-env VITE_BRAND=onbuzz npm run build:web-ui && cross-env LOXIA_BRAND=onbuzz electron-builder --mac -c.productName=\"Loxia OnBuzz\" -c.appId=com.loxia.onbuzz -c.executableName=loxia-onbuzz -c.mac.icon=electron/icon-onbuzz.png",
|
|
36
|
+
"electron:build:onbuzz:linux": "cross-env VITE_BRAND=onbuzz npm run build:web-ui && cross-env LOXIA_BRAND=onbuzz electron-builder --linux -c.productName=\"Loxia OnBuzz\" -c.appId=com.loxia.onbuzz -c.executableName=loxia-onbuzz -c.linux.icon=electron/icon-onbuzz.png"
|
|
37
37
|
},
|
|
38
38
|
"keywords": [
|
|
39
39
|
"ai",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* regress again by patching one path and forgetting the others.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { jest, describe, test, expect } from '@jest/globals';
|
|
13
|
+
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
|
|
14
14
|
import { createMockLogger, createMockConfig, createMockStateManager } from '../../__test-utils__/mockFactories.js';
|
|
15
15
|
|
|
16
16
|
// ── Mocks ───────────────────────────────────────────────────────────────────
|
|
@@ -36,8 +36,17 @@ jest.unstable_mockModule('../../services/visualEditorBridge.js', () => ({
|
|
|
36
36
|
}),
|
|
37
37
|
}));
|
|
38
38
|
|
|
39
|
+
const mockCancelRequest = jest.fn().mockReturnValue({ success: true });
|
|
40
|
+
jest.unstable_mockModule('../../services/promptService.js', () => ({
|
|
41
|
+
getPromptService: jest.fn().mockReturnValue({ cancelRequest: mockCancelRequest }),
|
|
42
|
+
}));
|
|
43
|
+
|
|
39
44
|
const { default: AgentPool } = await import('../agentPool.js');
|
|
40
45
|
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
mockCancelRequest.mockClear().mockReturnValue({ success: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
41
50
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
42
51
|
function makePool(overrides = {}) {
|
|
43
52
|
const config = createMockConfig(overrides.config);
|
|
@@ -278,7 +287,7 @@ describe('agentPool._wakeAgentForMessage (shared helper)', () => {
|
|
|
278
287
|
|
|
279
288
|
const info = await pool._wakeAgentForMessage(agent, 'test');
|
|
280
289
|
|
|
281
|
-
expect(info).toEqual({ wasPaused: false, hadDelay: false, hadPausedUntil: false });
|
|
290
|
+
expect(info).toEqual({ wasPaused: false, hadDelay: false, hadPausedUntil: false, wasAwaitingInput: false });
|
|
282
291
|
expect(agent.status).toBe('active');
|
|
283
292
|
expect(agent.delayEndTime).toBeFalsy();
|
|
284
293
|
});
|
|
@@ -310,6 +319,109 @@ describe('agentPool._wakeAgentForMessage (shared helper)', () => {
|
|
|
310
319
|
|
|
311
320
|
const info = await pool._wakeAgentForMessage(null, 'test');
|
|
312
321
|
|
|
313
|
-
expect(info).toEqual({ wasPaused: false, hadDelay: false, hadPausedUntil: false });
|
|
322
|
+
expect(info).toEqual({ wasPaused: false, hadDelay: false, hadPausedUntil: false, wasAwaitingInput: false });
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
327
|
+
// awaitingUserInput deadlock breaker — an inbound user/inter-agent message
|
|
328
|
+
// supersedes a pending (possibly orphaned) prompt so the agent can't sit
|
|
329
|
+
// permanently skipped with queued messages piling up.
|
|
330
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
const AWAITING = () => ({ type: 'user_prompt', requestId: 'prompt-req-1', startedAt: '2026-06-30T07:03:46.000Z' });
|
|
333
|
+
|
|
334
|
+
describe('agentPool._wakeAgentForMessage — awaitingUserInput recovery', () => {
|
|
335
|
+
test('user-message clears awaitingUserInput and cancels the live prompt', async () => {
|
|
336
|
+
const { pool } = makePool();
|
|
337
|
+
const agent = await pool.createAgent(agentCfg());
|
|
338
|
+
agent.awaitingUserInput = AWAITING();
|
|
339
|
+
|
|
340
|
+
const info = await pool._wakeAgentForMessage(agent, 'user-message');
|
|
341
|
+
|
|
342
|
+
expect(info.wasAwaitingInput).toBe(true);
|
|
343
|
+
expect(agent.awaitingUserInput).toBeUndefined();
|
|
344
|
+
expect(mockCancelRequest).toHaveBeenCalledWith('prompt-req-1', expect.stringContaining('user-message'));
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test('inter-agent-message also clears awaitingUserInput', async () => {
|
|
348
|
+
const { pool } = makePool();
|
|
349
|
+
const agent = await pool.createAgent(agentCfg());
|
|
350
|
+
agent.awaitingUserInput = AWAITING();
|
|
351
|
+
|
|
352
|
+
const info = await pool._wakeAgentForMessage(agent, 'inter-agent-message');
|
|
353
|
+
|
|
354
|
+
expect(info.wasAwaitingInput).toBe(true);
|
|
355
|
+
expect(agent.awaitingUserInput).toBeUndefined();
|
|
356
|
+
expect(mockCancelRequest).toHaveBeenCalled();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('tool-result wake does NOT clear awaitingUserInput (internal)', async () => {
|
|
360
|
+
const { pool } = makePool();
|
|
361
|
+
const agent = await pool.createAgent(agentCfg());
|
|
362
|
+
agent.awaitingUserInput = AWAITING();
|
|
363
|
+
|
|
364
|
+
const info = await pool._wakeAgentForMessage(agent, 'tool-result');
|
|
365
|
+
|
|
366
|
+
expect(info.wasAwaitingInput).toBe(false);
|
|
367
|
+
expect(agent.awaitingUserInput).toBeDefined();
|
|
368
|
+
expect(mockCancelRequest).not.toHaveBeenCalled();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('a non-user_prompt awaiting type is cleared but the prompt service is not touched', async () => {
|
|
372
|
+
const { pool } = makePool();
|
|
373
|
+
const agent = await pool.createAgent(agentCfg());
|
|
374
|
+
agent.awaitingUserInput = { type: 'credentials', siteId: 'github', startedAt: 'x' };
|
|
375
|
+
|
|
376
|
+
const info = await pool._wakeAgentForMessage(agent, 'user-message');
|
|
377
|
+
|
|
378
|
+
expect(info.wasAwaitingInput).toBe(true);
|
|
379
|
+
expect(agent.awaitingUserInput).toBeUndefined();
|
|
380
|
+
expect(mockCancelRequest).not.toHaveBeenCalled();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test('a user_prompt without a requestId is cleared without cancelling', async () => {
|
|
384
|
+
const { pool } = makePool();
|
|
385
|
+
const agent = await pool.createAgent(agentCfg());
|
|
386
|
+
agent.awaitingUserInput = { type: 'user_prompt', startedAt: 'x' }; // no requestId
|
|
387
|
+
|
|
388
|
+
await pool._wakeAgentForMessage(agent, 'user-message');
|
|
389
|
+
|
|
390
|
+
expect(agent.awaitingUserInput).toBeUndefined();
|
|
391
|
+
expect(mockCancelRequest).not.toHaveBeenCalled();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('a throwing cancelRequest is swallowed and the flag is still cleared', async () => {
|
|
395
|
+
mockCancelRequest.mockImplementation(() => { throw new Error('service down'); });
|
|
396
|
+
const { pool } = makePool();
|
|
397
|
+
const agent = await pool.createAgent(agentCfg());
|
|
398
|
+
agent.awaitingUserInput = AWAITING();
|
|
399
|
+
|
|
400
|
+
const info = await pool._wakeAgentForMessage(agent, 'user-message');
|
|
401
|
+
|
|
402
|
+
expect(info.wasAwaitingInput).toBe(true);
|
|
403
|
+
expect(agent.awaitingUserInput).toBeUndefined();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('no awaitingUserInput → wasAwaitingInput false, no cancel', async () => {
|
|
407
|
+
const { pool } = makePool();
|
|
408
|
+
const agent = await pool.createAgent(agentCfg());
|
|
409
|
+
|
|
410
|
+
const info = await pool._wakeAgentForMessage(agent, 'user-message');
|
|
411
|
+
|
|
412
|
+
expect(info.wasAwaitingInput).toBe(false);
|
|
413
|
+
expect(mockCancelRequest).not.toHaveBeenCalled();
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test('end-to-end: addUserMessage revives an awaiting agent and queues the message', async () => {
|
|
417
|
+
const { pool } = makePool();
|
|
418
|
+
const agent = await pool.createAgent(agentCfg());
|
|
419
|
+
agent.awaitingUserInput = AWAITING();
|
|
420
|
+
|
|
421
|
+
await pool.addUserMessage(agent.id, { content: 'please continue' });
|
|
422
|
+
|
|
423
|
+
expect(agent.awaitingUserInput).toBeUndefined();
|
|
424
|
+
expect(agent.messageQueues.userMessages).toHaveLength(1);
|
|
425
|
+
expect(mockCancelRequest).toHaveBeenCalledWith('prompt-req-1', expect.any(String));
|
|
314
426
|
});
|
|
315
427
|
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stateManager.restoreAgent must clear a stale awaitingUserInput on load.
|
|
3
|
+
*
|
|
4
|
+
* An agent persisted mid-prompt (user_prompt / credentials) is otherwise reborn
|
|
5
|
+
* with awaitingUserInput set but a dead requestId — the scheduler then skips it
|
|
6
|
+
* forever, and queued messages pile up unprocessed (the exact 2-day-stuck agent
|
|
7
|
+
* this fixes). Restore should drop the flag so queued/new messages drive it.
|
|
8
|
+
*/
|
|
9
|
+
import { jest, describe, test, expect } from '@jest/globals';
|
|
10
|
+
import StateManager from '../stateManager.js';
|
|
11
|
+
import { createMockLogger } from '../../__test-utils__/mockFactories.js';
|
|
12
|
+
|
|
13
|
+
const AGENT_INFO = { name: 'Intent hunter Clone', stateFile: 'a1.state.json', conversationsFile: 'a1.conversations.json' };
|
|
14
|
+
|
|
15
|
+
function makeSM() {
|
|
16
|
+
const sm = new StateManager({ system: {} }, createMockLogger());
|
|
17
|
+
sm.getStateDir = () => '/tmp/state';
|
|
18
|
+
sm.validateModelConversations = jest.fn().mockResolvedValue(undefined);
|
|
19
|
+
sm.checkAgentPauseStatus = jest.fn().mockResolvedValue({ isPaused: false, pausedUntil: null });
|
|
20
|
+
return sm;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function stubLoad(sm, agentState) {
|
|
24
|
+
sm.loadJSONResilient = jest.fn(async (file) => {
|
|
25
|
+
if (String(file).includes('conversations')) {
|
|
26
|
+
return { data: { version: 2, agentId: 'a1', conversations: { full: { messages: [], lastUpdated: 'x' } } }, recovery: null };
|
|
27
|
+
}
|
|
28
|
+
return { data: { version: 2, agentId: 'a1', state: agentState }, recovery: null };
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const baseState = (extra = {}) => ({
|
|
33
|
+
id: 'a1', name: 'Intent hunter Clone', mode: 'chat', status: 'active',
|
|
34
|
+
messageQueues: { userMessages: [{ content: 'please continue' }], interAgentMessages: [], toolResults: [] },
|
|
35
|
+
...extra,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('stateManager.restoreAgent — awaitingUserInput recovery', () => {
|
|
39
|
+
test('clears a persisted awaitingUserInput (user_prompt) but keeps queued messages', async () => {
|
|
40
|
+
const sm = makeSM();
|
|
41
|
+
stubLoad(sm, baseState({
|
|
42
|
+
awaitingUserInput: { type: 'user_prompt', requestId: 'prompt-req-1', startedAt: '2026-06-30T07:03:46.000Z' },
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
const agent = await sm.restoreAgent('a1', AGENT_INFO, '/proj');
|
|
46
|
+
|
|
47
|
+
expect(agent.awaitingUserInput).toBeUndefined();
|
|
48
|
+
expect(agent.isRestored).toBe(true);
|
|
49
|
+
// The queued messages that could never be processed are preserved so they
|
|
50
|
+
// drive the agent once it's schedulable again.
|
|
51
|
+
expect(agent.messageQueues.userMessages).toHaveLength(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('clears a persisted awaitingUserInput of any type (e.g. credentials)', async () => {
|
|
55
|
+
const sm = makeSM();
|
|
56
|
+
stubLoad(sm, baseState({ awaitingUserInput: { type: 'credentials', siteId: 'github', startedAt: 'x' } }));
|
|
57
|
+
|
|
58
|
+
const agent = await sm.restoreAgent('a1', AGENT_INFO, '/proj');
|
|
59
|
+
expect(agent.awaitingUserInput).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('leaves a normal agent (no awaitingUserInput) untouched', async () => {
|
|
63
|
+
const sm = makeSM();
|
|
64
|
+
stubLoad(sm, baseState());
|
|
65
|
+
|
|
66
|
+
const agent = await sm.restoreAgent('a1', AGENT_INFO, '/proj');
|
|
67
|
+
expect(agent.awaitingUserInput).toBeUndefined();
|
|
68
|
+
expect(agent.isRestored).toBe(true);
|
|
69
|
+
expect(agent.mode).toBe('chat');
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/core/agentPool.js
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from '../utilities/constants.js';
|
|
21
21
|
import DirectoryAccessManager from '../utilities/directoryAccessManager.js';
|
|
22
22
|
import { getVisualEditorBridge } from '../services/visualEditorBridge.js';
|
|
23
|
+
import { getPromptService } from '../services/promptService.js';
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Capabilities that were once granted but whose backing tool has since been
|
|
@@ -1469,9 +1470,35 @@ class AgentPool {
|
|
|
1469
1470
|
* @private
|
|
1470
1471
|
*/
|
|
1471
1472
|
async _wakeAgentForMessage(agent, reason) {
|
|
1472
|
-
const info = { wasPaused: false, hadDelay: false, hadPausedUntil: false };
|
|
1473
|
+
const info = { wasPaused: false, hadDelay: false, hadPausedUntil: false, wasAwaitingInput: false };
|
|
1473
1474
|
if (!agent) return info;
|
|
1474
1475
|
|
|
1476
|
+
// A real inbound message (from the user or another agent) SUPERSEDES a
|
|
1477
|
+
// pending awaiting-user-input wait. Without this, an agent whose prompt
|
|
1478
|
+
// was orphaned — the modal is gone, the in-memory prompt promise died in a
|
|
1479
|
+
// restart, or the user simply chose to reply in chat instead of the modal —
|
|
1480
|
+
// stays permanently skipped by the scheduler (shouldAgentBeActive bails on
|
|
1481
|
+
// awaitingUserInput before it ever looks at the queue), so every message the
|
|
1482
|
+
// user sends just piles up unprocessed. Clear the flag so the message gets
|
|
1483
|
+
// handled, and cancel the live prompt request (user_prompt only) so the
|
|
1484
|
+
// tool's promise rejects cleanly instead of leaking. Tool-result wakes are
|
|
1485
|
+
// internal and don't count — the prompt's own completion path clears the
|
|
1486
|
+
// flag there.
|
|
1487
|
+
const isInboundMessage = reason === 'user-message' || reason === 'inter-agent-message';
|
|
1488
|
+
if (isInboundMessage && agent.awaitingUserInput) {
|
|
1489
|
+
info.wasAwaitingInput = true;
|
|
1490
|
+
const { type, requestId } = agent.awaitingUserInput;
|
|
1491
|
+
delete agent.awaitingUserInput;
|
|
1492
|
+
if (type === 'user_prompt' && requestId) {
|
|
1493
|
+
try {
|
|
1494
|
+
getPromptService(this.logger).cancelRequest(requestId, `superseded by ${reason}`);
|
|
1495
|
+
} catch (err) {
|
|
1496
|
+
this.logger?.warn?.(`Failed to cancel superseded prompt ${requestId}: ${err.message}`);
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
this.logger.info(`Agent ${agent.id} awaitingUserInput (${type}) cleared — ${reason} supersedes the pending prompt`);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1475
1502
|
// Auto-resume explicitly paused agent.
|
|
1476
1503
|
if (agent.status === AGENT_STATUS.PAUSED) {
|
|
1477
1504
|
info.wasPaused = true;
|
package/src/core/stateManager.js
CHANGED
|
@@ -948,6 +948,20 @@ class StateManager {
|
|
|
948
948
|
_restoreRecoveries: recoveries,
|
|
949
949
|
};
|
|
950
950
|
|
|
951
|
+
// RECOVERY: an agent persisted mid-prompt is reborn with awaitingUserInput
|
|
952
|
+
// set, but the in-memory prompt promise died with the old process — the
|
|
953
|
+
// requestId now points at nothing, and the scheduler would skip this agent
|
|
954
|
+
// forever (queued messages included). Clear the stale flag so it can be
|
|
955
|
+
// driven again; any queued/new message picks it right back up. Mirrors
|
|
956
|
+
// agentPool.rehydrateFromState's recovery.
|
|
957
|
+
if (restoredAgent.awaitingUserInput) {
|
|
958
|
+
this.logger.warn(`Restored agent ${agentId} was awaiting user input (${restoredAgent.awaitingUserInput.type}) — clearing stale flag`, {
|
|
959
|
+
inputType: restoredAgent.awaitingUserInput.type,
|
|
960
|
+
startedAt: restoredAgent.awaitingUserInput.startedAt,
|
|
961
|
+
});
|
|
962
|
+
delete restoredAgent.awaitingUserInput;
|
|
963
|
+
}
|
|
964
|
+
|
|
951
965
|
// CRITICAL: Restore interAgentTracking as a Map (it comes as plain object from JSON)
|
|
952
966
|
if (!restoredAgent.interAgentTracking || typeof restoredAgent.interAgentTracking !== 'object') {
|
|
953
967
|
restoredAgent.interAgentTracking = new Map();
|