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.0",
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
+ });
@@ -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;
@@ -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();