feat(gateway): complete openclaw phase1 queue parity v2
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { GatewayEvent, GatewayRequest, OutboundMessage } from '../protocol.js';
|
||||
import { LaneQueue } from '../lane-queue.js';
|
||||
import { LaneQueue, LaneQueueRejectedError } from '../lane-queue.js';
|
||||
import { createAgentHandlers } from './agent.js';
|
||||
import type { AgentHandlerDeps } from './agent.js';
|
||||
import { CommandRegistry, registerBuiltinCommands } from '../../commands/index.js';
|
||||
@@ -28,6 +28,7 @@ describe('createAgentHandlers command fast-path', () => {
|
||||
};
|
||||
|
||||
const sessionManager = {
|
||||
getSessionConfig: vi.fn(),
|
||||
setSessionConfig: vi.fn(),
|
||||
deleteSessionConfig: vi.fn(),
|
||||
};
|
||||
@@ -123,6 +124,26 @@ describe('createAgentHandlers command fast-path', () => {
|
||||
expect((sent[0] as GatewayEvent).event).toBe('done');
|
||||
expect(((sent[0] as GatewayEvent).data as { content: string }).content).toBe('agent response');
|
||||
});
|
||||
|
||||
it('handles /queue command via fast-path and persists queue session config', async () => {
|
||||
const sent: OutboundMessage[] = [];
|
||||
const send = vi.fn((msg: OutboundMessage) => sent.push(msg));
|
||||
const req: GatewayRequest = {
|
||||
id: 5,
|
||||
method: 'agent.send',
|
||||
params: {
|
||||
message: '/queue set mode followup',
|
||||
connectionId: 'conn-1',
|
||||
metadata: { isCommand: true, command: 'queue', commandArgs: 'set mode followup' },
|
||||
},
|
||||
};
|
||||
|
||||
await handlers['agent.send'](req, send);
|
||||
|
||||
expect(sessionManager.setSessionConfig).toHaveBeenCalledWith('ws', 'ws:conn-1', 'queue.mode', 'followup');
|
||||
expect(mockAgent.process).not.toHaveBeenCalled();
|
||||
expect(((sent[0] as GatewayEvent).data as { content: string }).content).toContain('Set queue.mode=followup');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAgentHandlers queue policy resolution', () => {
|
||||
@@ -154,7 +175,7 @@ describe('createAgentHandlers queue policy resolution', () => {
|
||||
cancel: vi.fn(),
|
||||
} as unknown as LaneQueue;
|
||||
|
||||
const resolveQueuePolicy = vi.fn(() => ({ mode: 'steer' as const, cap: 3 }));
|
||||
const resolveQueuePolicy = vi.fn(() => ({ mode: 'steer_backlog' as const, cap: 3, debounceMs: 25 }));
|
||||
|
||||
const handlers = createAgentHandlers({
|
||||
sessionBridge: sessionBridge as unknown as AgentHandlerDeps['sessionBridge'],
|
||||
@@ -178,8 +199,64 @@ describe('createAgentHandlers queue policy resolution', () => {
|
||||
channel: 'ws',
|
||||
});
|
||||
expect((laneQueue.enqueue as unknown as ReturnType<typeof vi.fn>).mock.calls[0][2]).toEqual({
|
||||
mode: 'steer',
|
||||
mode: 'steer_backlog',
|
||||
cap: 3,
|
||||
debounceMs: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits structured queue error events for lane rejections', async () => {
|
||||
const sessionBridge = {
|
||||
getAgent: vi.fn(() => ({
|
||||
process: vi.fn(async () => 'ok'),
|
||||
getUsage: vi.fn(() => ({
|
||||
primary: { inputTokens: 0, outputTokens: 0, calls: 0 },
|
||||
delegation: {},
|
||||
total: { inputTokens: 0, outputTokens: 0, calls: 0, estimatedCost: 0 },
|
||||
})),
|
||||
getModelTier: vi.fn(() => 'default'),
|
||||
setModelTier: vi.fn(),
|
||||
compact: vi.fn(async () => null),
|
||||
reset: vi.fn(),
|
||||
})),
|
||||
getSessionId: vi.fn(() => 'ws:s1'),
|
||||
setBusy: vi.fn(),
|
||||
setOnToolUse: vi.fn(),
|
||||
isBusy: vi.fn(() => false),
|
||||
};
|
||||
|
||||
const laneQueue = {
|
||||
enqueue: vi.fn(async () => {
|
||||
throw new LaneQueueRejectedError({
|
||||
code: 'overflow',
|
||||
laneId: 'ws:s1',
|
||||
mode: 'followup',
|
||||
overflow: 'drop_new',
|
||||
droppedCount: 1,
|
||||
message: 'Lane queue full (drop_new)',
|
||||
});
|
||||
}),
|
||||
cancel: vi.fn(),
|
||||
} as unknown as LaneQueue;
|
||||
|
||||
const handlers = createAgentHandlers({
|
||||
sessionBridge: sessionBridge as unknown as AgentHandlerDeps['sessionBridge'],
|
||||
laneQueue,
|
||||
});
|
||||
|
||||
const sent: OutboundMessage[] = [];
|
||||
const send = vi.fn((msg: OutboundMessage) => sent.push(msg));
|
||||
|
||||
await handlers['agent.send']({
|
||||
id: 6,
|
||||
method: 'agent.send',
|
||||
params: { message: 'hello', connectionId: 'conn-1' },
|
||||
}, send);
|
||||
|
||||
expect(sent).toHaveLength(1);
|
||||
const event = sent[0] as GatewayEvent;
|
||||
expect(event.event).toBe('error');
|
||||
expect((event.data as { code: number }).code).toBe(3);
|
||||
expect((event.data as { queue?: { code: string } }).queue?.code).toBe('overflow');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { makeEvent, makeError, ErrorCode } from '../protocol.js';
|
||||
import type { SessionBridge } from '../session-bridge.js';
|
||||
import type { LaneQueue } from '../lane-queue.js';
|
||||
import type { LaneQueueConfig } from '../lane-queue.js';
|
||||
import { LaneQueueRejectedError } from '../lane-queue.js';
|
||||
import type { MetricsCollector } from '../metrics.js';
|
||||
import type { Attachment } from '../../channels/types.js';
|
||||
import type { SessionManager } from '../../session/manager.js';
|
||||
@@ -56,13 +57,15 @@ export function createAgentHandlers(deps: AgentHandlerDeps) {
|
||||
const sessionId = deps.sessionBridge.getSessionId(connectionId);
|
||||
const laneId = sessionId ?? connectionId;
|
||||
const channel = sessionId?.split(':', 1)[0] ?? 'ws';
|
||||
const resolvedPolicy = deps.resolveQueuePolicy?.({ laneId, sessionId, connectionId, channel });
|
||||
|
||||
// Enqueue the work — if the lane is idle it runs immediately,
|
||||
// otherwise it waits for earlier requests on the same session to finish.
|
||||
const requestId = request.id.toString();
|
||||
deps.metrics?.startRequest(requestId, { sessionId: laneId, channel: 'ws' });
|
||||
|
||||
return deps.laneQueue.enqueue(laneId, async () => {
|
||||
try {
|
||||
return await deps.laneQueue.enqueue(laneId, async () => {
|
||||
deps.sessionBridge.setBusy(connectionId, true);
|
||||
|
||||
const commandInput = safeParams.metadata?.isCommand && typeof safeParams.metadata.command === 'string'
|
||||
@@ -256,6 +259,89 @@ export function createAgentHandlers(deps: AgentHandlerDeps) {
|
||||
|
||||
return `Elevated mode: on until ${new Date(untilMs).toISOString()}`;
|
||||
},
|
||||
|
||||
getQueue: () => {
|
||||
const mode = resolvedPolicy?.mode ?? 'collect';
|
||||
const cap = resolvedPolicy?.cap ?? 50;
|
||||
const overflow = resolvedPolicy?.overflow ?? 'drop_old';
|
||||
const debounceMs = resolvedPolicy?.debounceMs ?? 0;
|
||||
const summarizeOverflow = resolvedPolicy?.summarizeOverflow ?? true;
|
||||
const source = deps.sessionManager && sessionId
|
||||
? deps.sessionManager.getSessionConfig('ws', sessionId, 'queue.mode') ? 'session override' : 'default/channel'
|
||||
: 'default/channel';
|
||||
return [
|
||||
'**Queue policy**',
|
||||
`mode: ${mode}`,
|
||||
`cap: ${cap}`,
|
||||
`overflow: ${overflow}`,
|
||||
`debounce_ms: ${debounceMs}`,
|
||||
`summarize_overflow: ${summarizeOverflow}`,
|
||||
`source: ${source}`,
|
||||
].join('\n');
|
||||
},
|
||||
|
||||
setQueue: (input: string) => {
|
||||
if (!deps.sessionManager || !sessionId) {
|
||||
return 'Queue command is not available in this session.';
|
||||
}
|
||||
const [rawKey, ...rest] = input.trim().split(/\s+/);
|
||||
const value = rest.join(' ').trim();
|
||||
if (!rawKey || !value) {
|
||||
return 'Usage: /queue <mode|cap|overflow|debounce_ms|summarize_overflow> <value>';
|
||||
}
|
||||
const key = rawKey.toLowerCase();
|
||||
if (key === 'mode') {
|
||||
if (!['collect', 'followup', 'steer', 'steer_backlog', 'interrupt'].includes(value)) {
|
||||
return 'Invalid mode. Use one of: collect, followup, steer, steer_backlog, interrupt';
|
||||
}
|
||||
deps.sessionManager.setSessionConfig('ws', sessionId, 'queue.mode', value);
|
||||
return `Set queue.mode=${value} for this session`;
|
||||
}
|
||||
if (key === 'cap') {
|
||||
const cap = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(cap) || cap < 1 || cap > 1000) {
|
||||
return 'Invalid cap. Use an integer between 1 and 1000';
|
||||
}
|
||||
deps.sessionManager.setSessionConfig('ws', sessionId, 'queue.cap', String(cap));
|
||||
return `Set queue.cap=${cap} for this session`;
|
||||
}
|
||||
if (key === 'overflow') {
|
||||
if (value !== 'drop_old' && value !== 'drop_new') {
|
||||
return 'Invalid overflow. Use drop_old or drop_new';
|
||||
}
|
||||
deps.sessionManager.setSessionConfig('ws', sessionId, 'queue.overflow', value);
|
||||
return `Set queue.overflow=${value} for this session`;
|
||||
}
|
||||
if (key === 'debounce_ms') {
|
||||
const debounceMs = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(debounceMs) || debounceMs < 0 || debounceMs > 60_000) {
|
||||
return 'Invalid debounce_ms. Use an integer between 0 and 60000';
|
||||
}
|
||||
deps.sessionManager.setSessionConfig('ws', sessionId, 'queue.debounce_ms', String(debounceMs));
|
||||
return `Set queue.debounce_ms=${debounceMs} for this session`;
|
||||
}
|
||||
if (key === 'summarize_overflow') {
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized !== 'true' && normalized !== 'false') {
|
||||
return 'Invalid summarize_overflow. Use true or false';
|
||||
}
|
||||
deps.sessionManager.setSessionConfig('ws', sessionId, 'queue.summarize_overflow', normalized);
|
||||
return `Set queue.summarize_overflow=${normalized} for this session`;
|
||||
}
|
||||
return 'Unknown queue key. Use one of: mode, cap, overflow, debounce_ms, summarize_overflow';
|
||||
},
|
||||
|
||||
resetQueue: () => {
|
||||
if (!deps.sessionManager || !sessionId) {
|
||||
return 'Queue command is not available in this session.';
|
||||
}
|
||||
deps.sessionManager.deleteSessionConfig('ws', sessionId, 'queue.mode');
|
||||
deps.sessionManager.deleteSessionConfig('ws', sessionId, 'queue.cap');
|
||||
deps.sessionManager.deleteSessionConfig('ws', sessionId, 'queue.overflow');
|
||||
deps.sessionManager.deleteSessionConfig('ws', sessionId, 'queue.debounce_ms');
|
||||
deps.sessionManager.deleteSessionConfig('ws', sessionId, 'queue.summarize_overflow');
|
||||
return 'Reset session queue overrides.';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -320,7 +406,18 @@ export function createAgentHandlers(deps: AgentHandlerDeps) {
|
||||
deps.sessionBridge.setOnToolUse(connectionId, undefined);
|
||||
deps.metrics?.endRequest(requestId);
|
||||
}
|
||||
}, deps.resolveQueuePolicy?.({ laneId, sessionId, connectionId, channel }));
|
||||
}, resolvedPolicy);
|
||||
} catch (err) {
|
||||
if (err instanceof LaneQueueRejectedError) {
|
||||
send(makeEvent(request.id, 'error', {
|
||||
code: ErrorCode.AgentBusy,
|
||||
message: err.message,
|
||||
queue: err.details,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
'agent.cancel': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
|
||||
@@ -125,6 +125,31 @@ const PATCHABLE_KEYS: Record<string, (config: Config, value: unknown) => boolean
|
||||
config.server.localhost = value;
|
||||
return true;
|
||||
},
|
||||
'server.queue.mode': (config, value) => {
|
||||
if (!['collect', 'followup', 'steer', 'steer_backlog', 'interrupt'].includes(String(value))) {return false;}
|
||||
config.server.queue.mode = value as typeof config.server.queue.mode;
|
||||
return true;
|
||||
},
|
||||
'server.queue.cap': (config, value) => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > 1000) {return false;}
|
||||
config.server.queue.cap = Math.floor(value);
|
||||
return true;
|
||||
},
|
||||
'server.queue.overflow': (config, value) => {
|
||||
if (value !== 'drop_old' && value !== 'drop_new') {return false;}
|
||||
config.server.queue.overflow = value;
|
||||
return true;
|
||||
},
|
||||
'server.queue.debounce_ms': (config, value) => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 60_000) {return false;}
|
||||
config.server.queue.debounce_ms = Math.floor(value);
|
||||
return true;
|
||||
},
|
||||
'server.queue.summarize_overflow': (config, value) => {
|
||||
if (typeof value !== 'boolean') {return false;}
|
||||
config.server.queue.summarize_overflow = value;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
export function createConfigHandlers(deps: ConfigHandlerDeps) {
|
||||
|
||||
@@ -720,7 +720,18 @@ describe('config handlers', () => {
|
||||
function makeConfig() {
|
||||
return {
|
||||
telegram: { bot_token: 'secret-token-123', allowed_chat_ids: [12345] },
|
||||
server: { tailscale: {}, localhost: true, port: 18800 },
|
||||
server: {
|
||||
tailscale: {},
|
||||
localhost: true,
|
||||
port: 18800,
|
||||
queue: {
|
||||
mode: 'collect' as const,
|
||||
cap: 50,
|
||||
overflow: 'drop_old' as const,
|
||||
debounce_ms: 0,
|
||||
summarize_overflow: true,
|
||||
},
|
||||
},
|
||||
models: {
|
||||
default: { provider: 'anthropic' as const, model: 'claude-3-haiku', api_key: 'sk-secret-key' },
|
||||
fallback_chain: ['anthropic'],
|
||||
@@ -754,18 +765,22 @@ describe('config handlers', () => {
|
||||
patches: {
|
||||
'hooks.confirm': ['shell.exec', 'file.write'],
|
||||
'hooks.log': ['file.read'],
|
||||
'server.queue.mode': 'followup',
|
||||
'server.queue.debounce_ms': 100,
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await handlers['config.patch'](req) as GatewayResponse;
|
||||
|
||||
const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean };
|
||||
expect(r.applied).toEqual(['hooks.confirm', 'hooks.log']);
|
||||
expect(r.applied).toEqual(['hooks.confirm', 'hooks.log', 'server.queue.mode', 'server.queue.debounce_ms']);
|
||||
expect(r.rejected).toEqual([]);
|
||||
expect(r.persisted).toBe(false);
|
||||
// Verify the config was actually mutated
|
||||
expect(config.hooks.confirm).toEqual(['shell.exec', 'file.write']);
|
||||
expect(config.hooks.log).toEqual(['file.read']);
|
||||
expect(config.server.queue.mode).toBe('followup');
|
||||
expect(config.server.queue.debounce_ms).toBe(100);
|
||||
});
|
||||
|
||||
it('config.patch rejects unknown keys', async () => {
|
||||
@@ -798,6 +813,7 @@ describe('config handlers', () => {
|
||||
params: {
|
||||
patches: {
|
||||
'hooks.confirm': 'not-an-array',
|
||||
'server.queue.cap': 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -805,7 +821,7 @@ describe('config handlers', () => {
|
||||
|
||||
const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean };
|
||||
expect(r.applied).toEqual([]);
|
||||
expect(r.rejected).toEqual(['hooks.confirm']);
|
||||
expect(r.rejected).toEqual(['hooks.confirm', 'server.queue.cap']);
|
||||
expect(r.persisted).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user