agent-ecosystem/agent-teams-controller/test/controller.test.js

3174 lines
122 KiB
JavaScript

const fs = require('fs');
const http = require('http');
const os = require('os');
const path = require('path');
const { createController } = require('../src/index.js');
describe('agent-teams-controller API', () => {
const tempDirs = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function makeClaudeDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-controller-'));
tempDirs.push(dir);
fs.mkdirSync(path.join(dir, 'teams', 'my-team'), { recursive: true });
fs.mkdirSync(path.join(dir, 'tasks', 'my-team'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'teams', 'my-team', 'config.json'),
JSON.stringify(
{
name: 'my-team',
leadSessionId: 'lead-session-1',
members: [
{ name: 'alice', role: 'team-lead' },
{ name: 'bob', role: 'developer' },
],
},
null,
2
)
);
return dir;
}
async function startControlServer(handler) {
const server = http.createServer(async (req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', async () => {
try {
const bodyText = Buffer.concat(chunks).toString('utf8');
const body = bodyText ? JSON.parse(bodyText) : undefined;
const result = await handler({
method: req.method,
url: req.url,
body,
});
res.writeHead(result.statusCode || 200, { 'content-type': 'application/json' });
res.end(JSON.stringify(result.body));
} catch (error) {
res.writeHead(500, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: error.message }));
}
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
return {
baseUrl: `http://127.0.0.1:${address.port}`,
close: async () =>
await new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve()))
),
};
}
function writeControlApiState(claudeDir, baseUrl) {
fs.writeFileSync(
path.join(claudeDir, 'team-control-api.json'),
JSON.stringify({ baseUrl, updatedAt: new Date().toISOString() }, null, 2)
);
}
it('creates tasks and exposes grouped controller modules', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const base = controller.tasks.createTask({ subject: 'Base task' });
const dependency = controller.tasks.createTask({ subject: 'Dependency task' });
const created = controller.tasks.createTask({
subject: 'Blocked task',
owner: 'bob',
'blocked-by': `${base.displayId},${dependency.displayId}`,
related: base.displayId,
});
expect(created.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
);
expect(created.displayId).toHaveLength(8);
expect(created.status).toBe('pending');
expect(created.reviewState).toBe('none');
expect(controller.tasks.getTask(base.id).blocks).toEqual([created.id]);
expect(controller.tasks.getTask(created.displayId).blockedBy).toEqual([base.id, dependency.id]);
controller.kanban.addReviewer('alice');
controller.tasks.completeTask(created.id, 'bob');
controller.review.requestReview(created.id, { from: 'alice' });
controller.review.approveReview(created.id, { 'notify-owner': true, from: 'alice' });
const kanbanState = controller.kanban.getKanbanState();
expect(kanbanState.reviewers).toEqual(['alice']);
expect(kanbanState.tasks[created.id].column).toBe('approved');
expect(controller.tasks.getTask(created.id).reviewState).toBe('approved');
const sent = controller.messages.appendSentMessage({
from: 'team-lead',
to: 'user',
text: 'All good',
leadSessionId: 'session-1',
source: 'lead_process',
attachments: [{ id: 'a1', filename: 'diff.txt', mimeType: 'text/plain', size: 12 }],
});
expect(sent.leadSessionId).toBe('session-1');
const ownerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
expect(ownerInbox.at(-1).summary).toContain('Approved');
expect(ownerInbox.at(-1).leadSessionId).toBe('lead-session-1');
const proc = controller.processes.registerProcess({
pid: process.pid,
label: 'dev-server',
port: '3000',
});
expect(proc.port).toBe(3000);
expect(controller.processes.listProcesses()).toHaveLength(1);
const stopped = controller.processes.stopProcess({ pid: process.pid });
expect(typeof stopped.stoppedAt).toBe('string');
});
it('builds member briefing from team config language and known member metadata', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.language = 'en';
config.projectPath = '/tmp/project-x';
config.members = [
{ name: 'alice', role: 'team-lead' },
{ name: 'bob', role: 'developer', workflow: 'Implement carefully', cwd: '/tmp/project-x' },
];
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const controller = createController({ teamName: 'my-team', claudeDir });
controller.tasks.createTask({ subject: 'Queued task', owner: 'bob' });
const briefing = await controller.tasks.memberBriefing('bob');
expect(briefing).toContain('Member briefing for bob on team "my-team" (my-team).');
expect(briefing).toContain('IMPORTANT: Communicate in English.');
expect(briefing).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
expect(briefing).toContain('Workflow:');
expect(briefing).toContain('Implement carefully');
expect(briefing).toContain('Working directory: /tmp/project-x');
expect(briefing).toContain(
'If an assigned task requires implementation, fixes, review follow-up, or concrete investigation, you may inspect, read/search, and edit files in this working directory as needed.'
);
expect(briefing).toContain('Task briefing for bob:');
expect(briefing).toContain(
'Use task_briefing as your primary working queue whenever you need to see assigned work.'
);
expect(briefing).toContain(
'Use task_list only to search/browse inventory rows, not as your working queue.'
);
expect(briefing).toContain('member_work_sync_status and member_work_sync_report');
expect(briefing).toContain(
'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.'
);
expect(briefing).toContain('After task_complete, notify your team lead via SendMessage.');
expect(briefing).toContain('Full details in task comment e5f6a7b8');
expect(briefing).not.toContain('task_get_comment {');
});
it('uses OpenCode-native visible-message wording for OpenCode member briefing', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.members = [
{ name: 'alice', role: 'team-lead' },
{ name: 'bob', role: 'developer', providerId: 'opencode', model: 'openrouter/test-model' },
];
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const controller = createController({ teamName: 'my-team', claudeDir });
const briefing = await controller.tasks.memberBriefing('bob');
expect(briefing).toContain(
'After task_complete, notify your team lead via MCP tool agent-teams_message_send.'
);
expect(briefing).toContain('OpenCode visible messaging rule: call agent-teams_message_send');
expect(briefing).toContain('OpenCode bootstrap silence rule');
expect(briefing).toContain('If it shows no actionable tasks, stop and wait silently.');
expect(briefing).toContain(
'agent-teams_message_send { teamName: "my-team", to: "alice", from: "bob"'
);
expect(briefing).toContain('Full details in task comment e5f6a7b8');
expect(briefing).toContain('Never invent placeholder task refs such as #00000000');
expect(briefing).not.toContain('task_get_comment {');
expect(briefing).not.toContain('notify your team lead via SendMessage');
});
it('uses Codex-native visible-message and prefixed task tool wording for Codex member briefing', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.members = [
{ name: 'alice', role: 'team-lead' },
{ name: 'bob', role: 'developer', providerId: 'codex', model: 'gpt-5.4-mini' },
];
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const controller = createController({ teamName: 'my-team', claudeDir });
const briefing = await controller.tasks.memberBriefing('bob');
expect(briefing).toContain(
'After task_complete, notify your team lead via MCP tool agent-teams_message_send.'
);
expect(briefing).toContain('Codex Native visible messaging rule');
expect(briefing).toContain('Codex Native task tool rule');
expect(briefing).toContain('mcp__agent-teams__task_get');
expect(briefing).not.toContain('notify your team lead via SendMessage');
});
it('rejects OpenCode idle acknowledgements without explicit delivery context', () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.members = [
{ name: 'alice', role: 'team-lead' },
{ name: 'bob', role: 'developer', providerId: 'opencode', model: 'opencode/test-model' },
];
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const controller = createController({ teamName: 'my-team', claudeDir });
expect(() =>
controller.messages.sendMessage({
to: 'user',
from: 'bob',
text: 'Понял.',
})
).toThrow('OpenCode idle/ack-only message_send was not delivered');
expect(() =>
controller.messages.sendMessage({
to: 'team-lead',
from: 'bob',
text: 'Нет назначенных задач.',
})
).toThrow('OpenCode idle/ack-only message_send was not delivered');
expect(() =>
controller.messages.sendMessage({
to: 'user',
from: 'bob',
text: 'Понял.',
source: 'runtime_delivery',
})
).toThrow('OpenCode idle/ack-only message_send was not delivered');
const delivered = controller.messages.sendMessage({
to: 'user',
from: 'bob',
text: 'Понял.',
source: 'runtime_delivery',
relayOfMessageId: 'msg-inbound-1',
});
expect(delivered.deliveredToInbox).toBe(true);
});
it('deduplicates repeated runtime_delivery replies to the same inbound message', () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.members = [
{ name: 'alice', role: 'team-lead' },
{ name: 'bob', role: 'developer', providerId: 'opencode', model: 'opencode/test-model' },
];
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const controller = createController({ teamName: 'my-team', claudeDir });
const first = controller.messages.sendMessage({
to: 'user',
from: 'bob',
text: 'Да, я здесь!',
source: 'runtime_delivery',
relayOfMessageId: 'msg-inbound-1',
});
const second = controller.messages.sendMessage({
to: 'user',
from: 'bob',
text: ' Да, я здесь! ',
source: 'runtime_delivery',
relayOfMessageId: 'msg-inbound-1',
});
const userInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'user.json');
const rows = JSON.parse(fs.readFileSync(userInboxPath, 'utf8'));
expect(rows).toHaveLength(1);
expect(second).toMatchObject({
deliveredToInbox: true,
deduplicated: true,
messageId: first.messageId,
duplicateOfMessageId: first.messageId,
deduplicationNotice: expect.stringContaining('do not call agent-teams_message_send again'),
});
});
it('strips hallucinated zero task placeholder prefixes from visible messages', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
controller.messages.sendMessage({
to: 'user',
from: 'bob',
text: '#00000000 bootstrap check-in and briefing retrieved. No actionable tasks.',
summary: '#00000000 ready',
});
const userInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'user.json');
const rows = JSON.parse(fs.readFileSync(userInboxPath, 'utf8'));
expect(rows[0].text).toBe('bootstrap check-in and briefing retrieved. No actionable tasks.');
expect(rows[0].summary).toBe('ready');
});
it('infers Codex-native briefing from a generic provider-scoped GPT model', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.members = [
{ name: 'alice', role: 'team-lead' },
{ name: 'bob', role: 'developer', model: 'openai/gpt-5.4-mini' },
];
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const controller = createController({ teamName: 'my-team', claudeDir });
const briefing = await controller.tasks.memberBriefing('bob');
expect(briefing).toContain(
'After task_complete, notify your team lead via MCP tool agent-teams_message_send.'
);
expect(briefing).toContain('Codex Native visible messaging rule');
});
it('keeps explicit native provider metadata stronger than OpenCode-looking model labels', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.members = [
{ name: 'alice', role: 'team-lead' },
{
name: 'bob',
role: 'developer',
providerId: 'anthropic',
model: 'opencode/minimax-m2.5-free',
},
];
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const controller = createController({ teamName: 'my-team', claudeDir });
const briefing = await controller.tasks.memberBriefing('bob');
expect(briefing).toContain('After task_complete, notify your team lead via SendMessage.');
expect(briefing).not.toContain('agent-teams_message_send');
});
it('resolves member briefing from members.meta.json when config members are missing', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.language = 'en';
delete config.members;
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
fs.writeFileSync(
path.join(claudeDir, 'teams', 'my-team', 'members.meta.json'),
JSON.stringify(
{
version: 1,
members: [{ name: 'bob', role: 'developer', workflow: 'Meta workflow' }],
},
null,
2
)
);
const controller = createController({ teamName: 'my-team', claudeDir });
const briefing = await controller.tasks.memberBriefing('bob');
expect(briefing).toContain('Role: developer.');
expect(briefing).toContain('Meta workflow');
});
it('resolves member briefing from inbox presence when member metadata is not persisted yet', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
delete config.members;
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
fs.mkdirSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes'), { recursive: true });
fs.writeFileSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'carol.json'), '[]');
const controller = createController({ teamName: 'my-team', claudeDir });
const fromInboxBriefing = await controller.tasks.memberBriefing('carol');
expect(fromInboxBriefing).toContain('Member briefing for carol on team "my-team" (my-team).');
expect(fromInboxBriefing).toContain('Role: team member.');
});
it('rejects member briefing when member is unknown to config, members.meta, and inboxes', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
delete config.members;
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const controller = createController({ teamName: 'my-team', claudeDir });
await expect(controller.tasks.memberBriefing('dave')).rejects.toThrow(
'Member not found in team metadata or inboxes: dave'
);
});
it('ignores pseudo-recipient inbox files when resolving members', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
delete config.members;
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes');
fs.mkdirSync(inboxDir, { recursive: true });
fs.writeFileSync(path.join(inboxDir, 'cross-team:other-team.json'), '[]');
fs.writeFileSync(path.join(inboxDir, 'other-team.alice.json'), '[]');
fs.writeFileSync(path.join(inboxDir, 'cross_team_send.json'), '[]');
const controller = createController({ teamName: 'my-team', claudeDir });
await expect(controller.tasks.memberBriefing('cross-team:other-team')).rejects.toThrow(
'Member not found in team metadata or inboxes: cross-team:other-team'
);
await expect(controller.tasks.memberBriefing('other-team.alice')).rejects.toThrow(
'Member not found in team metadata or inboxes: other-team.alice'
);
await expect(controller.tasks.memberBriefing('cross_team_send')).rejects.toThrow(
'Member not found in team metadata or inboxes: cross_team_send'
);
});
it('rejects member briefing for explicitly removed members', async () => {
const claudeDir = makeClaudeDir();
fs.writeFileSync(
path.join(claudeDir, 'teams', 'my-team', 'members.meta.json'),
JSON.stringify(
{
version: 1,
members: [{ name: 'carol', role: 'developer', removedAt: Date.now() }],
},
null,
2
)
);
const controller = createController({ teamName: 'my-team', claudeDir });
await expect(controller.tasks.memberBriefing('carol')).rejects.toThrow(
'Member is removed from the team: carol'
);
});
it('creates a fresh registry entry when an old pid was recycled without stoppedAt', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const processesPath = path.join(claudeDir, 'teams', 'my-team', 'processes.json');
fs.writeFileSync(
processesPath,
JSON.stringify(
[
{
id: 'old-entry',
pid: 999999,
label: 'stale',
registeredAt: '2024-01-01T00:00:00.000Z',
},
],
null,
2
)
);
const registered = controller.processes.registerProcess({
pid: 999999,
label: 'fresh',
});
expect(registered.id).not.toBe('old-entry');
const rows = JSON.parse(fs.readFileSync(processesPath, 'utf8'));
expect(rows).toHaveLength(2);
expect(rows[0].stoppedAt).toBeTruthy();
expect(rows[1].id).toBe(registered.id);
});
it('keeps assigned tasks pending by default, supports explicit immediate start, notifies owners, and groups briefing into actionable and awareness queues', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const pendingTask = controller.tasks.createTask({
subject: 'Queued task',
description: 'Do this later',
owner: 'bob',
prompt: 'Check the migration plan first.',
});
const activeTask = controller.tasks.createTask({
subject: 'Active task',
description: 'Resume immediately',
owner: 'bob',
startImmediately: true,
});
const completedTask = controller.tasks.createTask({
subject: 'Already done',
description: 'Completed task description should stay out of compact rows',
owner: 'bob',
});
controller.tasks.completeTask(completedTask.id, 'bob');
controller.tasks.addTaskComment(activeTask.id, {
from: 'bob',
text: 'Resumed work with latest context.',
});
const needsFixTask = controller.tasks.createTask({
subject: 'Fix after review',
owner: 'bob',
status: 'pending',
reviewState: 'needsFix',
createdAt: '2026-01-02T00:00:00.000Z',
notifyOwner: false,
});
const reviewTask = controller.tasks.createTask({
subject: 'Waiting for review',
owner: 'bob',
status: 'completed',
reviewState: 'review',
createdAt: '2026-01-03T00:00:00.000Z',
notifyOwner: false,
});
const approvedTask = controller.tasks.createTask({
subject: 'Approved work',
owner: 'bob',
status: 'completed',
reviewState: 'approved',
createdAt: '2026-01-04T00:00:00.000Z',
notifyOwner: false,
});
const reassignedTask = controller.tasks.createTask({ subject: 'Reassigned later' });
controller.tasks.setTaskOwner(reassignedTask.id, 'bob');
expect(pendingTask.status).toBe('pending');
expect(activeTask.status).toBe('in_progress');
const ownerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
expect(ownerInbox).toHaveLength(4);
expect(ownerInbox[0].summary).toContain(`#${pendingTask.displayId}`);
expect(ownerInbox[0].text).toContain('task_get');
expect(ownerInbox[0].text).toContain('task_start');
expect(ownerInbox[0].text).toContain('task_add_comment');
expect(ownerInbox[0].text).toContain(
'If you are idle and this task is ready to start, start it now.'
);
expect(ownerInbox[0].text).toContain(
'If you are busy, blocked, or still need more context, immediately add a short task comment'
);
expect(ownerInbox[0].text).toContain('Description:');
expect(ownerInbox[0].text).toContain('Do this later');
expect(ownerInbox[0].text).toContain('Instructions:');
expect(ownerInbox[0].text).toContain('Check the migration plan first.');
expect(ownerInbox[0].leadSessionId).toBe('lead-session-1');
expect(ownerInbox[3].summary).toContain(`#${reassignedTask.displayId}`);
expect(ownerInbox[3].text).toContain(
'If you are idle and this task is ready to start, start it now.'
);
expect(ownerInbox[3].text).toContain('task_add_comment');
const briefing = await controller.tasks.taskBriefing('bob');
expect(briefing).toContain(
'Primary queue for bob. Act only on Actionable items. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner.'
);
expect(briefing).toContain(
'Use task_list only to search/browse inventory rows, not as your working queue.'
);
expect(briefing).toContain('Actionable:');
expect(briefing).toContain(`#${activeTask.displayId}`);
expect(briefing).toContain('Description: Resume immediately');
expect(briefing).toContain('Resumed work with latest context.');
expect(briefing).toContain(`#${needsFixTask.displayId}`);
expect(briefing).toContain('reason=needs_fix');
expect(briefing).toContain(`#${pendingTask.displayId}`);
expect(briefing).not.toContain('Description: Do this later');
expect(briefing).toContain('Awareness:');
expect(briefing).toContain(`#${reviewTask.displayId}`);
expect(briefing).toContain('reason=review_reviewer_missing');
expect(briefing).toContain(`#${completedTask.displayId}`);
expect(briefing).not.toContain('Completed task description should stay out of compact rows');
expect(briefing).toContain(`#${approvedTask.displayId}`);
expect(briefing).toContain('Counters: actionable=4, awareness=3');
});
it('treats stale legacy terminal reviewState on pending tasks as owner-ready work', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const staleTask = controller.tasks.createTask({
subject: 'Legacy stale approved task',
owner: 'bob',
status: 'pending',
reviewState: 'approved',
notifyOwner: false,
});
const briefing = await controller.tasks.taskBriefing('bob');
const staleLine = briefing.split('\n').find((line) => line.includes(`#${staleTask.displayId}`));
expect(staleLine).toContain('[status=pending]');
expect(staleLine).not.toContain('review=');
expect(staleLine).toContain('reason=owner_ready');
const rows = controller.tasks.listTaskInventory({ owner: 'bob' });
expect(rows.find((row) => row.id === staleTask.id)).toMatchObject({
status: 'pending',
reviewState: 'none',
});
});
it('reconciles stale kanban rows and linked inbox comments idempotently', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({
subject: 'Ship migration',
owner: 'bob',
});
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
fs.writeFileSync(
kanbanPath,
JSON.stringify(
{
teamName: 'my-team',
reviewers: [],
tasks: {
[task.id]: { column: 'review', movedAt: '2026-01-01T00:00:00.000Z', reviewer: null },
staleTask: { column: 'approved', movedAt: '2026-01-01T00:00:00.000Z' },
},
columnOrder: {
review: [task.id, 'staleTask'],
approved: ['staleTask'],
},
},
null,
2
)
);
const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes');
fs.mkdirSync(inboxDir, { recursive: true });
fs.writeFileSync(
path.join(inboxDir, 'bob.json'),
JSON.stringify(
[
{
from: 'alice',
to: 'bob',
summary: `Please revisit #${task.displayId}`,
messageId: 'm-1',
timestamp: '2026-02-23T10:00:00.000Z',
read: false,
text: 'Need one more verification pass.',
},
{
from: 'team-lead',
to: 'bob',
summary: `Comment on #${task.displayId}`,
messageId: 'm-2',
timestamp: '2026-02-23T11:00:00.000Z',
read: false,
text:
`**Comment on task #${task.displayId}**\n> Ship migration\n\n> Heads up\n\n` +
'<agent-block>\nReply to this comment using:\nnode "tool.js" --team my-team task comment 1 --text "..." --from "bob"\n</agent-block>',
},
],
null,
2
)
);
const first = controller.maintenance.reconcileArtifacts({ reason: 'manual' });
expect(first.staleKanbanEntriesRemoved).toBe(1);
expect(first.staleColumnOrderRefsRemoved).toBe(2);
expect(first.linkedCommentsCreated).toBe(1);
const reloaded = controller.tasks.getTask(task.id);
expect(reloaded.comments).toHaveLength(1);
expect(reloaded.comments[0].id).toBe('msg-m-1');
expect(reloaded.comments[0].text).toBe('Need one more verification pass.');
const cleanedKanban = JSON.parse(fs.readFileSync(kanbanPath, 'utf8'));
expect(cleanedKanban.tasks.staleTask).toBeUndefined();
expect(cleanedKanban.columnOrder.review).toEqual([task.id]);
expect(cleanedKanban.columnOrder.approved).toBeUndefined();
const second = controller.maintenance.reconcileArtifacts({ reason: 'manual' });
expect(second.staleKanbanEntriesRemoved).toBe(0);
expect(second.staleColumnOrderRefsRemoved).toBe(0);
expect(second.linkedCommentsCreated).toBe(0);
});
it('tracks lifecycle history and intervals without duplicate same-status transitions', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Lifecycle task' });
expect(task.status).toBe('pending');
expect(task.historyEvents).toHaveLength(1);
expect(task.workIntervals).toBeUndefined();
const started = controller.tasks.startTask(task.id, 'bob');
const startedAgain = controller.tasks.startTask(task.id, 'bob');
const completed = controller.tasks.completeTask(task.id, 'bob');
const completedAgain = controller.tasks.completeTask(task.id, 'bob');
const deleted = controller.tasks.softDeleteTask(task.id, 'bob');
const restored = controller.tasks.restoreTask(task.id, 'bob');
expect(started.status).toBe('in_progress');
expect(startedAgain.historyEvents).toHaveLength(2);
expect(startedAgain.workIntervals).toHaveLength(1);
expect(startedAgain.workIntervals[0].startedAt).toBeTruthy();
expect(completed.status).toBe('completed');
expect(completedAgain.historyEvents).toHaveLength(3);
expect(completedAgain.workIntervals).toHaveLength(1);
expect(completedAgain.workIntervals[0].completedAt).toBeTruthy();
expect(deleted.status).toBe('deleted');
expect(deleted.deletedAt).toBeTruthy();
expect(restored.status).toBe('pending');
expect(restored.deletedAt).toBeUndefined();
expect(restored.historyEvents).toHaveLength(5);
// Verify the event sequence: task_created, then 4 status_changed events
const types = restored.historyEvents.map((e) => e.type);
expect(types).toEqual([
'task_created',
'status_changed',
'status_changed',
'status_changed',
'status_changed',
]);
// Verify the status flow: pending -> in_progress -> completed -> deleted -> pending
const firstEvent = restored.historyEvents[0];
expect(firstEvent.status).toBe('pending');
const statusChanges = restored.historyEvents.slice(1).map((e) => e.to);
expect(statusChanges).toEqual(['in_progress', 'completed', 'deleted', 'pending']);
});
it('does not treat malformed empty completedAt work intervals as already open', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Malformed work interval' });
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
rawTask.workIntervals = [{ startedAt: '2026-01-01T00:00:00.000Z', completedAt: '' }];
fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2));
controller.tasks.startTask(task.id, 'bob');
const reloaded = controller.tasks.getTask(task.id);
expect(reloaded.workIntervals).toHaveLength(2);
expect(reloaded.workIntervals[0].completedAt).toBe('');
expect(reloaded.workIntervals[1].completedAt).toBeUndefined();
});
it('tracks owner assignment history without duplicate same-owner events', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Owner history' });
controller.tasks.setTaskOwner(task.id, 'bob', 'team-lead');
controller.tasks.setTaskOwner(task.id, 'bob', 'team-lead');
controller.tasks.setTaskOwner(task.id, 'alice', 'team-lead');
controller.tasks.setTaskOwner(task.id, null, 'team-lead');
const ownerEvents = controller.tasks
.getTask(task.id)
.historyEvents.filter((event) => event.type === 'owner_changed');
expect(ownerEvents).toHaveLength(3);
expect(ownerEvents[0]).toMatchObject({ to: 'bob', actor: 'team-lead' });
expect(ownerEvents[1]).toMatchObject({ from: 'bob', to: 'alice', actor: 'team-lead' });
expect(ownerEvents[2]).toMatchObject({ from: 'alice', actor: 'team-lead' });
expect(ownerEvents[2].to).toBeUndefined();
});
it('wraps review instructions in the canonical agent block format used by the UI', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Review me', owner: 'bob' });
controller.kanban.addReviewer('alice');
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead' });
const reviewerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json');
const inbox = JSON.parse(fs.readFileSync(reviewerInboxPath, 'utf8'));
expect(inbox).toHaveLength(1);
expect(inbox[0].text).toContain('<info_for_agent>');
expect(inbox[0].text).toContain('CURRENT review cycle');
expect(inbox[0].text).toContain('Before declaring it duplicate, call task_get');
expect(inbox[0].text).toContain('reviewState/status');
expect(inbox[0].text).toContain('review_approve');
expect(inbox[0].text).not.toContain('<agent-block>');
expect(inbox[0].leadSessionId).toBe('lead-session-1');
});
it('ignores mismatched leadSessionId placeholders on review_request and uses canonical config session', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Review me', owner: 'bob' });
controller.kanban.addReviewer('alice');
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, {
from: 'team-lead',
leadSessionId: 'team-lead',
});
const reviewerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json');
const inbox = JSON.parse(fs.readFileSync(reviewerInboxPath, 'utf8'));
expect(inbox).toHaveLength(1);
expect(inbox[0].leadSessionId).toBe('lead-session-1');
});
it('starts review idempotently after review_request', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Review me', owner: 'bob' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
const result = controller.review.startReview(task.id, { from: 'alice' });
expect(result.ok).toBe(true);
expect(result.taskId).toBe(task.id);
expect(result.displayId).toBe(task.displayId);
expect(result.column).toBe('review');
// Verify kanban state
const kanbanState = controller.kanban.getKanbanState();
expect(kanbanState.tasks[task.id].column).toBe('review');
// Verify task reviewState
const updatedTask = controller.tasks.getTask(task.id);
expect(updatedTask.reviewState).toBe('review');
// Verify history event
const reviewEvent = updatedTask.historyEvents.find((e) => e.type === 'review_started');
expect(reviewEvent).toBeDefined();
expect(reviewEvent.from).toBe('review');
expect(reviewEvent.to).toBe('review');
expect(reviewEvent.actor).toBe('alice');
expect(updatedTask.reviewIntervals).toHaveLength(1);
expect(updatedTask.reviewIntervals[0].reviewer).toBe('alice');
expect(updatedTask.reviewIntervals[0].startedAt).toBeTruthy();
expect(updatedTask.reviewIntervals[0].completedAt).toBeUndefined();
// Idempotent: calling again should also succeed without duplicate events
const again = controller.review.startReview(task.id, { from: 'alice' });
expect(again.ok).toBe(true);
const reloaded = controller.tasks.getTask(task.id);
const startedEvents = reloaded.historyEvents.filter((e) => e.type === 'review_started');
expect(startedEvents).toHaveLength(1);
expect(reloaded.reviewIntervals).toHaveLength(1);
});
it('closes review intervals when review is approved or changes are requested', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const approvedTask = controller.tasks.createTask({ subject: 'Approve review', owner: 'bob' });
controller.tasks.completeTask(approvedTask.id, 'bob');
controller.review.requestReview(approvedTask.id, { from: 'team-lead', reviewer: 'alice' });
controller.review.startReview(approvedTask.id, { from: 'alice' });
const approved = controller.review.approveReview(approvedTask.id, { from: 'alice' });
expect(approved.reviewIntervals).toHaveLength(1);
expect(approved.reviewIntervals[0].reviewer).toBe('alice');
expect(approved.reviewIntervals[0].completedAt).toBeTruthy();
const changesTask = controller.tasks.createTask({ subject: 'Request changes', owner: 'bob' });
controller.tasks.completeTask(changesTask.id, 'bob');
controller.review.requestReview(changesTask.id, { from: 'team-lead', reviewer: 'alice' });
controller.review.startReview(changesTask.id, { from: 'alice' });
const changed = controller.review.requestChanges(changesTask.id, {
from: 'alice',
comment: 'Needs a fix.',
});
expect(changed.reviewIntervals).toHaveLength(1);
expect(changed.reviewIntervals[0].reviewer).toBe('alice');
expect(changed.reviewIntervals[0].completedAt).toBeTruthy();
});
it('does not treat malformed empty completedAt review intervals as already open', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Review me', owner: 'bob' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
rawTask.reviewIntervals = [
{ reviewer: 'alice', startedAt: '2026-01-01T00:00:00.000Z', completedAt: '' },
];
fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2));
controller.review.startReview(task.id, { from: 'alice' });
const reloaded = controller.tasks.getTask(task.id);
expect(reloaded.reviewIntervals).toHaveLength(2);
expect(reloaded.reviewIntervals[0].completedAt).toBe('');
expect(reloaded.reviewIntervals[1].reviewer).toBe('alice');
expect(reloaded.reviewIntervals[1].completedAt).toBeUndefined();
});
it('records review_start after review_request and surfaces review_in_progress for the reviewer', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Queued for review', owner: 'bob' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
const started = controller.review.startReview(task.id, { from: 'alice' });
expect(started.ok).toBe(true);
const reloaded = controller.tasks.getTask(task.id);
const requestedEvents = reloaded.historyEvents.filter((e) => e.type === 'review_requested');
const startedEvents = reloaded.historyEvents.filter((e) => e.type === 'review_started');
expect(requestedEvents).toHaveLength(1);
expect(startedEvents).toHaveLength(1);
expect(startedEvents[0].from).toBe('review');
expect(startedEvents[0].to).toBe('review');
expect(startedEvents[0].actor).toBe('alice');
const reviewerBriefing = await controller.tasks.taskBriefing('alice');
expect(reviewerBriefing).toContain(`#${task.displayId}`);
expect(reviewerBriefing).toContain('reason=review_in_progress');
expect(reviewerBriefing).toContain('reviewer=alice');
});
it('uses the assigned reviewer when review_start omits from', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({
subject: 'Queued for implicit reviewer',
owner: 'bob',
});
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
controller.review.startReview(task.id);
const reloaded = controller.tasks.getTask(task.id);
const startedEvent = reloaded.historyEvents.find((event) => event.type === 'review_started');
expect(startedEvent.actor).toBe('alice');
const reviewerBriefing = await controller.tasks.taskBriefing('alice');
expect(reviewerBriefing).toContain(`#${task.displayId}`);
expect(reviewerBriefing).toContain('reason=review_in_progress');
expect(reviewerBriefing).toContain('reviewer=alice');
});
it('rejects review terminal transitions outside active completed review tasks', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const pendingTask = controller.tasks.createTask({ subject: 'Pending task', owner: 'bob' });
expect(() => controller.review.approveReview(pendingTask.id, { from: 'alice' })).toThrow(
'must be completed before approval'
);
const completedTask = controller.tasks.createTask({
subject: 'Completed but not review',
owner: 'bob',
});
controller.tasks.completeTask(completedTask.id, 'bob');
expect(() =>
controller.review.requestChanges(completedTask.id, { from: 'alice', comment: 'Fix it' })
).toThrow('must be in review before requesting changes');
const deletedTask = controller.tasks.createTask({
subject: 'Deleted review task',
owner: 'bob',
});
controller.tasks.softDeleteTask(deletedTask.id, 'bob');
expect(() => controller.review.approveReview(deletedTask.id, { from: 'alice' })).toThrow(
'is deleted'
);
expect(() =>
controller.review.requestChanges(deletedTask.id, { from: 'alice', comment: 'Fix it' })
).toThrow('is deleted');
expect(controller.tasks.getTask(deletedTask.id).status).toBe('deleted');
});
it('allows direct manual approval kanban shortcut for completed tasks outside review', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({
subject: 'Completed manual shortcut',
owner: 'bob',
});
controller.tasks.completeTask(task.id, 'bob');
expect(() => controller.review.approveReview(task.id, { from: 'alice' })).toThrow(
'must be in review before approval'
);
expect(() => controller.kanban.setKanbanColumn(task.id, 'approved')).toThrow(
'must already be approved'
);
const state = controller.kanban.setKanbanColumn(task.id, 'approved', {
transition: 'manual_approve',
});
expect(state.tasks[task.id].column).toBe('approved');
const approvedTask = controller.tasks.getTask(task.id);
expect(approvedTask.reviewState).toBe('approved');
expect(
(approvedTask.historyEvents || []).filter((event) => event.type === 'review_approved')
).toHaveLength(0);
});
it('rejects review_start outside active review and keeps owner routing intact', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const pendingTask = controller.tasks.createTask({
subject: 'Pending implementation',
owner: 'bob',
});
expect(() => controller.review.startReview(pendingTask.id, { from: 'alice' })).toThrow(
'must be completed before starting review'
);
expect(controller.tasks.getTask(pendingTask.id).reviewState).toBe('none');
const completedTask = controller.tasks.createTask({
subject: 'Completed without review request',
owner: 'bob',
});
controller.tasks.completeTask(completedTask.id, 'bob');
expect(() => controller.review.startReview(completedTask.id, { from: 'alice' })).toThrow(
'must be in review before starting review'
);
const bobBriefing = await controller.tasks.taskBriefing('bob');
expect(bobBriefing).toContain(`#${pendingTask.displayId}`);
expect(bobBriefing).toContain('actionOwner=@bob');
expect(bobBriefing).not.toContain('reason=review_in_progress');
});
it('rejects direct kanban lifecycle bypasses while allowing repair of matching review state', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const pendingTask = controller.tasks.createTask({
subject: 'Kanban bypass pending',
owner: 'bob',
});
expect(() => controller.kanban.setKanbanColumn(pendingTask.id, 'approved')).toThrow(
'must be completed before moving to APPROVED column'
);
const completedTask = controller.tasks.createTask({
subject: 'Kanban bypass completed',
owner: 'bob',
});
controller.tasks.completeTask(completedTask.id, 'bob');
expect(() => controller.kanban.setKanbanColumn(completedTask.id, 'review')).toThrow(
'must be in review before moving to REVIEW column'
);
controller.review.requestReview(completedTask.id, { from: 'team-lead', reviewer: 'alice' });
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
const state = JSON.parse(fs.readFileSync(kanbanPath, 'utf8'));
delete state.tasks[completedTask.id];
fs.writeFileSync(kanbanPath, JSON.stringify(state, null, 2));
controller.kanban.setKanbanColumn(completedTask.id, 'review');
expect(controller.kanban.getKanbanState().tasks[completedTask.id].column).toBe('review');
});
it('rejects review_request for already approved tasks until work is reopened', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Approved terminal task', owner: 'bob' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
controller.review.startReview(task.id, { from: 'alice' });
controller.review.approveReview(task.id, { from: 'alice' });
expect(() =>
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' })
).toThrow('is already approved');
expect(controller.tasks.getTask(task.id).reviewState).toBe('approved');
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved');
});
it('repairs kanban on idempotent review transitions without duplicate history', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Repair review column', owner: 'bob' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
controller.review.startReview(task.id, { from: 'alice' });
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
const reviewState = JSON.parse(fs.readFileSync(kanbanPath, 'utf8'));
delete reviewState.tasks[task.id];
reviewState.columnOrder = { review: [] };
fs.writeFileSync(kanbanPath, JSON.stringify(reviewState, null, 2));
controller.review.startReview(task.id, { from: 'alice' });
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('review');
expect(
controller.tasks
.getTask(task.id)
.historyEvents.filter((event) => event.type === 'review_started')
).toHaveLength(1);
controller.review.approveReview(task.id, { from: 'alice' });
const approvedState = JSON.parse(fs.readFileSync(kanbanPath, 'utf8'));
delete approvedState.tasks[task.id];
approvedState.columnOrder = { approved: [] };
fs.writeFileSync(kanbanPath, JSON.stringify(approvedState, null, 2));
const approvedAgain = controller.review.approveReview(task.id, { from: 'alice' });
expect(approvedAgain.alreadyApproved).toBe(true);
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved');
expect(
controller.tasks
.getTask(task.id)
.historyEvents.filter((event) => event.type === 'review_approved')
).toHaveLength(1);
});
it('throws when starting review on a deleted task', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Deleted task', owner: 'bob' });
controller.tasks.softDeleteTask(task.id, 'bob');
expect(() => controller.review.startReview(task.id, { from: 'alice' })).toThrow('is deleted');
});
it('clears stale needsFix reviewState when owner restarts work', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Needs fix restart', owner: 'bob' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
controller.review.requestChanges(task.id, { from: 'alice', comment: 'Please fix.' });
const started = controller.tasks.startTask(task.id, 'bob');
expect(started.status).toBe('in_progress');
expect(started.reviewState).toBe('none');
expect(controller.tasks.getTask(task.id).reviewState).toBe('none');
expect(controller.tasks.listTaskInventory({ owner: 'bob' })[0].reviewState).toBe('none');
const briefing = await controller.tasks.taskBriefing('bob');
expect(briefing).toContain('reason=owner_executing');
expect(briefing).not.toContain('reason=needs_fix');
});
it('persists full inbox metadata through controller messages.sendMessage', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const sent = controller.messages.sendMessage({
to: 'bob',
from: 'team-lead',
text: 'Need your review',
summary: 'Review request',
commentId: 'comment-123',
relayOfMessageId: 'm-original-1',
source: 'system_notification',
messageKind: 'member_work_sync_nudge',
workSyncIntent: 'review_pickup',
workSyncIntentKey: 'review-pickup:evt-1',
workSyncReviewRequestEventIds: ['evt-1'],
leadSessionId: 'session-42',
attachments: [{ id: 'a1', filename: 'note.txt', mimeType: 'text/plain', size: 7 }],
});
expect(sent.deliveredToInbox).toBe(true);
expect(sent.messageId).toBeTruthy();
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows).toHaveLength(1);
expect(rows[0].source).toBe('system_notification');
expect(rows[0].messageKind).toBe('member_work_sync_nudge');
expect(rows[0].workSyncIntent).toBe('review_pickup');
expect(rows[0].workSyncIntentKey).toBe('review-pickup:evt-1');
expect(rows[0].workSyncReviewRequestEventIds).toEqual(['evt-1']);
expect(rows[0].commentId).toBe('comment-123');
expect(rows[0].relayOfMessageId).toBe('m-original-1');
expect(rows[0].leadSessionId).toBe('session-42');
expect(rows[0].attachments[0].filename).toBe('note.txt');
});
it('persists slash command metadata through controller messages.appendSentMessage', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
controller.messages.appendSentMessage({
from: 'user',
to: 'alice',
text: '/compact keep only kanban context',
messageKind: 'slash_command',
slashCommand: {
name: 'compact',
command: '/compact',
args: 'keep only kanban context',
knownDescription: 'Compact the active context',
},
});
controller.messages.appendSentMessage({
from: 'alice',
to: 'user',
text: 'Compacted context.',
messageKind: 'slash_command_result',
commandOutput: {
stream: 'stdout',
commandLabel: '/compact',
},
});
const sentPath = path.join(claudeDir, 'teams', 'my-team', 'sentMessages.json');
const rows = JSON.parse(fs.readFileSync(sentPath, 'utf8'));
expect(rows).toHaveLength(2);
expect(rows[0].messageKind).toBe('slash_command');
expect(rows[0].slashCommand).toMatchObject({
name: 'compact',
command: '/compact',
args: 'keep only kanban context',
});
expect(rows[1].messageKind).toBe('slash_command_result');
expect(rows[1].commandOutput).toEqual({
stream: 'stdout',
commandLabel: '/compact',
});
});
it('canonicalizes local message recipients and guards user-directed sender identity', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
controller.messages.sendMessage({
to: 'team-lead',
from: 'bob',
text: 'Need lead input',
summary: 'Lead input',
actionMode: 'ask',
});
const leadInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json');
const leadRows = JSON.parse(fs.readFileSync(leadInboxPath, 'utf8'));
expect(leadRows).toHaveLength(1);
expect(leadRows[0].to).toBe('alice');
expect(leadRows[0].from).toBe('bob');
expect(leadRows[0].actionMode).toBe('ask');
controller.messages.sendMessage({
to: 'user',
from: 'lead',
text: 'Visible user reply',
summary: 'Reply',
});
const userInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'user.json');
const userRows = JSON.parse(fs.readFileSync(userInboxPath, 'utf8'));
expect(userRows).toHaveLength(1);
expect(userRows[0].to).toBe('user');
expect(userRows[0].from).toBe('alice');
expect(() =>
controller.messages.sendMessage({
to: 'user',
text: 'Missing sender',
})
).toThrow('message_send to user requires from to be the responding team member name');
expect(() =>
controller.messages.sendMessage({
to: 'other-team.alice',
from: 'bob',
text: 'Wrong transport',
})
).toThrow('message_send cannot target another team. Use cross_team_send with toTeam.');
expect(() =>
controller.messages.sendMessage({
to: 'cross_team_send',
from: 'bob',
text: 'Wrong transport',
})
).toThrow('message_send cannot target cross_team_send. Use cross_team_send with toTeam.');
});
it('prevents agent-facing message_send from impersonating the human user', () => {
const claudeDir = makeClaudeDir();
const appController = createController({ teamName: 'my-team', claudeDir });
const agentController = createController({
teamName: 'my-team',
claudeDir,
allowUserMessageSender: false,
});
const appMessage = appController.messages.sendMessage({
to: 'team-lead',
from: 'user',
text: 'Real user question.',
summary: 'User question',
});
expect(appMessage.deliveredToInbox).toBe(true);
expect(() =>
agentController.messages.sendMessage({
to: 'team-lead',
from: 'user',
text: 'Forged user message.',
summary: 'Forged',
})
).toThrow('message_send from user is reserved for the human user');
expect(() =>
agentController.messages.sendMessage({
to: 'team-lead',
text: 'Missing sender should not default to user.',
})
).toThrow('message_send requires from to be your configured teammate name');
const agentMessage = agentController.messages.sendMessage({
to: 'team-lead',
from: 'bob',
text: 'Legitimate teammate message.',
summary: 'Teammate update',
});
expect(agentMessage.deliveredToInbox).toBe(true);
const leadInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json');
const leadRows = JSON.parse(fs.readFileSync(leadInboxPath, 'utf8'));
expect(leadRows.map((row) => row.from)).toEqual(['user', 'bob']);
});
it('wakes task owner on regular comment from another member', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({
subject: 'Investigate',
owner: 'bob',
notifyOwner: false,
});
const commented = controller.tasks.addTaskComment(task.id, {
from: 'alice',
text: 'I found the root cause.',
});
expect(commented.task.comments.at(-1).text).toBe('I found the root cause.');
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows).toHaveLength(1);
expect(rows[0].summary).toContain(`#${task.displayId}`);
expect(rows[0].text).toContain('I found the root cause.');
expect(rows[0].leadSessionId).toBe('lead-session-1');
});
it('normalizes task comment authors at the write boundary', () => {
const claudeDir = makeClaudeDir();
fs.writeFileSync(
path.join(claudeDir, 'teams', 'my-team', 'config.json'),
JSON.stringify(
{
name: 'my-team',
leadSessionId: 'lead-session-1',
members: [
{ name: 'team-lead', role: 'team-lead', providerId: 'codex', provider: 'codex' },
{ name: 'bob', role: 'developer' },
],
},
null,
2
)
);
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Review result', notifyOwner: false });
const fromProvider = controller.tasks.addTaskComment(task.id, {
from: 'codex',
text: 'Lead runtime finished review.',
});
const fromAlias = controller.tasks.addTaskComment(task.id, {
from: 'lead',
text: 'Lead alias finished review.',
});
const fromUser = controller.tasks.addTaskComment(task.id, {
from: 'User',
text: 'User follow-up.',
});
const fromSystem = controller.tasks.addTaskComment(task.id, {
from: 'System',
text: 'System note.',
});
expect(fromProvider.comment.author).toBe('team-lead');
expect(fromAlias.comment.author).toBe('team-lead');
expect(fromUser.comment.author).toBe('user');
expect(fromSystem.comment.author).toBe('system');
});
it('rejects provider and lead aliases for agent-facing task comments', () => {
const claudeDir = makeClaudeDir();
fs.writeFileSync(
path.join(claudeDir, 'teams', 'my-team', 'config.json'),
JSON.stringify(
{
name: 'my-team',
leadSessionId: 'lead-session-1',
members: [
{ name: 'team-lead', role: 'team-lead', providerId: 'codex', provider: 'codex' },
{ name: 'bob', role: 'developer' },
],
},
null,
2
)
);
const appController = createController({ teamName: 'my-team', claudeDir });
const agentController = createController({
teamName: 'my-team',
claudeDir,
allowUserMessageSender: false,
});
const task = appController.tasks.createTask({
subject: 'Reject agent aliases',
owner: 'bob',
notifyOwner: false,
});
expect(() =>
agentController.tasks.addTaskComment(task.id, {
from: 'codex',
text: 'Provider alias should not be accepted from MCP.',
})
).toThrow('Unknown task comment author: codex');
expect(() =>
agentController.tasks.addTaskComment(task.id, {
from: 'lead',
text: 'Lead alias should not be accepted from MCP.',
})
).toThrow('Unknown task comment author: lead');
let unknownAuthorError;
try {
agentController.tasks.addTaskComment(task.id, {
from: 'Codex',
text: 'Provider alias case should not be accepted from MCP.',
});
} catch (error) {
unknownAuthorError = error;
}
expect(unknownAuthorError.message).toContain('Unknown task comment author: Codex');
expect(unknownAuthorError.message).toContain('Use one of: bob, team-lead');
expect(unknownAuthorError.message).not.toContain('user');
expect(unknownAuthorError.message).not.toContain('system');
expect(appController.tasks.getTask(task.id).comments || []).toEqual([]);
});
it('does not map a real teammate named like the lead provider id to the lead', () => {
const claudeDir = makeClaudeDir();
fs.writeFileSync(
path.join(claudeDir, 'teams', 'my-team', 'config.json'),
JSON.stringify(
{
name: 'my-team',
leadSessionId: 'lead-session-1',
members: [
{ name: 'team-lead', role: 'team-lead', providerId: 'codex', provider: 'codex' },
{ name: 'codex', role: 'developer' },
],
},
null,
2
)
);
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Member named codex', notifyOwner: false });
const commented = controller.tasks.addTaskComment(task.id, {
from: 'codex',
text: 'Real teammate comment.',
});
expect(commented.comment.author).toBe('codex');
const agentController = createController({
teamName: 'my-team',
claudeDir,
allowUserMessageSender: false,
});
const agentCommented = agentController.tasks.addTaskComment(task.id, {
from: 'codex',
text: 'Agent-facing real teammate comment.',
});
expect(agentCommented.comment.author).toBe('codex');
});
it('rejects task comments from unknown authors', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({
subject: 'Reject unknown author',
notifyOwner: false,
});
expect(() =>
controller.tasks.addTaskComment(task.id, {
from: 'ghost',
text: 'This should not be persisted.',
})
).toThrow('Unknown task comment author: ghost');
expect(controller.tasks.getTask(task.id).comments || []).toEqual([]);
});
it('prevents agent-facing task_add_comment from impersonating app-owned authors', () => {
const claudeDir = makeClaudeDir();
const appController = createController({ teamName: 'my-team', claudeDir });
const agentController = createController({
teamName: 'my-team',
claudeDir,
allowUserMessageSender: false,
});
const task = appController.tasks.createTask({
subject: 'Reserved comment authors',
notifyOwner: false,
});
const appComment = appController.tasks.addTaskComment(task.id, {
from: 'user',
text: 'Real user comment.',
});
expect(appComment.comment.author).toBe('user');
expect(() =>
agentController.tasks.addTaskComment(task.id, {
from: 'user',
text: 'Forged user comment.',
})
).toThrow('task comment author "user" is reserved for app-owned writes');
expect(() =>
agentController.tasks.addTaskComment(task.id, {
text: 'Missing sender should not default to lead.',
})
).toThrow('task_add_comment requires from to be your configured teammate name');
let unknownAuthorError;
try {
agentController.tasks.addTaskComment(task.id, {
from: 'ghost',
text: 'Unknown teammate should get a useful recovery error.',
});
} catch (error) {
unknownAuthorError = error;
}
expect(unknownAuthorError.message).toContain('Unknown task comment author: ghost');
expect(unknownAuthorError.message).not.toContain('user');
expect(unknownAuthorError.message).not.toContain('system');
const agentComment = agentController.tasks.addTaskComment(task.id, {
from: 'bob',
text: 'Legitimate teammate comment.',
});
expect(agentComment.comment.author).toBe('bob');
const comments = agentController.tasks.getTask(task.id).comments || [];
expect(comments.map((comment) => comment.author)).toEqual(['user', 'bob']);
});
it('keeps internal dependency comments when agent-facing task_complete unblocks a task', () => {
const claudeDir = makeClaudeDir();
const appController = createController({ teamName: 'my-team', claudeDir });
const agentController = createController({
teamName: 'my-team',
claudeDir,
allowUserMessageSender: false,
});
const dependency = appController.tasks.createTask({
subject: 'Prepare calculator API',
owner: 'bob',
notifyOwner: false,
});
const blocked = appController.tasks.createTask({
subject: 'Build calculator UI',
owner: 'bob',
'blocked-by': dependency.displayId,
notifyOwner: false,
});
expect(() => agentController.tasks.completeTask(dependency.id, 'bob')).not.toThrow();
const comments = appController.tasks.getTask(blocked.id).comments || [];
expect(comments).toHaveLength(1);
expect(comments[0].author).toBe('system');
expect(comments[0].id).toBe(`dep-resolved-${dependency.id}-${blocked.id}`);
expect(comments[0].text).toContain('Dependency resolved');
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows).toHaveLength(1);
expect(rows[0].from).toBe('alice');
expect(rows[0].text).toContain('Dependency resolved');
});
it('includes the assigned task ref in owner assignment notifications', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({
subject: 'Implement runtime handoff',
owner: 'bob',
descriptionTaskRefs: [{ taskId: 'related-task', displayId: 'rel12345', teamName: 'my-team' }],
});
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows).toHaveLength(1);
expect(rows[0].summary).toBe(`New task #${task.displayId} assigned`);
expect(rows[0].taskRefs).toEqual([
{ taskId: task.id, displayId: task.displayId, teamName: 'my-team' },
{ taskId: 'related-task', displayId: 'rel12345', teamName: 'my-team' },
]);
});
it('uses Codex-native MCP wording in owner assignment notifications for Codex members', () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.members = [
{ name: 'alice', role: 'team-lead' },
{ name: 'bob', role: 'developer', providerId: 'codex', model: 'gpt-5.4-mini' },
];
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const controller = createController({ teamName: 'my-team', claudeDir });
controller.tasks.createTask({
subject: 'Implement Codex handoff',
owner: 'bob',
});
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows[0].text).toContain('MCP tool agent-teams_message_send');
expect(rows[0].text).toContain('Codex Native visible messaging rule');
expect(rows[0].text).toContain('mcp__agent-teams__task_get');
expect(rows[0].text).not.toContain('notify your lead via SendMessage');
});
it('does not wake owner for self-comments and keeps user clarification sticky until explicitly cleared', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({
subject: 'Need product input',
owner: 'bob',
needsClarification: 'user',
notifyOwner: false,
});
controller.tasks.addTaskComment(task.id, {
from: 'bob',
text: 'Starting to investigate.',
});
const ownerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
expect(fs.existsSync(ownerInboxPath)).toBe(false);
const replied = controller.tasks.addTaskComment(task.id, {
from: 'user',
text: 'Please use the safer option.',
});
expect(replied.task.needsClarification).toBe('user');
const reloaded = controller.tasks.getTask(task.id);
expect(reloaded.needsClarification).toBe('user');
const rows = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
expect(rows).toHaveLength(1);
expect(rows[0].text).toContain('Please use the safer option.');
const cleared = controller.tasks.setNeedsClarification(task.id, 'clear');
expect(cleared.needsClarification).toBeUndefined();
expect(controller.tasks.getTask(task.id).needsClarification).toBeUndefined();
});
it('wakes lead owner on comment from another member', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({
subject: 'Lead-owned task',
owner: 'team-lead',
notifyOwner: false,
});
controller.tasks.addTaskComment(task.id, {
from: 'bob',
text: 'Need your decision here.',
});
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows).toHaveLength(1);
expect(rows[0].from).toBe('bob');
expect(rows[0].to).toBe('alice');
expect(rows[0].text).toContain('Need your decision here.');
});
it('moves review back to pending+needsFix and notifies owner on requestChanges', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Needs revision', owner: 'bob' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
const updated = controller.review.requestChanges(task.id, {
from: 'alice',
comment: 'Please address review feedback.',
});
expect(updated.status).toBe('pending');
expect(updated.reviewState).toBe('needsFix');
expect(updated.comments.at(-1).type).toBe('review_request');
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows.at(-1).source).toBe('system_notification');
expect(rows.at(-1).summary).toContain('Fix request');
expect(rows.at(-1).text).toContain('moved back to pending');
expect(rows.at(-1).text).toContain('request review again');
expect(rows.at(-1).leadSessionId).toBe('lead-session-1');
});
it('ignores mismatched leadSessionId placeholders on review_approve owner notifications', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Approve me', owner: 'bob' });
controller.kanban.addReviewer('alice');
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
controller.review.approveReview(task.id, {
from: 'team-lead',
note: 'Looks good.',
'notify-owner': true,
leadSessionId: 'team-lead',
});
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows.at(-1).summary).toContain('Approved');
expect(rows.at(-1).leadSessionId).toBe('lead-session-1');
});
it('ignores mismatched leadSessionId placeholders on review_request_changes owner notifications', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Needs revision', owner: 'bob' });
controller.kanban.addReviewer('alice');
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
controller.review.requestChanges(task.id, {
from: 'alice',
comment: 'Please address review feedback.',
leadSessionId: 'team-lead',
});
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows.at(-1).summary).toContain('Fix request');
expect(rows.at(-1).leadSessionId).toBe('lead-session-1');
});
it('keeps approved tasks in awareness ordered by freshness', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const approvedTasks = Array.from({ length: 12 }, (_, index) =>
controller.tasks.createTask({
subject: `Approved ${index + 1}`,
owner: 'bob',
status: 'completed',
reviewState: 'approved',
createdAt: `2026-01-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`,
})
);
const briefing = await controller.tasks.taskBriefing('bob');
expect(briefing).toContain('Awareness:');
expect(briefing).toContain(`#${approvedTasks[11].displayId}`);
expect(briefing).toContain(`#${approvedTasks[2].displayId}`);
expect(briefing).toContain(`#${approvedTasks[1].displayId}`);
expect(briefing).toContain(`#${approvedTasks[0].displayId}`);
expect(briefing.indexOf(`#${approvedTasks[11].displayId}`)).toBeLessThan(
briefing.indexOf(`#${approvedTasks[0].displayId}`)
);
});
it('builds derived lead briefing and filtered task inventory', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const queuedTask = controller.tasks.createTask({
subject: 'Queued implementation',
owner: 'bob',
notifyOwner: false,
});
const unassignedTask = controller.tasks.createTask({
subject: 'Needs owner',
notifyOwner: false,
});
const reviewTask = controller.tasks.createTask({
subject: 'Needs review pickup',
owner: 'bob',
notifyOwner: false,
});
controller.tasks.completeTask(reviewTask.id, 'bob');
controller.review.requestReview(reviewTask.id, { from: 'alice', reviewer: 'alice' });
const leadBriefing = await controller.tasks.leadBriefing();
expect(leadBriefing).toContain('Lead queue for alice on team "my-team":');
expect(leadBriefing).toContain(
'Primary lead queue. Sections below already represent lead-owned actions or watch-only context.'
);
expect(leadBriefing).toContain(
'Use task_list only for search, filtering, and drill-down inventory lookups.'
);
expect(leadBriefing).toContain('Needs owner assignment:');
expect(leadBriefing).toContain(`#${unassignedTask.displayId}`);
expect(leadBriefing).toContain('Lead-owned follow-up:');
expect(leadBriefing).toContain(`#${reviewTask.displayId}`);
const reviewInventory = controller.tasks.listTaskInventory({ reviewState: 'review' });
expect(reviewInventory).toHaveLength(1);
expect(reviewInventory[0].id).toBe(reviewTask.id);
const ownerPendingInventory = controller.tasks.listTaskInventory({
owner: 'bob',
status: 'pending',
});
expect(ownerPendingInventory.map((task) => task.id)).toEqual([queuedTask.id]);
});
it('uses legacy kanban reviewer as a migration fallback for active review tasks', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.members.push({ name: 'carol', role: 'reviewer' });
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const controller = createController({ teamName: 'my-team', claudeDir });
const reviewTask = controller.tasks.createTask({
subject: 'Legacy review assignment',
owner: 'bob',
status: 'completed',
reviewState: 'review',
notifyOwner: false,
});
fs.writeFileSync(
path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json'),
JSON.stringify(
{
teamName: 'my-team',
reviewers: [],
tasks: {
[reviewTask.id]: {
column: 'review',
reviewer: 'carol',
movedAt: '2026-01-01T00:00:00.000Z',
},
},
},
null,
2
)
);
const reviewerBriefing = await controller.tasks.taskBriefing('carol');
expect(reviewerBriefing).toContain(
'Primary queue for carol. Act only on Actionable items. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner.'
);
expect(reviewerBriefing).toContain('Actionable:');
expect(reviewerBriefing).toContain(`#${reviewTask.displayId}`);
expect(reviewerBriefing).toContain('reviewer=carol');
const leadBriefing = await controller.tasks.leadBriefing();
expect(leadBriefing).toContain(
'Use task_list only for search, filtering, and drill-down inventory lookups.'
);
expect(leadBriefing).toContain('Watching:');
expect(leadBriefing).toContain(`#${reviewTask.displayId}`);
expect(leadBriefing).not.toContain('review_reviewer_missing');
});
it('does not treat role names containing lead as canonical team lead', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify(
{
name: 'my-team',
leadSessionId: 'lead-session-1',
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'alice', role: 'tech lead' },
{ name: 'bob', role: 'developer' },
],
},
null,
2
)
);
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Alice owns this', owner: 'alice' });
const aliceBriefing = await controller.tasks.taskBriefing('alice');
const leadBriefing = await controller.tasks.leadBriefing();
expect(aliceBriefing).toContain('Actionable:');
expect(aliceBriefing).toContain(`#${task.displayId}`);
expect(aliceBriefing).toContain('actionOwner=@alice');
expect(leadBriefing).not.toContain(`#${task.displayId}`);
});
it('recognizes lead and orchestrator agent types as canonical team leads', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify(
{
name: 'my-team',
leadSessionId: 'lead-session-1',
members: [
{ name: 'alice', role: 'developer' },
{ name: 'leadbot', agentType: 'lead' },
{ name: 'opsbot', agentType: 'orchestrator' },
],
},
null,
2
)
);
const controller = createController({ teamName: 'my-team', claudeDir });
const aliceTask = controller.tasks.createTask({ subject: 'Alice owns this', owner: 'alice' });
const leadTask = controller.tasks.createTask({ subject: 'Lead owns this', owner: 'leadbot' });
const aliceBriefing = await controller.tasks.taskBriefing('alice');
const leadBriefing = await controller.tasks.leadBriefing();
expect(aliceBriefing).toContain(`#${aliceTask.displayId}`);
expect(aliceBriefing).toContain('actionOwner=@alice');
expect(aliceBriefing).not.toContain(`#${leadTask.displayId}`);
expect(leadBriefing).toContain(`#${leadTask.displayId}`);
expect(leadBriefing).not.toContain(`#${aliceTask.displayId}`);
});
it('stores canonical member names for lead aliases in owners, reviewers, and reviewer config', () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify(
{
name: 'my-team',
members: [
{ name: 'leadbot', agentType: 'lead' },
{ name: 'alice', role: 'reviewer' },
{ name: 'bob', role: 'developer' },
],
},
null,
2
)
);
const controller = createController({ teamName: 'my-team', claudeDir });
const leadOwnedTask = controller.tasks.createTask({
subject: 'Lead alias owner',
owner: 'lead',
});
expect(leadOwnedTask.owner).toBe('leadbot');
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(
false
);
const reassignedTask = controller.tasks.createTask({
subject: 'Reassign alias owner',
owner: 'bob',
});
expect(controller.tasks.setTaskOwner(reassignedTask.id, 'team-lead').owner).toBe('leadbot');
controller.kanban.addReviewer('lead');
expect(controller.kanban.listReviewers()).toEqual(['leadbot']);
const reviewTask = controller.tasks.createTask({ subject: 'Review alias', owner: 'bob' });
controller.tasks.completeTask(reviewTask.id, 'bob');
controller.review.requestReview(reviewTask.id, { from: 'alice', reviewer: 'lead' });
const requested = controller.tasks
.getTask(reviewTask.id)
.historyEvents.filter((event) => event.type === 'review_requested')
.at(-1);
expect(requested.reviewer).toBe('leadbot');
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'leadbot.json'))).toBe(
true
);
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(
false
);
});
it('rejects task_briefing for unknown members', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
await expect(controller.tasks.taskBriefing('bbo')).rejects.toThrow(
'Member not found in team metadata or inboxes: bbo'
);
});
it('warns when task_briefing member exists only because of inbox state', async () => {
const claudeDir = makeClaudeDir();
const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes');
fs.mkdirSync(inboxDir, { recursive: true });
fs.writeFileSync(path.join(inboxDir, 'bbo.json'), '[]', 'utf8');
const controller = createController({ teamName: 'my-team', claudeDir });
const briefing = await controller.tasks.taskBriefing('bbo');
expect(briefing).toContain('Board warnings:');
expect(briefing).toContain(
'Member identity warning: bbo is known only from inbox state, not team config/member metadata. Verify the member name before acting.'
);
});
it('clears kanban tasks and column order when review tasks leave review', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Column cleanup', owner: 'bob' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
controller.kanban.updateColumnOrder('review', [task.id]);
controller.review.requestChanges(task.id, { from: 'alice', comment: 'Needs work.' });
let kanbanState = controller.kanban.getKanbanState();
expect(kanbanState.tasks[task.id]).toBeUndefined();
expect(kanbanState.columnOrder).toBeUndefined();
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
controller.kanban.updateColumnOrder('review', [task.id]);
const deleted = controller.tasks.softDeleteTask(task.id, 'bob');
expect(deleted.status).toBe('deleted');
expect(deleted.reviewState).toBe('none');
kanbanState = controller.kanban.getKanbanState();
expect(kanbanState.tasks[task.id]).toBeUndefined();
expect(kanbanState.columnOrder).toBeUndefined();
});
it('clears kanban tasks and column order when task_set_status deletes a review task', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({
subject: 'Generic status delete cleanup',
owner: 'bob',
});
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
controller.kanban.updateColumnOrder('review', [task.id]);
const deleted = controller.tasks.setTaskStatus(task.id, 'deleted', 'bob');
expect(deleted.status).toBe('deleted');
expect(deleted.reviewState).toBe('none');
const kanbanState = controller.kanban.getKanbanState();
expect(kanbanState.tasks[task.id]).toBeUndefined();
expect(kanbanState.columnOrder).toBeUndefined();
});
it('surfaces unreadable task rows as board anomalies', async () => {
const claudeDir = makeClaudeDir();
fs.writeFileSync(path.join(claudeDir, 'tasks', 'my-team', 'broken.json'), '{ bad json', 'utf8');
const controller = createController({ teamName: 'my-team', claudeDir });
const leadBriefing = await controller.tasks.leadBriefing();
expect(leadBriefing).toContain('Board anomalies:');
expect(leadBriefing).toContain('unreadable_task (broken)');
expect(leadBriefing).toContain('anomalies=1');
});
it('caps large member briefings and points agents to drill-down tools', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
for (let i = 0; i < 60; i += 1) {
controller.tasks.createTask({
subject: `Large queue task ${i}`,
description: 'x'.repeat(3000),
owner: 'bob',
status: 'in_progress',
comments: Array.from({ length: 8 }, (_, index) => ({
id: `comment-${i}-${index}`,
author: 'bob',
text: 'y'.repeat(1000),
createdAt: new Date(Date.UTC(2026, 0, 1, 0, i, index)).toISOString(),
})),
notifyOwner: false,
});
}
const briefing = await controller.tasks.taskBriefing('bob');
const renderedTaskLines = briefing.split('\n').filter((line) => line.startsWith('- #'));
expect(renderedTaskLines.length).toBe(50);
expect(briefing).toContain('10 more Actionable item(s) omitted');
expect(briefing).toContain('Use task_list filters and task_get for drill-down.');
expect(briefing.length).toBeLessThan(100_000);
});
it('resets approved review state when work is reopened to pending', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Approved then reopened', owner: 'bob' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
controller.review.approveReview(task.id, { from: 'alice' });
const reopened = controller.tasks.setTaskStatus(task.id, 'pending', 'alice');
expect(reopened.status).toBe('pending');
expect(reopened.reviewState).toBe('none');
expect(controller.tasks.listTaskInventory({ reviewState: 'approved' })).toHaveLength(0);
expect(controller.tasks.listTaskInventory({ owner: 'bob' })[0].reviewState).toBe('none');
const bobBriefing = await controller.tasks.taskBriefing('bob');
expect(bobBriefing).toContain(`#${task.displayId}`);
expect(bobBriefing).toContain('reason=owner_ready');
expect(bobBriefing).toContain('actionOwner=@bob');
});
it('guards direct kanban_clear against active review state while keeping no-op clears safe', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({
subject: 'Do not unapprove directly',
owner: 'bob',
});
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
controller.review.approveReview(task.id, { from: 'alice' });
expect(() => controller.kanban.clearKanban(task.id)).toThrow('reviewState=approved');
expect(controller.tasks.getTask(task.id).reviewState).toBe('approved');
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved');
controller.tasks.setTaskStatus(task.id, 'pending', 'alice');
const noOpState = controller.kanban.clearKanban(task.id);
expect(noOpState.tasks[task.id]).toBeUndefined();
expect(controller.tasks.getTask(task.id).reviewState).toBe('none');
});
it('does not let inbox-only names become real owners or reviewers', async () => {
const claudeDir = makeClaudeDir();
const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes');
fs.mkdirSync(inboxDir, { recursive: true });
fs.writeFileSync(path.join(inboxDir, 'boob.json'), '[]', 'utf8');
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Typo owner guard', owner: 'bob' });
expect(() => controller.tasks.setTaskOwner(task.id, 'boob')).toThrow(
'Unknown task owner: boob'
);
controller.tasks.completeTask(task.id, 'bob');
expect(() =>
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'boob' })
).toThrow('Unknown reviewer: boob');
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
rawTask.owner = 'boob';
rawTask.status = 'pending';
rawTask.reviewState = 'none';
fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2));
const leadBriefing = await controller.tasks.leadBriefing();
expect(leadBriefing).toContain(`#${task.displayId}`);
expect(leadBriefing).toContain('reason=owner_invalid');
expect(leadBriefing).toContain('Needs owner assignment:');
});
it('prevents deleted tasks from being resurrected by normal work tools', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Deleted work guard', owner: 'bob' });
controller.tasks.softDeleteTask(task.id, 'bob');
expect(() => controller.tasks.startTask(task.id, 'bob')).toThrow(
'use task_restore before starting work'
);
expect(() => controller.tasks.completeTask(task.id, 'bob')).toThrow(
'use task_restore before changing status'
);
expect(() => controller.tasks.setTaskStatus(task.id, 'pending', 'bob')).toThrow(
'use task_restore before changing status'
);
const restored = controller.tasks.restoreTask(task.id, 'alice');
expect(restored.status).toBe('pending');
expect(restored.reviewState).toBe('none');
});
it('rejects task_restore for non-deleted tasks', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({
subject: 'Approved task must stay approved',
owner: 'bob',
});
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
controller.review.approveReview(task.id, { from: 'alice' });
expect(() => controller.tasks.restoreTask(task.id, 'alice')).toThrow(
'task_restore only restores deleted tasks'
);
expect(controller.tasks.getTask(task.id).status).toBe('completed');
expect(controller.tasks.getTask(task.id).reviewState).toBe('approved');
});
it('uses actual kanban overlay for kanbanColumn inventory filters', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Approved without overlay', owner: 'bob' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
controller.review.approveReview(task.id, { from: 'alice' });
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
const state = JSON.parse(fs.readFileSync(kanbanPath, 'utf8'));
delete state.tasks[task.id];
fs.writeFileSync(kanbanPath, JSON.stringify(state, null, 2));
expect(
controller.tasks.listTaskInventory({ reviewState: 'approved' }).map((row) => row.id)
).toContain(task.id);
expect(controller.tasks.listTaskInventory({ kanbanColumn: 'approved' })).toHaveLength(0);
});
it('repairs an invalid review_started actor without losing the assigned reviewer', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({ subject: 'Repair reviewer actor', owner: 'bob' });
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
rawTask.historyEvents.push({
id: 'bad-review-start',
timestamp: '2026-01-01T00:00:00.000Z',
type: 'review_started',
from: 'review',
to: 'review',
actor: 'alicce',
});
fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2));
controller.review.startReview(task.id, { from: 'alice' });
const startedEvents = controller.tasks
.getTask(task.id)
.historyEvents.filter((event) => event.type === 'review_started');
expect(startedEvents.at(-1).actor).toBe('alice');
const reviewerBriefing = await controller.tasks.taskBriefing('alice');
expect(reviewerBriefing).toContain(`#${task.displayId}`);
expect(reviewerBriefing).toContain('reviewer=alice');
expect(reviewerBriefing).not.toContain('review_reviewer_missing');
});
it('repairs a valid but mismatched review_started actor back to the assigned reviewer', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.members.push({ name: 'carol', role: 'reviewer' });
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({
subject: 'Repair mismatched reviewer actor',
owner: 'bob',
});
controller.tasks.completeTask(task.id, 'bob');
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
rawTask.historyEvents.push({
id: 'wrong-review-start',
timestamp: '2026-01-01T00:00:00.000Z',
type: 'review_started',
from: 'review',
to: 'review',
actor: 'carol',
});
rawTask.reviewIntervals = [{ reviewer: 'carol', startedAt: '2026-01-01T00:00:00.000Z' }];
fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2));
controller.review.startReview(task.id);
const repairedTask = controller.tasks.getTask(task.id);
const startedEvents = repairedTask.historyEvents.filter(
(event) => event.type === 'review_started'
);
expect(startedEvents.at(-1).actor).toBe('alice');
expect(repairedTask.reviewIntervals).toHaveLength(2);
expect(repairedTask.reviewIntervals[0]).toMatchObject({
reviewer: 'carol',
completedAt: expect.any(String),
});
expect(repairedTask.reviewIntervals[1].reviewer).toBe('alice');
expect(repairedTask.reviewIntervals[1].completedAt).toBeUndefined();
const aliceBriefing = await controller.tasks.taskBriefing('alice');
const carolBriefing = await controller.tasks.taskBriefing('carol');
expect(aliceBriefing).toContain(`#${task.displayId}`);
expect(aliceBriefing).toContain('reviewer=alice');
expect(carolBriefing).not.toContain('reason=review_in_progress');
});
it('bounds anomaly and subject rendering on primary queue surfaces', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const longSubject = `Long subject ${'x'.repeat(5000)}`;
const task = controller.tasks.createTask({
subject: longSubject,
owner: 'bob',
notifyOwner: false,
});
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
fs.writeFileSync(
kanbanPath,
JSON.stringify(
{
teamName: 'my-team',
reviewers: [],
tasks: {
missing: { column: 'review', movedAt: '2026-01-01T00:00:00.000Z' },
},
columnOrder: { review: ['missing', task.id] },
},
null,
2
)
);
fs.writeFileSync(
path.join(claudeDir, 'tasks', 'my-team', 'bad-status.json'),
JSON.stringify({ id: 'bad-status', subject: 'Bad status', status: 'inprogress' }, null, 2),
'utf8'
);
for (let index = 0; index < 30; index += 1) {
fs.writeFileSync(
path.join(claudeDir, 'tasks', 'my-team', `broken-${index}.json`),
'{ bad json',
'utf8'
);
}
const briefing = await controller.tasks.leadBriefing();
expect(briefing).toContain('Board anomalies:');
expect(briefing).toContain('Invalid task status "inprogress"');
expect(briefing).toContain('stale_kanban_task (missing)');
expect(briefing).toContain('more board anomaly item(s) omitted');
expect(briefing).not.toContain('x'.repeat(1000));
const inventoryRow = controller.tasks.listTaskInventory({ owner: 'bob' })[0];
expect(inventoryRow.subject).toContain('[truncated]');
expect(inventoryRow.subject.length).toBeLessThan(300);
});
it('marks stale processes stopped during listing and supports unregister', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const processesPath = path.join(claudeDir, 'teams', 'my-team', 'processes.json');
fs.writeFileSync(
processesPath,
JSON.stringify(
[
{
id: 'stale-entry',
pid: 999999,
label: 'stale',
registeredAt: '2024-01-01T00:00:00.000Z',
},
],
null,
2
)
);
const listed = controller.processes.listProcesses();
expect(listed).toHaveLength(1);
expect(listed[0].alive).toBe(false);
expect(listed[0].stoppedAt).toBeTruthy();
const persisted = JSON.parse(fs.readFileSync(processesPath, 'utf8'));
expect(persisted[0].stoppedAt).toBeTruthy();
controller.processes.unregisterProcess({ id: 'stale-entry' });
expect(controller.processes.listProcesses()).toEqual([]);
});
it('task_add_comment succeeds even when owner notification write fails', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({
subject: 'Comment resilience',
owner: 'bob',
notifyOwner: false,
});
// Make inboxes directory read-only to force notification write failure
const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes');
fs.mkdirSync(inboxDir, { recursive: true });
// Write a broken file that will cause JSON parse failure on append
fs.writeFileSync(path.join(inboxDir, 'bob.json'), 'NOT VALID JSON');
// Comment should still succeed despite notification failure
const commented = controller.tasks.addTaskComment(task.id, {
from: 'alice',
text: 'This should persist despite notification failure.',
});
expect(commented.commentId).toBeTruthy();
expect(commented.task.comments).toHaveLength(1);
expect(commented.task.comments[0].text).toBe(
'This should persist despite notification failure.'
);
});
it('launches and stops a team through the runtime control API bridge', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const calls = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
if (method === 'POST' && url === '/api/teams/my-team/launch') {
return { body: { runId: 'run-123' } };
}
if (method === 'GET' && url === '/api/teams/provisioning/run-123') {
return {
body: {
runId: 'run-123',
teamName: 'my-team',
state: 'ready',
message: 'Ready',
startedAt: '2026-03-12T00:00:00.000Z',
updatedAt: '2026-03-12T00:00:01.000Z',
},
};
}
if (method === 'POST' && url === '/api/teams/my-team/stop') {
return {
body: {
teamName: 'my-team',
isAlive: false,
runId: null,
progress: null,
},
};
}
if (method === 'GET' && url === '/api/teams/my-team/runtime') {
return {
body: {
teamName: 'my-team',
isAlive: false,
runId: null,
progress: null,
},
};
}
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
});
try {
const launched = await controller.runtime.launchTeam({
cwd: '/tmp/project',
controlUrl: server.baseUrl,
});
expect(launched.runId).toBe('run-123');
expect(launched.isAlive).toBe(true);
expect(launched.progress.state).toBe('ready');
const stopped = await controller.runtime.stopTeam({
controlUrl: server.baseUrl,
});
expect(stopped.isAlive).toBe(false);
expect(stopped.runId).toBeNull();
expect(calls).toEqual([
{
method: 'POST',
url: '/api/teams/my-team/launch',
body: { cwd: '/tmp/project' },
},
{
method: 'GET',
url: '/api/teams/provisioning/run-123',
body: undefined,
},
{
method: 'POST',
url: '/api/teams/my-team/stop',
body: undefined,
},
{
method: 'GET',
url: '/api/teams/my-team/runtime',
body: undefined,
},
]);
} finally {
await server.close();
}
});
it('forwards OpenCode runtime MCP calls to the app control API', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const calls = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
if (method === 'POST' && url === '/api/teams/my-team/opencode/runtime/bootstrap-checkin') {
return { body: { ok: true, state: 'accepted' } };
}
if (method === 'POST' && url === '/api/teams/my-team/opencode/runtime/deliver-message') {
return { body: { ok: true, state: 'delivered' } };
}
if (method === 'POST' && url === '/api/teams/my-team/opencode/runtime/task-event') {
return { body: { ok: true, state: 'recorded' } };
}
if (method === 'POST' && url === '/api/teams/my-team/opencode/runtime/heartbeat') {
return { body: { ok: true, state: 'accepted' } };
}
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
});
try {
await controller.runtime.runtimeBootstrapCheckin({
controlUrl: server.baseUrl,
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
});
await controller.runtime.runtimeDeliverMessage({
controlUrl: server.baseUrl,
idempotencyKey: 'idem-1',
runId: 'run-oc',
fromMemberName: 'bob',
runtimeSessionId: 'ses-1',
to: 'user',
text: 'hello',
});
await controller.runtime.runtimeTaskEvent({
controlUrl: server.baseUrl,
idempotencyKey: 'idem-task-1',
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
taskId: 'task-1',
event: 'started',
});
await controller.runtime.runtimeHeartbeat({
controlUrl: server.baseUrl,
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
});
expect(calls.map((call) => call.url)).toEqual([
'/api/teams/my-team/opencode/runtime/bootstrap-checkin',
'/api/teams/my-team/opencode/runtime/deliver-message',
'/api/teams/my-team/opencode/runtime/task-event',
'/api/teams/my-team/opencode/runtime/heartbeat',
]);
expect(calls[0].body).toEqual({
teamName: 'my-team',
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
});
} finally {
await server.close();
}
});
it('retries OpenCode bootstrap check-in on retryable control API failures', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const calls = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
if (calls.length < 3) {
return { statusCode: 500, body: { error: 'temporary bootstrap failure' } };
}
return { body: { ok: true, state: 'accepted', diagnostics: [] } };
});
try {
const result = await controller.runtime.runtimeBootstrapCheckin({
controlUrl: server.baseUrl,
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
});
expect(result).toMatchObject({
ok: true,
state: 'accepted',
diagnostics: expect.arrayContaining(['opencode_bootstrap_checkin_retry']),
});
expect(calls).toHaveLength(3);
expect(calls.map((call) => call.body)).toEqual([
{
teamName: 'my-team',
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
},
{
teamName: 'my-team',
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
},
{
teamName: 'my-team',
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
},
]);
} finally {
await server.close();
}
});
it('accepts idempotent OpenCode bootstrap check-in after a timed-out committed request', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const calls = [];
let committed = false;
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
if (!committed) {
committed = true;
await new Promise((resolve) => setTimeout(resolve, 1200));
return { body: { ok: true, state: 'accepted', diagnostics: [] } };
}
return {
body: {
ok: true,
state: 'accepted',
diagnostics: ['opencode_bootstrap_checkin_duplicate_accepted'],
},
};
});
try {
const result = await controller.runtime.runtimeBootstrapCheckin({
controlUrl: server.baseUrl,
waitTimeoutMs: 1000,
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
});
expect(result).toMatchObject({
ok: true,
state: 'accepted',
diagnostics: expect.arrayContaining([
'opencode_bootstrap_checkin_duplicate_accepted',
'opencode_bootstrap_checkin_retry',
]),
});
expect(calls).toHaveLength(2);
} finally {
await server.close();
}
});
it('does not retry OpenCode bootstrap check-in on validation failures', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const calls = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
return { statusCode: 400, body: { error: 'invalid bootstrap payload' } };
});
try {
await expect(
controller.runtime.runtimeBootstrapCheckin({
controlUrl: server.baseUrl,
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
})
).rejects.toThrow('invalid bootstrap payload');
expect(calls).toHaveLength(1);
} finally {
await server.close();
}
});
it('fails OpenCode bootstrap check-in clearly after bounded timeout retries', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const calls = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
await new Promise((resolve) => setTimeout(resolve, 1200));
return { body: { ok: true, state: 'accepted' } };
});
try {
await expect(
controller.runtime.runtimeBootstrapCheckin({
controlUrl: server.baseUrl,
waitTimeoutMs: 1000,
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
})
).rejects.toThrow('Timed out calling team control API');
expect(calls).toHaveLength(3);
} finally {
await server.close();
}
});
it('forwards member work sync status and reports to the app validator', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const calls = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
if (method === 'POST' && url === '/api/teams/my-team/member-work-sync/bob/refresh') {
return {
body: {
teamName: 'my-team',
memberName: 'bob',
state: 'needs_sync',
agenda: {
teamName: 'my-team',
memberName: 'bob',
generatedAt: '2026-04-29T00:00:00.000Z',
fingerprint: 'agenda:v1:abc',
items: [],
diagnostics: [],
},
reportToken: 'wrs:v1.test.token',
reportTokenExpiresAt: '2026-04-29T00:15:00.000Z',
evaluatedAt: '2026-04-29T00:00:00.000Z',
diagnostics: ['no_current_report'],
},
};
}
if (method === 'POST' && url === '/api/teams/my-team/member-work-sync/report') {
return { body: { accepted: true, code: 'accepted', status: body } };
}
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
});
try {
const status = await controller.workSync.memberWorkSyncStatus({
controlUrl: server.baseUrl,
from: 'bob',
});
const report = await controller.workSync.memberWorkSyncReport({
controlUrl: server.baseUrl,
memberName: 'bob',
state: 'still_working',
agendaFingerprint: 'agenda:v1:abc',
reportToken: 'wrs:v1.test.token',
taskIds: [' task-1 ', '', 'task-1'],
note: 'Continuing work',
leaseTtlMs: 120000,
});
expect(status.state).toBe('needs_sync');
expect(report.accepted).toBe(true);
expect(calls).toEqual([
{
method: 'POST',
url: '/api/teams/my-team/member-work-sync/bob/refresh',
body: {},
},
{
method: 'POST',
url: '/api/teams/my-team/member-work-sync/report',
body: {
teamName: 'my-team',
memberName: 'bob',
state: 'still_working',
agendaFingerprint: 'agenda:v1:abc',
reportToken: 'wrs:v1.test.token',
taskIds: ['task-1'],
note: 'Continuing work',
leaseTtlMs: 120000,
},
},
]);
} finally {
await server.close();
}
});
it('records member work sync report intents only when the app validator is unavailable', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const pending = await controller.workSync.memberWorkSyncReport({
memberName: 'bob',
state: 'still_working',
agendaFingerprint: 'agenda:v1:abc',
reportToken: 'wrs:v1.test.token',
taskIds: ['task-1'],
});
expect(pending.pendingValidation).toBe(true);
expect(pending.accepted).toBe(false);
const intentFile = path.join(
claudeDir,
'teams',
'my-team',
'.member-work-sync',
'pending-reports.json'
);
const intents = JSON.parse(fs.readFileSync(intentFile, 'utf8'));
expect(Object.values(intents.intents)).toEqual([
expect.objectContaining({
teamName: 'my-team',
memberName: 'bob',
reason: 'control_api_unavailable',
status: 'pending',
request: expect.objectContaining({
memberName: 'bob',
source: 'mcp',
reportToken: 'wrs:v1.test.token',
}),
}),
]);
});
it('does not record pending work sync intents for app-side validation rejections', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const server = await startControlServer(async () => ({
statusCode: 400,
body: { error: 'stale_fingerprint' },
}));
try {
await expect(
controller.workSync.memberWorkSyncReport({
controlUrl: server.baseUrl,
memberName: 'bob',
state: 'still_working',
agendaFingerprint: 'agenda:v1:stale',
reportToken: 'wrs:v1.test.token',
})
).rejects.toThrow('stale_fingerprint');
expect(
fs.existsSync(
path.join(claudeDir, 'teams', 'my-team', '.member-work-sync', 'pending-reports.json')
)
).toBe(false);
} finally {
await server.close();
}
});
it('prefers the published control endpoint over a stale env URL', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const previousUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
const server = await startControlServer(async ({ method, url }) => {
if (method === 'POST' && url === '/api/teams/my-team/launch') {
return { body: { runId: 'run-fresh' } };
}
if (method === 'GET' && url === '/api/teams/provisioning/run-fresh') {
return {
body: {
runId: 'run-fresh',
teamName: 'my-team',
state: 'ready',
message: 'Ready',
startedAt: '2026-03-12T00:00:00.000Z',
updatedAt: '2026-03-12T00:00:01.000Z',
},
};
}
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
});
try {
process.env.CLAUDE_TEAM_CONTROL_URL = 'http://127.0.0.1:1';
writeControlApiState(claudeDir, server.baseUrl);
const launched = await controller.runtime.launchTeam({
cwd: '/tmp/project',
});
expect(launched.runId).toBe('run-fresh');
expect(launched.progress.state).toBe('ready');
} finally {
if (previousUrl === undefined) {
delete process.env.CLAUDE_TEAM_CONTROL_URL;
} else {
process.env.CLAUDE_TEAM_CONTROL_URL = previousUrl;
}
await server.close();
}
});
it('falls back to the env endpoint when the published control file is stale', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const previousUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
const server = await startControlServer(async ({ method, url }) => {
if (method === 'POST' && url === '/api/teams/my-team/launch') {
return { body: { runId: 'run-env' } };
}
if (method === 'GET' && url === '/api/teams/provisioning/run-env') {
return {
body: {
runId: 'run-env',
teamName: 'my-team',
state: 'ready',
message: 'Ready',
startedAt: '2026-03-12T00:00:00.000Z',
updatedAt: '2026-03-12T00:00:01.000Z',
},
};
}
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
});
try {
process.env.CLAUDE_TEAM_CONTROL_URL = server.baseUrl;
writeControlApiState(claudeDir, 'http://127.0.0.1:1');
const launched = await controller.runtime.launchTeam({
cwd: '/tmp/project',
});
expect(launched.runId).toBe('run-env');
expect(launched.progress.state).toBe('ready');
} finally {
if (previousUrl === undefined) {
delete process.env.CLAUDE_TEAM_CONTROL_URL;
} else {
process.env.CLAUDE_TEAM_CONTROL_URL = previousUrl;
}
await server.close();
}
});
it('falls back to the next control endpoint when the first one responds with 404', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const previousUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
const staleServer = await startControlServer(async () => {
return { statusCode: 404, body: { error: 'Not found' } };
});
const liveServer = await startControlServer(async ({ method, url }) => {
if (method === 'POST' && url === '/api/teams/my-team/launch') {
return { body: { runId: 'run-live' } };
}
if (method === 'GET' && url === '/api/teams/provisioning/run-live') {
return {
body: {
runId: 'run-live',
teamName: 'my-team',
state: 'ready',
message: 'Ready',
startedAt: '2026-03-12T00:00:00.000Z',
updatedAt: '2026-03-12T00:00:01.000Z',
},
};
}
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
});
try {
writeControlApiState(claudeDir, staleServer.baseUrl);
process.env.CLAUDE_TEAM_CONTROL_URL = liveServer.baseUrl;
const launched = await controller.runtime.launchTeam({
cwd: '/tmp/project',
});
expect(launched.runId).toBe('run-live');
expect(launched.progress.state).toBe('ready');
} finally {
if (previousUrl === undefined) {
delete process.env.CLAUDE_TEAM_CONTROL_URL;
} else {
process.env.CLAUDE_TEAM_CONTROL_URL = previousUrl;
}
await staleServer.close();
await liveServer.close();
}
});
describe('lookupMessage', () => {
it('finds a message by exact messageId from sentMessages', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const sent = controller.messages.appendSentMessage({
from: 'team-lead',
to: 'bob',
text: 'Please check the logs',
source: 'user_sent',
});
const result = controller.messages.lookupMessage(sent.messageId);
expect(result.message.messageId).toBe(sent.messageId);
expect(result.message.text).toBe('Please check the logs');
expect(result.store).toBe('sent');
});
it('finds a message by exact messageId from inbox', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const delivered = controller.messages.sendMessage({
to: 'bob',
from: 'user',
text: 'Deploy to staging',
source: 'inbox',
});
const result = controller.messages.lookupMessage(delivered.messageId);
expect(result.message.messageId).toBe(delivered.messageId);
expect(result.message.text).toBe('Deploy to staging');
expect(result.store).toBe('inbox:bob');
});
it('throws on unknown messageId', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
expect(() => controller.messages.lookupMessage('nonexistent-id')).toThrow(
'Message not found: nonexistent-id'
);
});
it('throws on missing messageId', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
expect(() => controller.messages.lookupMessage('')).toThrow('Missing messageId');
});
it('does not match by relayOfMessageId', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
controller.messages.sendMessage({
to: 'bob',
from: 'team-lead',
text: 'Relayed message',
relayOfMessageId: 'original-msg-123',
source: 'system_notification',
});
// The relayOfMessageId should NOT be found as a direct messageId match
expect(() => controller.messages.lookupMessage('original-msg-123')).toThrow(
'Message not found: original-msg-123'
);
});
it('rejects ambiguous messageId found in multiple stores', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
// Manually write same messageId to both sent and inbox
const sentPath = path.join(claudeDir, 'teams', 'my-team', 'sentMessages.json');
const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes');
fs.mkdirSync(inboxDir, { recursive: true });
const inboxPath = path.join(inboxDir, 'bob.json');
const dupeId = 'dupe-message-id';
fs.writeFileSync(sentPath, JSON.stringify([{ messageId: dupeId, text: 'copy-1' }]));
fs.writeFileSync(inboxPath, JSON.stringify([{ messageId: dupeId, text: 'copy-2' }]));
expect(() => controller.messages.lookupMessage(dupeId)).toThrow(
'Ambiguous messageId: dupe-message-id found in multiple stores'
);
});
});
});