feat: bind bot chats to existing harness sessions

This commit is contained in:
xmanrui 2026-08-18 02:59:29 +08:00
parent 76d5430f1f
commit 1af8610cd5
21 changed files with 1807 additions and 130 deletions

View file

@ -166,6 +166,162 @@ test('HarnessClient lists sessions by workspace accounting and forwards request
);
});
test('HarnessClient adopts one registered ordinary session and forwards request options', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/default-workspace',
agentPreset: 'custom-preset',
});
const options = {
signal: new AbortController().signal,
rpcId: 'dingtalk-session-adopt',
};
const calls = [];
client.ensureRunning = async (received) => {
calls.push({ method: 'ensureRunning', options: received });
};
client.rpc = async (method, payload, timeoutMs, rpcOptions) => {
calls.push({ method, payload, timeoutMs, options: rpcOptions });
if (method === 'workspace.list') {
return {
items: [
{
workspaceId: 'workspace-target',
path: '/tmp/target',
sessionIds: ['session-other', 'session-target'],
},
{ workspaceId: 'workspace-unsafe', path: '/tmp/unsafe\u202e', sessionIds: [] },
{ workspaceId: 'workspace-other', path: '/tmp/other', sessionIds: [] },
],
archivedSessionIds: ['session-target'],
};
}
if (method === 'session.list') {
return {
items: [{
sessionId: 'session-target',
projections: { values: { title: 'Existing conversation' } },
}],
};
}
assert.equal(method, 'session.create');
return { sessionId: 'session-target', agentPreset: 'persisted-preset' };
};
assert.deepEqual(await client.adoptWorkspaceSession('session-target', options), {
sessionId: 'session-target',
workspace: '/tmp/target',
title: 'Existing conversation',
archived: true,
});
assert.deepEqual(calls, [
{ method: 'ensureRunning', options },
{ method: 'workspace.list', payload: {}, timeoutMs: 30_000, options },
{ method: 'session.list', payload: {}, timeoutMs: 30_000, options },
{
method: 'session.create',
payload: { workspaceId: 'workspace-target', sessionId: 'session-target' },
timeoutMs: 30_000,
options,
},
]);
});
test('HarnessClient safely rejects invalid, unregistered, ambiguous, and subagent adoption', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/default-workspace',
});
let mode = 'unregistered';
let createCalls = 0;
client.ensureRunning = async () => undefined;
client.rpc = async (method) => {
if (method === 'workspace.list') {
if (mode === 'invalid-workspaces') return { items: null, archivedSessionIds: [] };
if (mode === 'unregistered') {
return {
items: [{ workspaceId: 'workspace', path: '/tmp/workspace', sessionIds: [] }],
archivedSessionIds: [],
};
}
return {
items: [
{
workspaceId: 'workspace-one',
path: '/tmp/one',
sessionIds: ['session-target'],
},
...(mode === 'ambiguous' ? [{
workspaceId: 'workspace-two',
path: '/tmp/two',
sessionIds: ['session-target'],
}] : []),
],
archivedSessionIds: [],
};
}
if (method === 'session.list') {
if (mode === 'summary-missing') return { items: [] };
return {
items: [{
sessionId: 'session-target',
...(mode === 'subagent' ? { origin: 'subagent' } : {}),
}],
};
}
createCalls += 1;
return mode === 'bad-create'
? { sessionId: 'different-session' }
: { sessionId: 'session-target' };
};
for (const invalid of [
undefined,
'',
' ',
'session target',
'session\u0000target',
's'.repeat(257),
]) {
await assert.rejects(
client.adoptWorkspaceSession(invalid),
(error) => error?.code === 'session-id-invalid',
);
}
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
(error) => error?.code === 'session-not-registered',
);
mode = 'ambiguous';
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
(error) => error?.code === 'session-workspace-ambiguous',
);
mode = 'summary-missing';
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
(error) => error?.code === 'session-summary-unavailable',
);
mode = 'subagent';
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
(error) => error?.code === 'session-subagent-unsupported',
);
assert.equal(createCalls, 0);
mode = 'invalid-workspaces';
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
/invalid response for workspace\.list/,
);
mode = 'bad-create';
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
/invalid response for session\.create/,
);
assert.equal(createCalls, 1);
});
test('reply tracker associates only the Harness turn created by the DingTalk prompt RPC', () => {
const tracker = new HarnessReplyTracker({ promptRpcId: 'dingtalk-prompt', afterSeq: 2 });
const update = tracker.consume([

View file

@ -156,6 +156,162 @@ test('HarnessClient lists sessions by workspace accounting in its stored order',
);
});
test('HarnessClient adopts one registered ordinary session without changing its preset', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/default-workspace',
agentPreset: 'custom-preset',
});
const options = {
signal: new AbortController().signal,
rpcId: 'feishu-session-adopt',
};
const calls = [];
client.ensureRunning = async (received) => {
calls.push({ method: 'ensureRunning', options: received });
};
client.rpc = async (method, payload, timeoutMs, rpcOptions) => {
calls.push({ method, payload, timeoutMs, options: rpcOptions });
if (method === 'workspace.list') {
return {
items: [
{
workspaceId: 'workspace-target',
path: '/tmp/target',
sessionIds: ['session-other', 'session-target'],
},
{ workspaceId: 'workspace-unsafe', path: '/tmp/unsafe\u202e', sessionIds: [] },
{ workspaceId: 'workspace-other', path: '/tmp/other', sessionIds: [] },
],
archivedSessionIds: ['session-target'],
};
}
if (method === 'session.list') {
return {
items: [{
sessionId: 'session-target',
projections: { values: { title: 'Existing conversation' } },
}],
};
}
assert.equal(method, 'session.create');
return { sessionId: 'session-target', agentPreset: 'persisted-preset' };
};
assert.deepEqual(await client.adoptWorkspaceSession('session-target', options), {
sessionId: 'session-target',
workspace: '/tmp/target',
title: 'Existing conversation',
archived: true,
});
assert.deepEqual(calls, [
{ method: 'ensureRunning', options },
{ method: 'workspace.list', payload: {}, timeoutMs: 30000, options },
{ method: 'session.list', payload: {}, timeoutMs: 30000, options },
{
method: 'session.create',
payload: { workspaceId: 'workspace-target', sessionId: 'session-target' },
timeoutMs: 30000,
options,
},
]);
});
test('HarnessClient safely rejects invalid, unregistered, ambiguous, and subagent adoption', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/default-workspace',
});
let mode = 'unregistered';
let createCalls = 0;
client.ensureRunning = async () => undefined;
client.rpc = async (method) => {
if (method === 'workspace.list') {
if (mode === 'invalid-workspaces') return { items: null, archivedSessionIds: [] };
if (mode === 'unregistered') {
return {
items: [{ workspaceId: 'workspace', path: '/tmp/workspace', sessionIds: [] }],
archivedSessionIds: [],
};
}
return {
items: [
{
workspaceId: 'workspace-one',
path: '/tmp/one',
sessionIds: ['session-target'],
},
...(mode === 'ambiguous' ? [{
workspaceId: 'workspace-two',
path: '/tmp/two',
sessionIds: ['session-target'],
}] : []),
],
archivedSessionIds: [],
};
}
if (method === 'session.list') {
if (mode === 'summary-missing') return { items: [] };
return {
items: [{
sessionId: 'session-target',
...(mode === 'subagent' ? { origin: 'subagent' } : {}),
}],
};
}
createCalls += 1;
return mode === 'bad-create'
? { sessionId: 'different-session' }
: { sessionId: 'session-target' };
};
for (const invalid of [
undefined,
'',
' ',
'session target',
'session\u0000target',
's'.repeat(257),
]) {
await assert.rejects(
client.adoptWorkspaceSession(invalid),
(error) => error?.code === 'session-id-invalid',
);
}
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
(error) => error?.code === 'session-not-registered',
);
mode = 'ambiguous';
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
(error) => error?.code === 'session-workspace-ambiguous',
);
mode = 'summary-missing';
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
(error) => error?.code === 'session-summary-unavailable',
);
mode = 'subagent';
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
(error) => error?.code === 'session-subagent-unsupported',
);
assert.equal(createCalls, 0);
mode = 'invalid-workspaces';
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
/invalid response for workspace\.list/,
);
mode = 'bad-create';
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
/invalid response for session\.create/,
);
assert.equal(createCalls, 1);
});
test('HarnessClient reads the nested workspace.create response used by DSH rc.6', async (t) => {
const methods = [];
t.mock.method(globalThis, 'fetch', async (_url, options) => {

View file

@ -154,6 +154,162 @@ test('HarnessClient lists sessions by workspace accounting in its stored order',
);
});
test('HarnessClient adopts one registered ordinary session without changing its preset', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/default-workspace',
agentPreset: 'custom-preset',
});
const options = {
signal: new AbortController().signal,
rpcId: 'weixin-session-adopt',
};
const calls = [];
client.ensureRunning = async (received) => {
calls.push({ method: 'ensureRunning', options: received });
};
client.rpc = async (method, payload, timeoutMs, rpcOptions) => {
calls.push({ method, payload, timeoutMs, options: rpcOptions });
if (method === 'workspace.list') {
return {
items: [
{
workspaceId: 'workspace-target',
path: '/tmp/target',
sessionIds: ['session-other', 'session-target'],
},
{ workspaceId: 'workspace-unsafe', path: '/tmp/unsafe\u202e', sessionIds: [] },
{ workspaceId: 'workspace-other', path: '/tmp/other', sessionIds: [] },
],
archivedSessionIds: ['session-target'],
};
}
if (method === 'session.list') {
return {
items: [{
sessionId: 'session-target',
projections: { values: { title: 'Existing conversation' } },
}],
};
}
assert.equal(method, 'session.create');
return { sessionId: 'session-target', agentPreset: 'persisted-preset' };
};
assert.deepEqual(await client.adoptWorkspaceSession('session-target', options), {
sessionId: 'session-target',
workspace: '/tmp/target',
title: 'Existing conversation',
archived: true,
});
assert.deepEqual(calls, [
{ method: 'ensureRunning', options },
{ method: 'workspace.list', payload: {}, timeoutMs: 30_000, options },
{ method: 'session.list', payload: {}, timeoutMs: 30_000, options },
{
method: 'session.create',
payload: { workspaceId: 'workspace-target', sessionId: 'session-target' },
timeoutMs: 30_000,
options,
},
]);
});
test('HarnessClient safely rejects invalid, unregistered, ambiguous, and subagent adoption', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/default-workspace',
});
let mode = 'unregistered';
let createCalls = 0;
client.ensureRunning = async () => undefined;
client.rpc = async (method) => {
if (method === 'workspace.list') {
if (mode === 'invalid-workspaces') return { items: null, archivedSessionIds: [] };
if (mode === 'unregistered') {
return {
items: [{ workspaceId: 'workspace', path: '/tmp/workspace', sessionIds: [] }],
archivedSessionIds: [],
};
}
return {
items: [
{
workspaceId: 'workspace-one',
path: '/tmp/one',
sessionIds: ['session-target'],
},
...(mode === 'ambiguous' ? [{
workspaceId: 'workspace-two',
path: '/tmp/two',
sessionIds: ['session-target'],
}] : []),
],
archivedSessionIds: [],
};
}
if (method === 'session.list') {
if (mode === 'summary-missing') return { items: [] };
return {
items: [{
sessionId: 'session-target',
...(mode === 'subagent' ? { origin: 'subagent' } : {}),
}],
};
}
createCalls += 1;
return mode === 'bad-create'
? { sessionId: 'different-session' }
: { sessionId: 'session-target' };
};
for (const invalid of [
undefined,
'',
' ',
'session target',
'session\u0000target',
's'.repeat(257),
]) {
await assert.rejects(
client.adoptWorkspaceSession(invalid),
(error) => error?.code === 'session-id-invalid',
);
}
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
(error) => error?.code === 'session-not-registered',
);
mode = 'ambiguous';
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
(error) => error?.code === 'session-workspace-ambiguous',
);
mode = 'summary-missing';
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
(error) => error?.code === 'session-summary-unavailable',
);
mode = 'subagent';
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
(error) => error?.code === 'session-subagent-unsupported',
);
assert.equal(createCalls, 0);
mode = 'invalid-workspaces';
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
/invalid response for workspace\.list/,
);
mode = 'bad-create';
await assert.rejects(
client.adoptWorkspaceSession('session-target'),
/invalid response for session\.create/,
);
assert.equal(createCalls, 1);
});
test('reply tracker associates only the Harness turn created by the Weixin prompt RPC', () => {
const tracker = new HarnessReplyTracker({ promptRpcId: 'weixin-prompt', afterSeq: 2 });
const first = tracker.consume([

View file

@ -0,0 +1,175 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { TextHarnessBridge } from '../src/channels/shared/text-harness-bridge.mjs';
import { runWorkspaceCommand } from '../src/channels/shared/workspace-command.mjs';
test('/session binds exactly one safe Session ID to the current conversation', async () => {
const calls = [];
const result = await runWorkspaceCommand('/SESSION session-123', {
async bindWorkspaceSession(key, sessionId) {
calls.push({ key, sessionId });
return {
sessionId,
workspace: '/workspace/project',
title: '安全标题\u202e伪造\n下一行',
archived: true,
};
},
}, 'direct:conversation-1');
assert.deepEqual(calls, [{ key: 'direct:conversation-1', sessionId: 'session-123' }]);
assert.match(result.message, /^当前聊天已绑定会话:/);
assert.match(result.message, /工作区:\/workspace\/project/);
assert.match(result.message, /标题:安全标题 伪造 下一行/);
assert.doesNotMatch(result.message, /\u202e|\n下一行/);
assert.match(result.message, /ID:session-123/);
assert.match(result.message, /归档:是/);
assert.equal(result.messages.join(''), result.message);
});
test('/session strictly rejects missing, multiple, oversized, and unsafe IDs', async () => {
let bindCalls = 0;
const harness = {
async bindWorkspaceSession() {
bindCalls += 1;
throw new Error('must not be called');
},
};
const invalid = [
'/session',
'/session first second',
'/session\nsecond-line',
`/session ${'x'.repeat(257)}`,
'/session unsafe\u202eid',
'/session unsafe\u0000id',
];
for (const command of invalid) {
const result = await runWorkspaceCommand(command, harness, 'direct:conversation-1');
assert.match(result.message, /用法:\/session Session ID/);
}
assert.equal(bindCalls, 0);
});
test('/session requires binding support and a conversation key', async () => {
assert.match(
(await runWorkspaceCommand('/session session-1', {}, 'direct:conversation-1')).message,
/暂不支持绑定已有会话/,
);
assert.match(
(await runWorkspaceCommand('/session session-1', {
async bindWorkspaceSession() { throw new Error('must not be called'); },
})).message,
/缺少可绑定的会话上下文/,
);
});
test('/session maps adoption, lifecycle, and concurrent failures to safe messages', async () => {
const cases = [
['session-id-invalid', /Session ID 格式无效/],
['session-not-registered', /未找到该会话/],
['session-workspace-ambiguous', /工作区归属不明确/],
['session-summary-unavailable', /暂时无法读取该会话的信息/],
['session-subagent-unsupported', /子代理会话不能绑定/],
['workspace-bot-not-found', /正在移除或已重新接入/],
['workspace-session-stale', /状态已发生变化/],
['agent-busy', /状态已发生变化/],
['session-conflict', /状态已发生变化/],
['internal', /暂时无法绑定会话/],
];
for (const [code, expected] of cases) {
const internal = new Error(`private detail for ${code}`);
internal.code = code;
const result = await runWorkspaceCommand('/session session-1', {
async bindWorkspaceSession() { throw internal; },
}, 'direct:conversation-1');
assert.match(result.message, expected);
assert.doesNotMatch(result.message, /private detail/);
}
const stale = new Error('old bot lifecycle');
stale.code = 'workspace-bot-not-found';
const staleAfterBinding = await runWorkspaceCommand('/session session-1', {
async bindWorkspaceSession(sessionKey, sessionId) {
return { sessionId, workspace: '/workspace/project', sessionKey };
},
assertWorkspaceScope() { throw stale; },
}, 'direct:conversation-1');
assert.match(staleAfterBinding.message, /正在移除或已重新接入/);
});
test('the shared bridge binds locally with its conversation key and never prompts or creates', async () => {
const sent = [];
const seen = new Set();
const calls = [];
let forbiddenCalls = 0;
const bridge = new TextHarnessBridge({
descriptor: { key: 'test', label: 'Test' },
bot: { async sendText(_target, text) { sent.push(text); } },
harness: {
async bindWorkspaceSession(key, sessionId) {
calls.push({ key, sessionId });
return {
sessionId,
workspace: '/workspace/project',
title: null,
archived: false,
};
},
async createSession() { forbiddenCalls += 1; },
async ask() { forbiddenCalls += 1; },
},
state: {
hasSeen(messageId) { return seen.has(messageId); },
async markSeen(messageId) { seen.add(messageId); },
},
});
await bridge.accept({
messageId: 'message-session-bind',
senderId: 'sender',
conversationId: 'conversation-1',
kind: 'direct',
content: '/session session-123',
replyTarget: 'target',
});
assert.deepEqual(calls, [{ key: 'direct:conversation-1', sessionId: 'session-123' }]);
assert.equal(forbiddenCalls, 0);
assert.equal(sent.length, 1);
assert.match(sent[0], /当前聊天已绑定会话/);
assert.match(sent[0], /标题:暂无标题/);
assert.match(sent[0], /归档:否/);
assert.equal(seen.has('message-session-bind'), true);
});
test('all nine channel bridges advertise /session and pass their current conversation key', async () => {
const bridgeFamilies = [
['../src/channels/shared/text-harness-bridge.mjs', 'conversationKey'],
['../src/channels/weixin/weixin-bridge.mjs', 'key'],
['../src/channels/feishu/bridge.mjs', 'key'],
['../src/channels/dingtalk/dingtalk-bridge.mjs', 'key'],
['../src/channels/wecom/wecom-bridge.mjs', 'key'],
['../src/channels/qq/qq-bridge.mjs', 'key'],
];
for (const [file, key] of bridgeFamilies) {
const source = await readFile(new URL(file, import.meta.url), 'utf8');
assert.match(source, /\/session Session ID 将当前聊天绑定到指定会话/);
assert.ok(
source.includes(`runWorkspaceCommand(text, this.#harness, ${key})`),
`${file} must pass ${key} to the shared command`,
);
}
for (const file of [
'../src/channels/discord/discord-bridge.mjs',
'../src/channels/slack/slack-bridge.mjs',
'../src/channels/telegram/telegram-bridge.mjs',
'../src/channels/whatsapp/whatsapp-bridge.mjs',
]) {
const source = await readFile(new URL(file, import.meta.url), 'utf8');
assert.match(source, /extends TextHarnessBridge/);
}
});

View file

@ -0,0 +1,613 @@
import assert from 'node:assert/strict';
import {
mkdtemp,
mkdir,
readFile,
realpath,
rename,
rm,
symlink,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
BotWorkspaceStore,
createBotWorkspaceScope,
} from '../src/channels/shared/bot-workspace-store.mjs';
import { askInWorkspaceSession } from '../src/channels/shared/workspace-session.mjs';
async function fixture(t) {
const root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-im-session-bind-')));
t.after(() => rm(root, { recursive: true, force: true }));
const defaultWorkspace = join(root, 'default');
const alternateWorkspace = join(root, 'alternate');
const thirdWorkspace = join(root, 'third');
await Promise.all([
mkdir(defaultWorkspace),
mkdir(alternateWorkspace),
mkdir(thirdWorkspace),
]);
return {
root,
defaultWorkspace,
alternateWorkspace,
thirdWorkspace,
path: join(root, 'workspaces.json'),
};
}
function deferred() {
let resolve;
const promise = new Promise((settle) => { resolve = settle; });
return { promise, resolve };
}
function memoryState(initial = {}) {
let sessions = { ...initial };
let clears = 0;
let sets = 0;
return {
sessionFor(key) { return sessions[key] ?? null; },
async setSession(key, sessionId) {
sets += 1;
sessions[key] = sessionId;
},
async clearSessions() {
clears += 1;
sessions = {};
},
snapshot() { return { ...sessions }; },
get clears() { return clears; },
get sets() { return sets; },
};
}
test('binding across workspaces clears old mappings, fences the generation, and binds one conversation', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_bind');
const previousGeneration = workspaces.generationFor('bot_bind');
const state = memoryState({ 'direct:one': 'old-one', 'group:two': 'old-two' });
const calls = [];
const harness = {
async adoptWorkspaceSession(sessionId) {
calls.push(sessionId);
return {
sessionId,
workspace: alternateWorkspace,
title: 'Existing conversation',
archived: true,
};
},
};
const scope = createBotWorkspaceScope(harness, { botId: 'bot_bind', workspaces, state });
const result = await scope.harness.bindWorkspaceSession('direct:one', 'session-target');
assert.deepEqual(calls, ['session-target']);
assert.deepEqual(result, {
sessionId: 'session-target',
workspace: alternateWorkspace,
title: 'Existing conversation',
archived: true,
});
assert.equal(workspaces.workspaceFor('bot_bind'), alternateWorkspace);
assert.notEqual(workspaces.generationFor('bot_bind'), previousGeneration);
assert.deepEqual(state.snapshot(), { 'direct:one': 'session-target' });
assert.equal(state.clears, 1);
assert.equal(state.sets, 1);
assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), {
version: 1,
workspaces: { bot_bind: alternateWorkspace },
});
});
test('binding inside the current workspace only replaces the selected conversation', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_same');
const generation = workspaces.generationFor('bot_same');
const state = memoryState({ 'direct:one': 'old-one', 'group:two': 'kept-two' });
const scope = createBotWorkspaceScope({
async adoptWorkspaceSession(sessionId) {
return { sessionId, workspace: defaultWorkspace };
},
}, { botId: 'bot_same', workspaces, state });
await scope.harness.bindWorkspaceSession('direct:one', 'session-current-workspace');
assert.equal(workspaces.workspaceFor('bot_same'), defaultWorkspace);
assert.equal(workspaces.generationFor('bot_same'), generation);
assert.equal(state.clears, 0);
assert.deepEqual(state.snapshot(), {
'direct:one': 'session-current-workspace',
'group:two': 'kept-two',
});
});
test('binding through an equivalent real path does not clear a symlink workspace', async (t) => {
const { root, path, defaultWorkspace } = await fixture(t);
const linkedWorkspace = join(root, 'default-link');
await symlink(defaultWorkspace, linkedWorkspace);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace: linkedWorkspace }).load();
await workspaces.ensure('bot_symlink', { workspace: linkedWorkspace });
const generation = workspaces.generationFor('bot_symlink');
const state = memoryState({ selected: 'session-old', other: 'session-kept' });
const scope = createBotWorkspaceScope({
async adoptWorkspaceSession(sessionId) {
return { sessionId, workspace: defaultWorkspace };
},
}, { botId: 'bot_symlink', workspaces, state });
await scope.harness.bindWorkspaceSession('selected', 'session-target');
assert.equal(workspaces.workspaceFor('bot_symlink'), linkedWorkspace);
assert.equal(workspaces.generationFor('bot_symlink'), generation);
assert.equal(state.clears, 0);
assert.deepEqual(state.snapshot(), {
selected: 'session-target',
other: 'session-kept',
});
});
test('the next message continues the bound Session without creating a new one', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_continue');
const state = memoryState({ conversation: 'session-old' });
const asked = [];
let createCalls = 0;
const scope = createBotWorkspaceScope({
async adoptWorkspaceSession(sessionId) {
return { sessionId, workspace: alternateWorkspace };
},
async sessionExists(sessionId) { return sessionId === 'session-target'; },
async createSession() {
createCalls += 1;
return 'session-created-unexpectedly';
},
async ask(sessionId, text) {
asked.push({ sessionId, text });
return 'continued answer';
},
}, { botId: 'bot_continue', workspaces, state });
await scope.harness.bindWorkspaceSession('conversation', 'session-target');
const reply = await askInWorkspaceSession({
harness: scope.harness,
state: scope.state,
key: 'conversation',
text: 'continue here',
});
assert.deepEqual(reply, { sessionId: 'session-target', answer: 'continued answer' });
assert.deepEqual(asked, [{ sessionId: 'session-target', text: 'continue here' }]);
assert.equal(createCalls, 0);
});
test('adoption errors and invalid adoption responses leave local state unchanged', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_reject');
const state = memoryState({ conversation: 'session-old' });
const rejection = Object.assign(new Error('subagent sessions cannot be adopted'), {
code: 'agent-busy',
});
const rejectedScope = createBotWorkspaceScope({
async adoptWorkspaceSession() { throw rejection; },
}, { botId: 'bot_reject', workspaces, state });
await assert.rejects(
rejectedScope.harness.bindWorkspaceSession('conversation', 'session-child'),
{ code: 'agent-busy' },
);
assert.equal(workspaces.workspaceFor('bot_reject'), defaultWorkspace);
assert.deepEqual(state.snapshot(), { conversation: 'session-old' });
const invalidScope = createBotWorkspaceScope({
async adoptWorkspaceSession() {
return { sessionId: 'different-session', workspace: alternateWorkspace };
},
}, { botId: 'bot_reject', workspaces, state });
await assert.rejects(
invalidScope.harness.bindWorkspaceSession('conversation', 'session-requested'),
/invalid adopted workspace session/,
);
assert.equal(workspaces.workspaceFor('bot_reject'), defaultWorkspace);
assert.deepEqual(state.snapshot(), { conversation: 'session-old' });
});
test('a workspace switch during adoption wins instead of being overwritten by the stale bind', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_adoption_race');
const state = memoryState({ conversation: 'session-old', other: 'session-other' });
const adoptionStarted = deferred();
const releaseAdoption = deferred();
const scope = createBotWorkspaceScope({
async adoptWorkspaceSession(sessionId) {
adoptionStarted.resolve();
await releaseAdoption.promise;
return { sessionId, workspace: defaultWorkspace };
},
}, { botId: 'bot_adoption_race', workspaces, state });
const binding = scope.harness.bindWorkspaceSession('conversation', 'session-target');
await adoptionStarted.promise;
await scope.harness.switchWorkspace(alternateWorkspace);
releaseAdoption.resolve();
await assert.rejects(binding, { code: 'workspace-session-stale' });
assert.equal(workspaces.workspaceFor('bot_adoption_race'), alternateWorkspace);
assert.deepEqual(state.snapshot(), {});
assert.equal(state.sets, 0);
});
test('the store queue rejects a generation change after adoption validation', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_queue_fence');
const state = memoryState({ conversation: 'session-old', other: 'session-other' });
const bindInQueue = workspaces.bindWorkspaceSession.bind(workspaces);
workspaces.bindWorkspaceSession = async (...args) => {
await workspaces.setWorkspace('bot_queue_fence', alternateWorkspace, {
clearSessions: () => state.clearSessions(),
});
return bindInQueue(...args);
};
const scope = createBotWorkspaceScope({
async adoptWorkspaceSession(sessionId) {
return { sessionId, workspace: defaultWorkspace };
},
}, { botId: 'bot_queue_fence', workspaces, state });
await assert.rejects(
scope.harness.bindWorkspaceSession('conversation', 'session-target'),
{ code: 'workspace-session-stale' },
);
assert.equal(workspaces.workspaceFor('bot_queue_fence'), alternateWorkspace);
assert.deepEqual(state.snapshot(), {});
assert.equal(state.sets, 0);
});
test('a workspace switch cannot interleave between bind persistence and its session write', async (t) => {
const {
path, defaultWorkspace, alternateWorkspace, thirdWorkspace,
} = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_serial');
let sessions = { first: 'session-old', second: 'session-other' };
const setStarted = deferred();
const releaseSet = deferred();
const state = {
sessionFor(key) { return sessions[key] ?? null; },
async clearSessions() { sessions = {}; },
async setSession(key, sessionId) {
setStarted.resolve();
await releaseSet.promise;
sessions[key] = sessionId;
},
snapshot() { return { ...sessions }; },
};
const scope = createBotWorkspaceScope({
async adoptWorkspaceSession(sessionId) {
return { sessionId, workspace: alternateWorkspace };
},
}, { botId: 'bot_serial', workspaces, state });
const binding = scope.harness.bindWorkspaceSession('first', 'session-target');
await setStarted.promise;
const switching = scope.harness.switchWorkspace(thirdWorkspace);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(workspaces.workspaceFor('bot_serial'), alternateWorkspace);
assert.deepEqual(state.snapshot(), {});
releaseSet.resolve();
await binding;
await switching;
assert.equal(workspaces.workspaceFor('bot_serial'), thirdWorkspace);
assert.deepEqual(state.snapshot(), {});
});
test('a later workspace generation cannot be reported as the completed binding', async (t) => {
const {
path, defaultWorkspace, alternateWorkspace, thirdWorkspace,
} = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_late_switch');
const state = memoryState({ conversation: 'session-old' });
const bindInQueue = workspaces.bindWorkspaceSession.bind(workspaces);
workspaces.bindWorkspaceSession = async (...args) => {
const bound = await bindInQueue(...args);
await workspaces.setWorkspace('bot_late_switch', thirdWorkspace, {
clearSessions: () => state.clearSessions(),
});
return bound;
};
const scope = createBotWorkspaceScope({
async adoptWorkspaceSession(sessionId) {
return { sessionId, workspace: alternateWorkspace };
},
}, { botId: 'bot_late_switch', workspaces, state });
await assert.rejects(
scope.harness.bindWorkspaceSession('conversation', 'session-target'),
{ code: 'workspace-session-stale' },
);
assert.equal(workspaces.workspaceFor('bot_late_switch'), thirdWorkspace);
assert.deepEqual(state.snapshot(), {});
});
test('a bind started by an old bot incarnation cannot mutate a same-id replacement', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_rebound');
const oldState = memoryState({ conversation: 'session-old' });
const adoptionStarted = deferred();
const releaseAdoption = deferred();
const oldScope = createBotWorkspaceScope({
async adoptWorkspaceSession(sessionId) {
adoptionStarted.resolve();
await releaseAdoption.promise;
return { sessionId, workspace: alternateWorkspace };
},
}, { botId: 'bot_rebound', workspaces, state: oldState });
const staleBinding = oldScope.harness.bindWorkspaceSession('conversation', 'session-target');
await adoptionStarted.promise;
const removal = await workspaces.beginRemoval('bot_rebound', {
clearSessions: () => oldState.clearSessions(),
});
await workspaces.finishRemoval(removal);
await workspaces.ensure('bot_rebound', { workspace: defaultWorkspace });
const replacementIncarnation = workspaces.incarnationFor('bot_rebound');
releaseAdoption.resolve();
await assert.rejects(staleBinding, { code: 'workspace-bot-not-found' });
assert.equal(workspaces.incarnationFor('bot_rebound'), replacementIncarnation);
assert.equal(workspaces.workspaceFor('bot_rebound'), defaultWorkspace);
assert.deepEqual(oldState.snapshot(), {});
assert.equal(oldState.sets, 0);
});
test('binding fences a session creation that started in the previous workspace generation', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_creation');
const creationStarted = deferred();
const releaseCreation = deferred();
const state = memoryState();
const scope = createBotWorkspaceScope({
async createSession() {
creationStarted.resolve();
await releaseCreation.promise;
return 'session-from-old-workspace';
},
async adoptWorkspaceSession(sessionId) {
return { sessionId, workspace: alternateWorkspace };
},
}, { botId: 'bot_creation', workspaces, state });
const oldCreation = scope.harness.createSession();
await creationStarted.promise;
await scope.harness.bindWorkspaceSession('bound', 'session-target');
releaseCreation.resolve();
const staleSessionId = await oldCreation;
assert.equal(await scope.state.setSession('other', staleSessionId), false);
assert.deepEqual(state.snapshot(), { bound: 'session-target' });
});
test('two conversations sharing one Session keep independent generation handles', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_shared_session');
const state = memoryState({ first: 'session-shared', second: 'session-shared' });
const bothExistenceChecksStarted = deferred();
const releaseExistenceChecks = deferred();
let existenceChecks = 0;
let creations = 0;
const asked = [];
const scope = createBotWorkspaceScope({
async sessionExists(sessionId) {
assert.equal(sessionId, 'session-shared');
existenceChecks += 1;
if (existenceChecks === 2) bothExistenceChecksStarted.resolve();
await releaseExistenceChecks.promise;
return true;
},
async createSession({ workspace }) {
assert.equal(workspace, alternateWorkspace);
creations += 1;
return `session-current-${creations}`;
},
async ask(sessionId, text) {
asked.push({ sessionId, text });
return `answer:${text}`;
},
}, { botId: 'bot_shared_session', workspaces, state });
const first = askInWorkspaceSession({
harness: scope.harness,
state: scope.state,
key: 'first',
text: 'first prompt',
});
const second = askInWorkspaceSession({
harness: scope.harness,
state: scope.state,
key: 'second',
text: 'second prompt',
});
await bothExistenceChecksStarted.promise;
await scope.harness.switchWorkspace(alternateWorkspace);
releaseExistenceChecks.resolve();
const replies = await Promise.all([first, second]);
assert.equal(replies.length, 2);
assert.equal(asked.some(({ sessionId }) => sessionId === 'session-shared'), false);
assert.deepEqual(new Set(asked.map(({ sessionId }) => sessionId)), new Set([
'session-current-1',
'session-current-2',
]));
assert.equal(workspaces.workspaceFor('bot_shared_session'), alternateWorkspace);
});
test('an old handle stays stale after switching away and rebinding the same Session id', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_rebind_same_id');
const state = memoryState({ first: 'session-shared' });
const existenceStarted = deferred();
const releaseExistence = deferred();
const asked = [];
const scope = createBotWorkspaceScope({
async adoptWorkspaceSession(sessionId) {
return { sessionId, workspace: defaultWorkspace };
},
async sessionExists(sessionId) {
assert.equal(sessionId, 'session-shared');
existenceStarted.resolve();
await releaseExistence.promise;
return true;
},
async createSession({ workspace }) {
assert.equal(workspace, defaultWorkspace);
return 'session-after-rebind';
},
async ask(sessionId, text) {
asked.push({ sessionId, text });
return 'answer';
},
}, { botId: 'bot_rebind_same_id', workspaces, state });
const oldPrompt = askInWorkspaceSession({
harness: scope.harness,
state: scope.state,
key: 'first',
text: 'old prompt',
});
await existenceStarted.promise;
await scope.harness.switchWorkspace(alternateWorkspace);
await scope.harness.bindWorkspaceSession('second', 'session-shared');
releaseExistence.resolve();
assert.deepEqual(await oldPrompt, { sessionId: 'session-after-rebind', answer: 'answer' });
assert.deepEqual(asked, [{ sessionId: 'session-after-rebind', text: 'old prompt' }]);
assert.equal(workspaces.workspaceFor('bot_rebind_same_id'), defaultWorkspace);
assert.deepEqual(state.snapshot(), {
second: 'session-shared',
first: 'session-after-rebind',
});
});
test('a Session created before a switch keeps its original generation provenance', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_create_provenance');
const state = memoryState();
const creationStarted = deferred();
const releaseCreation = deferred();
const createdIn = [];
const asked = [];
const scope = createBotWorkspaceScope({
async createSession({ workspace }) {
createdIn.push(workspace);
if (createdIn.length === 1) {
creationStarted.resolve();
await releaseCreation.promise;
return 'session-created-before-switch';
}
return 'session-created-after-switch';
},
async ask(sessionId, text) {
asked.push({ sessionId, text });
return 'answer';
},
}, { botId: 'bot_create_provenance', workspaces, state });
const prompting = askInWorkspaceSession({
harness: scope.harness,
state: scope.state,
key: 'conversation',
text: 'prompt',
});
await creationStarted.promise;
await scope.harness.switchWorkspace(alternateWorkspace);
releaseCreation.resolve();
assert.deepEqual(await prompting, {
sessionId: 'session-created-after-switch',
answer: 'answer',
});
assert.deepEqual(createdIn, [defaultWorkspace, alternateWorkspace]);
assert.deepEqual(asked, [{ sessionId: 'session-created-after-switch', text: 'prompt' }]);
assert.deepEqual(state.snapshot(), { conversation: 'session-created-after-switch' });
});
test('workspace persistence failure leaves old mappings cleared and never writes the target session', async (t) => {
const { root, defaultWorkspace, alternateWorkspace } = await fixture(t);
const storeDirectory = join(root, 'store');
const storePath = join(storeDirectory, 'workspaces.json');
await mkdir(storeDirectory);
const workspaces = await new BotWorkspaceStore(storePath, { defaultWorkspace }).load();
await workspaces.ensure('bot_persist');
const generation = workspaces.generationFor('bot_persist');
const state = memoryState({ first: 'session-old', second: 'session-other' });
const scope = createBotWorkspaceScope({
async adoptWorkspaceSession(sessionId) {
return { sessionId, workspace: alternateWorkspace };
},
}, { botId: 'bot_persist', workspaces, state });
const savedDirectory = `${storeDirectory}-saved`;
await rename(storeDirectory, savedDirectory);
await writeFile(storeDirectory, 'blocks persistence');
await assert.rejects(scope.harness.bindWorkspaceSession('first', 'session-target'));
assert.equal(workspaces.workspaceFor('bot_persist'), defaultWorkspace);
assert.notEqual(workspaces.generationFor('bot_persist'), generation);
assert.deepEqual(state.snapshot(), {});
assert.equal(state.sets, 0);
await rm(storeDirectory, { force: true });
await rename(savedDirectory, storeDirectory);
});
test('session persistence failure keeps the new workspace and does not restore old mappings', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_state_failure');
let sessions = { first: 'session-old', second: 'session-other' };
const state = {
sessionFor(key) { return sessions[key] ?? null; },
async clearSessions() { sessions = {}; },
async setSession() { throw new Error('state persistence failed'); },
snapshot() { return { ...sessions }; },
};
const scope = createBotWorkspaceScope({
async adoptWorkspaceSession(sessionId) {
return { sessionId, workspace: alternateWorkspace };
},
}, { botId: 'bot_state_failure', workspaces, state });
await assert.rejects(
scope.harness.bindWorkspaceSession('first', 'session-target'),
/state persistence failed/,
);
assert.equal(workspaces.workspaceFor('bot_state_failure'), alternateWorkspace);
assert.deepEqual(state.snapshot(), {});
assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), {
version: 1,
workspaces: { bot_state_failure: alternateWorkspace },
});
});