mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 01:53:21 +08:00
750 lines
27 KiB
JavaScript
750 lines
27 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import test from 'node:test';
|
|
|
|
import { createBotWorkspaceScope } from '../../../src/channels/shared/bot-workspace-store.mjs';
|
|
import {
|
|
HarnessClient,
|
|
HarnessTurnError,
|
|
} from '../../../src/channels/shared/harness-client.mjs';
|
|
import {
|
|
OUTBOUND_ARTIFACT_TOOL,
|
|
createOutboundArtifactTool,
|
|
outboundArtifactRegistry,
|
|
} from '../../../src/channels/shared/semantic/artifact.mjs';
|
|
import { WORKSPACE_SESSION_STALE } from '../../../src/channels/shared/workspace-session.mjs';
|
|
|
|
const CATALOG = {
|
|
groups: [{
|
|
id: 'deepseek-official',
|
|
name: 'DeepSeek',
|
|
models: [{ id: 'deepseek-v4', name: 'DeepSeek V4' }],
|
|
}],
|
|
failures: [],
|
|
};
|
|
|
|
function deferred() {
|
|
let resolve;
|
|
const promise = new Promise((promiseResolve) => { resolve = promiseResolve; });
|
|
return { promise, resolve };
|
|
}
|
|
|
|
function modelClient(options = {}) {
|
|
const calls = [];
|
|
const responses = new Map([
|
|
['llm.models', CATALOG],
|
|
['session.models', {
|
|
...CATALOG,
|
|
current: { provider: 'deepseek-official', model: 'deepseek-v4' },
|
|
routable: true,
|
|
}],
|
|
['session.selectModel', {
|
|
selected: { provider: 'deepseek-official', model: 'deepseek-v4' },
|
|
}],
|
|
['session.list', {
|
|
items: [
|
|
{ sessionId: 'session-one', running: true },
|
|
{ sessionId: 'session-two', running: false },
|
|
],
|
|
}],
|
|
]);
|
|
const client = new HarnessClient({
|
|
baseUrl: 'http://127.0.0.1:3981',
|
|
workspace: '/tmp/workspace',
|
|
...options,
|
|
});
|
|
client.ensureRunning = async (options) => calls.push(['ensureRunning', options]);
|
|
client.rpc = async (method, payload, timeoutMs, options) => {
|
|
calls.push(['rpc', method, payload, timeoutMs, options]);
|
|
return responses.get(method);
|
|
};
|
|
return { calls, client, responses };
|
|
}
|
|
|
|
test('HarnessClient exposes and validates model and run-state RPCs', async () => {
|
|
const { calls, client } = modelClient();
|
|
const signal = new AbortController().signal;
|
|
const options = { signal };
|
|
|
|
assert.equal(await client.listModels(options), CATALOG);
|
|
assert.deepEqual((await client.getSessionModels('session-one', options)).current, {
|
|
provider: 'deepseek-official', model: 'deepseek-v4',
|
|
});
|
|
assert.deepEqual(await client.selectSessionModel('session-one', {
|
|
provider: 'deepseek-official', model: 'deepseek-v4',
|
|
}, options), {
|
|
selected: { provider: 'deepseek-official', model: 'deepseek-v4' },
|
|
});
|
|
assert.equal(await client.isSessionRunning('session-one', options), true);
|
|
assert.equal(await client.isSessionRunning('missing', options), false);
|
|
|
|
assert.deepEqual(calls.filter(([type]) => type === 'rpc').map(([, method, payload]) => (
|
|
[method, payload]
|
|
)), [
|
|
['llm.models', {}],
|
|
['session.models', { sessionId: 'session-one' }],
|
|
['session.selectModel', {
|
|
sessionId: 'session-one',
|
|
provider: 'deepseek-official',
|
|
model: 'deepseek-v4',
|
|
}],
|
|
['session.list', {}],
|
|
['session.list', {}],
|
|
]);
|
|
assert.ok(calls.filter(([type]) => type === 'ensureRunning').every(([, value]) => value === options));
|
|
});
|
|
|
|
test('HarnessClient preserves reasoning metadata and forwards an optional reasoning effort', async () => {
|
|
const { calls, client, responses } = modelClient();
|
|
const reasoningCatalog = {
|
|
groups: [{
|
|
id: 'deepseek-official',
|
|
name: 'DeepSeek',
|
|
models: [{
|
|
id: 'deepseek-v4',
|
|
name: 'DeepSeek V4',
|
|
description: 'Reasoning model',
|
|
reasoning: {
|
|
efforts: [
|
|
{ id: 'off', name: 'Off' },
|
|
{ id: 'high', name: 'High', description: 'More thinking' },
|
|
],
|
|
defaultEffort: 'high',
|
|
},
|
|
}],
|
|
}],
|
|
failures: [],
|
|
};
|
|
responses.set('llm.models', reasoningCatalog);
|
|
responses.set('session.models', {
|
|
...reasoningCatalog,
|
|
current: {
|
|
provider: 'deepseek-official',
|
|
model: 'deepseek-v4',
|
|
reasoningEffort: 'high',
|
|
},
|
|
routable: true,
|
|
});
|
|
responses.set('session.selectModel', {
|
|
selected: {
|
|
provider: 'deepseek-official',
|
|
model: 'deepseek-v4',
|
|
reasoningEffort: 'off',
|
|
},
|
|
});
|
|
|
|
assert.equal((await client.listModels()).groups[0].models[0].reasoning.defaultEffort, 'high');
|
|
assert.equal((await client.getSessionModels('session-one')).current.reasoningEffort, 'high');
|
|
await client.selectSessionModel('session-one', {
|
|
provider: 'deepseek-official',
|
|
model: 'deepseek-v4',
|
|
reasoningEffort: 'off',
|
|
});
|
|
|
|
assert.deepEqual(
|
|
calls.find((entry) => entry[0] === 'rpc' && entry[1] === 'session.selectModel')?.[2],
|
|
{
|
|
sessionId: 'session-one',
|
|
provider: 'deepseek-official',
|
|
model: 'deepseek-v4',
|
|
reasoningEffort: 'off',
|
|
},
|
|
);
|
|
});
|
|
|
|
test('HarnessClient rejects malformed model and run-state responses', async () => {
|
|
const { client, responses } = modelClient();
|
|
responses.set('llm.models', { groups: null, failures: [] });
|
|
await assert.rejects(client.listModels(), /invalid response for llm\.models/);
|
|
|
|
responses.set('llm.models', {
|
|
...CATALOG,
|
|
groups: [{
|
|
...CATALOG.groups[0],
|
|
models: [{
|
|
...CATALOG.groups[0].models[0],
|
|
reasoning: { efforts: [] },
|
|
}],
|
|
}],
|
|
});
|
|
await assert.rejects(client.listModels(), /invalid response for llm\.models/);
|
|
|
|
responses.set('session.models', {
|
|
...CATALOG,
|
|
current: { provider: 'deepseek-official' },
|
|
routable: true,
|
|
});
|
|
await assert.rejects(
|
|
client.getSessionModels('session-one'),
|
|
/invalid response for session\.models/,
|
|
);
|
|
|
|
responses.set('session.models', {
|
|
...CATALOG,
|
|
current: {
|
|
provider: 'deepseek-official',
|
|
model: 'deepseek-v4',
|
|
reasoningEffort: '',
|
|
},
|
|
routable: true,
|
|
});
|
|
await assert.rejects(
|
|
client.getSessionModels('session-one'),
|
|
/invalid response for session\.models/,
|
|
);
|
|
|
|
responses.set('session.selectModel', { selected: { provider: '', model: 'bad' } });
|
|
await assert.rejects(
|
|
client.selectSessionModel('session-one', { provider: 'p', model: 'm' }),
|
|
/invalid response for session\.selectModel/,
|
|
);
|
|
|
|
await assert.rejects(
|
|
client.selectSessionModel('session-one', {
|
|
provider: 'deepseek-official',
|
|
model: 'deepseek-v4',
|
|
reasoningEffort: '',
|
|
}),
|
|
/provider and model are required/,
|
|
);
|
|
|
|
responses.set('session.list', { items: [{ sessionId: 'session-one', running: 'yes' }] });
|
|
await assert.rejects(client.isSessionRunning('session-one'), /invalid response for session\.list/);
|
|
});
|
|
|
|
test('selectSessionModel holds the maintenance executor across its RPC', async () => {
|
|
const maintenanceController = new AbortController();
|
|
const stages = [];
|
|
const { calls, client } = modelClient({
|
|
sessionMaintenanceExecutor({ sessionId, operation }) {
|
|
stages.push(['maintenance', sessionId]);
|
|
const result = operation(maintenanceController.signal);
|
|
stages.push(['operation-started']);
|
|
return result;
|
|
},
|
|
});
|
|
const callerController = new AbortController();
|
|
await client.selectSessionModel('session-one', {
|
|
provider: 'deepseek-official', model: 'deepseek-v4',
|
|
}, { signal: callerController.signal });
|
|
|
|
assert.deepEqual(stages, [
|
|
['maintenance', 'session-one'],
|
|
['operation-started'],
|
|
]);
|
|
const rpcOptions = calls.find((entry) => entry[0] === 'rpc' && entry[1] === 'session.selectModel')[4];
|
|
assert.notEqual(rpcOptions.signal, callerController.signal);
|
|
assert.equal(rpcOptions.signal.aborted, false);
|
|
maintenanceController.abort();
|
|
assert.equal(rpcOptions.signal.aborted, true, 'maintenance cancellation must abort the RPC');
|
|
});
|
|
|
|
function controlledTurn({ sessionId, initialEnd = false, controlExecutor } = {}) {
|
|
const id = sessionId ?? `session-${Math.random()}`;
|
|
const calls = [];
|
|
const admitted = deferred();
|
|
let promptRpcId = null;
|
|
let ended = initialEnd;
|
|
let answer = initialEnd ? 'already complete' : '';
|
|
let endReason = initialEnd ? 'completed' : null;
|
|
let historyFailure = null;
|
|
const client = new HarnessClient({
|
|
baseUrl: 'http://127.0.0.1:3982',
|
|
workspace: '/tmp/workspace',
|
|
...(controlExecutor ? { controlExecutor } : {}),
|
|
});
|
|
client.ensureRunning = async () => true;
|
|
const history = () => {
|
|
if (!promptRpcId) return { events: [] };
|
|
const events = [
|
|
{ event: { seq: 1, type: 'turn/start', data: { turn: 7 } } },
|
|
{ event: {
|
|
seq: 2,
|
|
type: 'user/message',
|
|
data: { turn: 7, source: { rpcId: promptRpcId } },
|
|
} },
|
|
];
|
|
if (answer) {
|
|
events.push({ event: {
|
|
seq: 3,
|
|
type: 'assistant/message',
|
|
data: {
|
|
turn: 7,
|
|
message: { content: [{ type: 'text', text: answer }] },
|
|
},
|
|
} });
|
|
}
|
|
if (ended) {
|
|
events.push({ event: {
|
|
seq: 4,
|
|
type: 'turn/end',
|
|
data: { turn: 7, reason: endReason },
|
|
} });
|
|
}
|
|
return { events };
|
|
};
|
|
client.rpc = async (method, payload, _timeoutMs, options) => {
|
|
calls.push({ method, payload, options });
|
|
if (method === 'session.history') {
|
|
if (historyFailure) throw historyFailure;
|
|
return history();
|
|
}
|
|
if (method === 'session.prompt' && payload.mode === 'queue') {
|
|
promptRpcId = options.rpcId;
|
|
admitted.resolve();
|
|
return {};
|
|
}
|
|
if (method === 'session.prompt' && payload.mode === 'steer') return {};
|
|
if (method === 'session.cancel') return {};
|
|
throw new Error(`Unexpected RPC ${method}`);
|
|
};
|
|
return {
|
|
id,
|
|
calls,
|
|
client,
|
|
admitted: admitted.promise,
|
|
promptRpcId: () => promptRpcId,
|
|
setText(text) { answer = text; },
|
|
failHistory(error = new Error('history unavailable')) { historyFailure = error; },
|
|
finish({ text = '', reason = 'cancelled' } = {}) {
|
|
answer = text;
|
|
endReason = reason;
|
|
ended = true;
|
|
},
|
|
};
|
|
}
|
|
|
|
test('HarnessClient preserves structured turn failures for channel classification', async () => {
|
|
for (const [reason, expectedCode, expectedProviderCode] of [
|
|
[{ kind: 'error', error: { code: 'RATE_LIMIT', message: 'private provider detail' } },
|
|
'harness-turn-failed', 'RATE_LIMIT'],
|
|
[{ kind: 'max-tokens' }, 'model-max-tokens', undefined],
|
|
[{ kind: 'completed' }, 'model-empty-response', undefined],
|
|
['completed', 'model-empty-response', undefined],
|
|
['stopped', 'turn-interrupted', undefined],
|
|
]) {
|
|
const turn = controlledTurn();
|
|
const asking = turn.client.ask(turn.id, 'work', { timeoutMs: 2_000 });
|
|
await turn.admitted;
|
|
turn.finish({ reason });
|
|
await assert.rejects(asking, (error) => {
|
|
assert.ok(error instanceof HarnessTurnError);
|
|
assert.equal(error.code, expectedCode);
|
|
assert.equal(error.providerCode, expectedProviderCode);
|
|
assert.equal(error.promptAccepted, true);
|
|
assert.doesNotMatch(error.message, /private provider detail/);
|
|
return true;
|
|
});
|
|
}
|
|
});
|
|
|
|
test('HarnessClient does not treat partial text from a failed turn as success', async () => {
|
|
for (const [reason, expectedCode, expectedProviderCode] of [
|
|
[{ kind: 'error', error: { code: 'RATE_LIMIT' } },
|
|
'harness-turn-failed', 'RATE_LIMIT'],
|
|
[{ kind: 'error', error: { code: 'CONTENT_FILTER' } },
|
|
'harness-turn-failed', 'CONTENT_FILTER'],
|
|
[{ kind: 'max-tokens' }, 'model-max-tokens', undefined],
|
|
[{ kind: 'blocked' }, 'turn-blocked', undefined],
|
|
['interrupted', 'turn-interrupted', undefined],
|
|
['stopped', 'turn-interrupted', undefined],
|
|
['cancelled', 'turn-interrupted', undefined],
|
|
['aborted', 'turn-aborted', undefined],
|
|
]) {
|
|
const turn = controlledTurn();
|
|
const asking = turn.client.ask(turn.id, 'work', { timeoutMs: 2_000 });
|
|
await turn.admitted;
|
|
turn.finish({ text: 'partial result must not escape', reason });
|
|
await assert.rejects(asking, (error) => {
|
|
assert.ok(error instanceof HarnessTurnError);
|
|
assert.equal(error.code, expectedCode);
|
|
assert.equal(error.providerCode, expectedProviderCode);
|
|
return true;
|
|
});
|
|
}
|
|
});
|
|
|
|
test('HarnessClient accepts partial text only for completed or omitted end reasons', async () => {
|
|
for (const reason of ['completed', { kind: 'completed' }, null]) {
|
|
const turn = controlledTurn();
|
|
const asking = turn.client.ask(turn.id, 'work', { timeoutMs: 2_000 });
|
|
await turn.admitted;
|
|
turn.finish({ text: 'valid partial result', reason });
|
|
assert.equal(await asking, 'valid partial result');
|
|
}
|
|
});
|
|
|
|
test('HarnessClient marks a reply timeout after prompt admission', async () => {
|
|
const turn = controlledTurn();
|
|
const asking = turn.client.ask(turn.id, 'work', { timeoutMs: 1 });
|
|
await turn.admitted;
|
|
await assert.rejects(asking, (error) => {
|
|
assert.ok(error instanceof HarnessTurnError);
|
|
assert.equal(error.code, 'harness-reply-timeout');
|
|
assert.equal(error.promptAccepted, true);
|
|
return true;
|
|
});
|
|
});
|
|
|
|
test('control methods require exact owner identity, key, and Session before any RPC', async () => {
|
|
const turn = controlledTurn();
|
|
const owner = {};
|
|
const control = { owner, key: 'direct:one' };
|
|
const asking = turn.client.ask(turn.id, 'work', { control, timeoutMs: 2_000 });
|
|
void asking.catch(() => undefined);
|
|
await turn.admitted;
|
|
|
|
for (const wrong of [
|
|
{ owner: {}, key: 'direct:one' },
|
|
{ owner, key: 'direct:two' },
|
|
null,
|
|
]) {
|
|
const before = turn.calls.length;
|
|
assert.equal(await turn.client.hasActiveTurn(turn.id, wrong), false);
|
|
assert.equal(await turn.client.stopActiveTurn(turn.id, wrong), false);
|
|
assert.equal(await turn.client.steerActiveTurn(turn.id, 'do more', wrong), false);
|
|
assert.equal(turn.calls.length, before, 'unowned controls must not observe or mutate Session');
|
|
}
|
|
const beforeOtherSession = turn.calls.length;
|
|
assert.equal(await turn.client.stopActiveTurn('different-session', control), false);
|
|
assert.equal(turn.calls.length, beforeOtherSession);
|
|
|
|
assert.equal(await turn.client.hasActiveTurn(turn.id, control), true);
|
|
assert.equal(await turn.client.stopActiveTurn(turn.id, control), true);
|
|
const cancelCount = () => turn.calls.filter(({ method }) => method === 'session.cancel').length;
|
|
assert.equal(cancelCount(), 1);
|
|
assert.equal(await turn.client.stopActiveTurn(turn.id, control), true, 'stop is idempotent');
|
|
assert.equal(cancelCount(), 1, 'an idempotent stop must not issue a second cancel RPC');
|
|
|
|
turn.finish();
|
|
await assert.rejects(asking, (error) => error?.code === 'turn-stopped');
|
|
assert.deepEqual(
|
|
turn.calls.find(({ method }) => method === 'session.cancel')?.payload,
|
|
{ sessionId: turn.id, keepInbox: true },
|
|
);
|
|
});
|
|
|
|
test('a stopped turn returns partial text instead of a generic failure', async () => {
|
|
const turn = controlledTurn();
|
|
const control = { owner: {}, key: 'direct:partial' };
|
|
const asking = turn.client.ask(turn.id, 'work', { control, timeoutMs: 2_000 });
|
|
await turn.admitted;
|
|
assert.equal(await turn.client.stopActiveTurn(turn.id, control), true);
|
|
turn.finish({ text: 'partial result' });
|
|
assert.equal(await asking, 'partial result');
|
|
});
|
|
|
|
test('an accepted stop preserves partial text but never hands off a registered artifact', async (t) => {
|
|
outboundArtifactRegistry.clear();
|
|
t.after(() => outboundArtifactRegistry.clear());
|
|
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-stopped-artifact-'));
|
|
t.after(() => rm(workspace, { recursive: true, force: true }));
|
|
const turn = controlledTurn({ sessionId: 'session-stopped-artifact' });
|
|
const control = { owner: {}, key: 'direct:stopped-artifact' };
|
|
const handedOff = [];
|
|
const asking = turn.client.ask(turn.id, 'work', {
|
|
control,
|
|
timeoutMs: 2_000,
|
|
onArtifact: async (artifact) => handedOff.push(artifact),
|
|
});
|
|
await turn.admitted;
|
|
const agent = {
|
|
session: {
|
|
header: { id: turn.id, cwd: workspace },
|
|
events: [
|
|
{ type: 'turn/start', data: { turn: 7 } },
|
|
{
|
|
type: 'user/message',
|
|
data: { turn: 7, source: { rpcId: turn.promptRpcId() } },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
await writeFile(join(workspace, 'must-not-send.txt'), 'cancelled result');
|
|
const tool = createOutboundArtifactTool({ registry: outboundArtifactRegistry });
|
|
const exec = {
|
|
name: OUTBOUND_ARTIFACT_TOOL,
|
|
callId: 'stopped-artifact-call',
|
|
rootCallId: 'stopped-artifact-call',
|
|
token: Symbol('stopped-artifact-call'),
|
|
agent,
|
|
};
|
|
await tool.definition.execute({ path: 'must-not-send.txt' }, exec);
|
|
tool.onResult(exec, { isError: false });
|
|
|
|
assert.equal(await turn.client.stopActiveTurn(turn.id, control), true);
|
|
turn.finish({ text: 'partial result', reason: 'stopped' });
|
|
assert.equal(await asking, 'partial result');
|
|
assert.deepEqual(handedOff, []);
|
|
assert.deepEqual(outboundArtifactRegistry.take(turn.id, 7), []);
|
|
});
|
|
|
|
test('an accepted stop also discards a file-only result instead of handing it off', async (t) => {
|
|
outboundArtifactRegistry.clear();
|
|
t.after(() => outboundArtifactRegistry.clear());
|
|
const workspace = await mkdtemp(join(tmpdir(), 'dsh-im-stopped-file-only-'));
|
|
t.after(() => rm(workspace, { recursive: true, force: true }));
|
|
const turn = controlledTurn({ sessionId: 'session-stopped-file-only' });
|
|
const control = { owner: {}, key: 'direct:stopped-file-only' };
|
|
const handedOff = [];
|
|
const asking = turn.client.ask(turn.id, 'work', {
|
|
control,
|
|
timeoutMs: 2_000,
|
|
onArtifact: async (artifact) => handedOff.push(artifact),
|
|
});
|
|
await turn.admitted;
|
|
const agent = {
|
|
session: {
|
|
header: { id: turn.id, cwd: workspace },
|
|
events: [
|
|
{ type: 'turn/start', data: { turn: 7 } },
|
|
{
|
|
type: 'user/message',
|
|
data: { turn: 7, source: { rpcId: turn.promptRpcId() } },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
await writeFile(join(workspace, 'must-not-send.txt'), 'cancelled file-only result');
|
|
const tool = createOutboundArtifactTool({ registry: outboundArtifactRegistry });
|
|
const exec = {
|
|
name: OUTBOUND_ARTIFACT_TOOL,
|
|
callId: 'stopped-file-only-call',
|
|
rootCallId: 'stopped-file-only-call',
|
|
token: Symbol('stopped-file-only-call'),
|
|
agent,
|
|
};
|
|
await tool.definition.execute({ path: 'must-not-send.txt' }, exec);
|
|
tool.onResult(exec, { isError: false });
|
|
|
|
assert.equal(await turn.client.stopActiveTurn(turn.id, control), true);
|
|
turn.finish({ reason: 'stopped' });
|
|
await assert.rejects(asking, (error) => error?.code === 'turn-stopped');
|
|
assert.deepEqual(handedOff, []);
|
|
assert.deepEqual(outboundArtifactRegistry.take(turn.id, 7), []);
|
|
});
|
|
|
|
test('accepted stop converts later polling failures to turn-stopped and preserves streamed text', async () => {
|
|
const withoutText = controlledTurn();
|
|
const firstControl = { owner: {}, key: 'direct:poll-error' };
|
|
const firstAsk = withoutText.client.ask(withoutText.id, 'work', {
|
|
control: firstControl, timeoutMs: 2_000,
|
|
});
|
|
await withoutText.admitted;
|
|
assert.equal(await withoutText.client.stopActiveTurn(withoutText.id, firstControl), true);
|
|
withoutText.failHistory();
|
|
await assert.rejects(firstAsk, (error) => error?.code === 'turn-stopped');
|
|
|
|
const withText = controlledTurn();
|
|
const secondControl = { owner: {}, key: 'direct:partial-poll-error' };
|
|
const updateSeen = deferred();
|
|
const secondAsk = withText.client.ask(withText.id, 'work', {
|
|
control: secondControl,
|
|
timeoutMs: 2_000,
|
|
onUpdate(update) {
|
|
if (update.type === 'text') updateSeen.resolve();
|
|
},
|
|
});
|
|
await withText.admitted;
|
|
withText.setText('partial before transport failure');
|
|
await updateSeen.promise;
|
|
assert.equal(await withText.client.stopActiveTurn(withText.id, secondControl), true);
|
|
withText.failHistory();
|
|
assert.equal(await secondAsk, 'partial before transport failure');
|
|
});
|
|
|
|
test('in-process control executor receives exact ownership and suppresses control RPCs', async () => {
|
|
const executions = [];
|
|
const turn = controlledTurn({
|
|
controlExecutor(request) {
|
|
executions.push(request);
|
|
return true;
|
|
},
|
|
});
|
|
const control = { owner: {}, key: 'direct:executor' };
|
|
const asking = turn.client.ask(turn.id, 'work', { control, timeoutMs: 2_000 });
|
|
await turn.admitted;
|
|
|
|
assert.equal(await turn.client.steerActiveTurn(turn.id, 'stay in this turn', control), true);
|
|
assert.equal(await turn.client.stopActiveTurn(turn.id, control), true);
|
|
assert.deepEqual(executions.map(({ sessionId, expectedTurn, action, text }) => ({
|
|
sessionId, expectedTurn, action, text,
|
|
})), [
|
|
{ sessionId: turn.id, expectedTurn: 7, action: 'steer', text: 'stay in this turn' },
|
|
{ sessionId: turn.id, expectedTurn: 7, action: 'stop', text: undefined },
|
|
]);
|
|
assert.ok(executions.every(({ promptRpcId }) => typeof promptRpcId === 'string' && promptRpcId));
|
|
assert.equal(turn.calls.some(({ method, payload }) => (
|
|
method === 'session.cancel' || (method === 'session.prompt' && payload.mode === 'steer')
|
|
)), false);
|
|
|
|
turn.finish();
|
|
await assert.rejects(asking, (error) => error?.code === 'turn-stopped');
|
|
});
|
|
|
|
test('a refused in-process control action never falls through to HTTP', async () => {
|
|
const turn = controlledTurn({ controlExecutor: () => false });
|
|
const control = { owner: {}, key: 'direct:executor-race' };
|
|
const asking = turn.client.ask(turn.id, 'work', { control, timeoutMs: 2_000 });
|
|
await turn.admitted;
|
|
assert.equal(await turn.client.steerActiveTurn(turn.id, 'must not wake', control), false);
|
|
assert.equal(await turn.client.stopActiveTurn(turn.id, control), false);
|
|
assert.equal(turn.calls.some(({ method, payload }) => (
|
|
method === 'session.cancel' || (method === 'session.prompt' && payload.mode === 'steer')
|
|
)), false);
|
|
turn.finish({ text: 'normal completion', reason: 'completed' });
|
|
assert.equal(await asking, 'normal completion');
|
|
});
|
|
|
|
test('steer uses mode steer only while the exact owned turn is still active', async () => {
|
|
const turn = controlledTurn();
|
|
const control = { owner: {}, key: 'direct:steer' };
|
|
const asking = turn.client.ask(turn.id, 'work', { control, timeoutMs: 2_000 });
|
|
await turn.admitted;
|
|
|
|
assert.equal(await turn.client.steerActiveTurn(
|
|
turn.id,
|
|
'first line\nsecond line',
|
|
control,
|
|
), true);
|
|
const steer = turn.calls.find(({ method, payload }) => (
|
|
method === 'session.prompt' && payload.mode === 'steer'
|
|
));
|
|
assert.deepEqual(steer.payload, {
|
|
sessionId: turn.id,
|
|
mode: 'steer',
|
|
content: [{ type: 'text', text: 'first line\nsecond line' }],
|
|
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
});
|
|
|
|
turn.finish({ text: 'done', reason: 'completed' });
|
|
assert.equal(await asking, 'done');
|
|
});
|
|
|
|
test('steer refuses a turn that ended during its ownership refresh', async () => {
|
|
const turn = controlledTurn({ initialEnd: true });
|
|
const control = { owner: {}, key: 'direct:ended' };
|
|
const asking = turn.client.ask(turn.id, 'work', { control, timeoutMs: 2_000 });
|
|
await turn.admitted;
|
|
|
|
assert.equal(await turn.client.steerActiveTurn(turn.id, 'must not wake', control), false);
|
|
assert.equal(turn.calls.some(({ method, payload }) => (
|
|
method === 'session.prompt' && payload.mode === 'steer'
|
|
)), false);
|
|
assert.equal(await asking, 'already complete');
|
|
});
|
|
|
|
function scopedHarnessFixture() {
|
|
const calls = [];
|
|
let generation = 1;
|
|
let present = true;
|
|
const workspaces = {
|
|
incarnationFor() { return 'incarnation-one'; },
|
|
has() { return present; },
|
|
generationFor() { return generation; },
|
|
workspaceFor() { return '/tmp/workspace'; },
|
|
};
|
|
const harness = {
|
|
async listModels(...args) {
|
|
calls.push(['listModels', ...args]);
|
|
return CATALOG;
|
|
},
|
|
async getSessionModels(...args) {
|
|
calls.push(['getSessionModels', ...args]);
|
|
return { ...CATALOG, current: { provider: 'p', model: 'm' }, routable: true };
|
|
},
|
|
async selectSessionModel(...args) {
|
|
calls.push(['selectSessionModel', ...args]);
|
|
return { selected: args[1] };
|
|
},
|
|
async isSessionRunning(...args) {
|
|
calls.push(['isSessionRunning', ...args]);
|
|
return true;
|
|
},
|
|
async hasActiveTurn(...args) {
|
|
calls.push(['hasActiveTurn', ...args]);
|
|
return true;
|
|
},
|
|
async stopActiveTurn(...args) {
|
|
calls.push(['stopActiveTurn', ...args]);
|
|
return true;
|
|
},
|
|
async steerActiveTurn(...args) {
|
|
calls.push(['steerActiveTurn', ...args]);
|
|
return true;
|
|
},
|
|
};
|
|
const scope = createBotWorkspaceScope(harness, {
|
|
botId: 'bot-one', workspaces, state: {},
|
|
});
|
|
return {
|
|
calls,
|
|
harness,
|
|
scope,
|
|
advanceGeneration() { generation += 1; },
|
|
removeBot() { present = false; },
|
|
};
|
|
}
|
|
|
|
test('workspace session handles forward every model and control API through one generation fence', async () => {
|
|
const fixture = scopedHarnessFixture();
|
|
const session = fixture.scope.harness.workspaceSession('session-one');
|
|
const signal = new AbortController().signal;
|
|
const options = { signal };
|
|
const control = { owner: {}, key: 'direct:one' };
|
|
|
|
await session.models(options);
|
|
await session.selectModel({ provider: 'p', model: 'm' }, options);
|
|
assert.equal(await session.isRunning(options), true);
|
|
assert.equal(await session.hasActiveTurn(control, options), true);
|
|
assert.equal(await session.stopActiveTurn(control, options), true);
|
|
assert.equal(await session.steerActiveTurn('more', control, options), true);
|
|
assert.deepEqual(fixture.calls, [
|
|
['getSessionModels', 'session-one', options],
|
|
['selectSessionModel', 'session-one', { provider: 'p', model: 'm' }, options],
|
|
['isSessionRunning', 'session-one', options],
|
|
['hasActiveTurn', 'session-one', control, options],
|
|
['stopActiveTurn', 'session-one', control, options],
|
|
['steerActiveTurn', 'session-one', 'more', control, options],
|
|
]);
|
|
|
|
const methods = [
|
|
['models', [options]],
|
|
['selectModel', [{ provider: 'p', model: 'm' }, options]],
|
|
['isRunning', [options]],
|
|
['hasActiveTurn', [control, options]],
|
|
['stopActiveTurn', [control, options]],
|
|
['steerActiveTurn', ['more', control, options]],
|
|
];
|
|
for (const [method, args] of methods) {
|
|
const stale = fixture.scope.harness.workspaceSession(`stale-${method}`);
|
|
fixture.advanceGeneration();
|
|
const before = fixture.calls.length;
|
|
await assert.rejects(
|
|
stale[method](...args),
|
|
(error) => error?.code === WORKSPACE_SESSION_STALE,
|
|
method,
|
|
);
|
|
assert.equal(fixture.calls.length, before, `${method} crossed a stale generation`);
|
|
}
|
|
});
|
|
|
|
test('workspace session and listModels re-check their scope after an in-flight RPC', async () => {
|
|
const fixture = scopedHarnessFixture();
|
|
const modelGate = deferred();
|
|
fixture.harness.getSessionModels = async () => modelGate.promise;
|
|
const session = fixture.scope.harness.workspaceSession('session-race');
|
|
const listing = session.models();
|
|
fixture.advanceGeneration();
|
|
modelGate.resolve({ ...CATALOG, current: { provider: 'p', model: 'm' }, routable: true });
|
|
await assert.rejects(listing, (error) => error?.code === WORKSPACE_SESSION_STALE);
|
|
|
|
const listGate = deferred();
|
|
fixture.harness.listModels = async () => listGate.promise;
|
|
const globalListing = fixture.scope.harness.listModels();
|
|
fixture.removeBot();
|
|
listGate.resolve(CATALOG);
|
|
await assert.rejects(globalListing, (error) => error?.code === 'workspace-bot-not-found');
|
|
});
|