feat: enhance review and messaging functionalities

- Added requestReview method to facilitate task review requests, integrating with kanban and messaging systems.
- Updated taskStore to manage review states, ensuring compatibility with kanban columns.
- Enhanced message handling by introducing appendSentMessage for better tracking of sent messages.
- Improved task management by normalizing review states and integrating them into task creation and retrieval processes.
- Updated tests to cover new review and messaging features, ensuring robust functionality across components.
This commit is contained in:
iliya 2026-03-07 17:47:28 +02:00
parent 4cf330e8cc
commit 95d610f43b
33 changed files with 750 additions and 157 deletions

View file

@ -15,7 +15,7 @@
</p> </p>
<p align="center"> <p align="center">
<sub>100% free, open source. No API keys. No configuration. Runs entirely locally.</sub> <sub>100% free, open source. No API keys. No configuration. Runs entirely locally. Not just coding agents.</sub>
</p> </p>
<br /> <br />

View file

@ -1,5 +1,6 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const taskStore = require('./taskStore.js');
function nowIso() { function nowIso() {
return new Date().toISOString(); return new Date().toISOString();
@ -80,6 +81,10 @@ function setKanbanColumn(paths, teamName, taskId, column) {
? { column: 'review', reviewer: null, movedAt: nowIso() } ? { column: 'review', reviewer: null, movedAt: nowIso() }
: { column: 'approved', movedAt: nowIso() }; : { column: 'approved', movedAt: nowIso() };
writeKanbanState(paths, teamName, state); writeKanbanState(paths, teamName, state);
taskStore.updateTask(paths, String(taskId), (task) => ({
...task,
reviewState: column,
}));
return state; return state;
} }
@ -87,6 +92,10 @@ function clearKanban(paths, teamName, taskId) {
const state = readKanbanState(paths, teamName); const state = readKanbanState(paths, teamName);
delete state.tasks[String(taskId)]; delete state.tasks[String(taskId)];
writeKanbanState(paths, teamName, state); writeKanbanState(paths, teamName, state);
taskStore.updateTask(paths, String(taskId), (task) => ({
...task,
reviewState: 'none',
}));
return state; return state;
} }

View file

@ -0,0 +1,144 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
function nowIso() {
return new Date().toISOString();
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function readJson(filePath, fallbackValue) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch {
return fallbackValue;
}
}
function writeJson(filePath, value) {
ensureDir(path.dirname(filePath));
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2));
fs.renameSync(tempPath, filePath);
}
function getInboxPath(paths, memberName) {
return path.join(paths.teamDir, 'inboxes', `${String(memberName).trim()}.json`);
}
function getSentMessagesPath(paths) {
return path.join(paths.teamDir, 'sentMessages.json');
}
function normalizeAttachments(attachments) {
if (!Array.isArray(attachments) || attachments.length === 0) {
return undefined;
}
const normalized = attachments
.filter((item) => item && typeof item === 'object')
.map((item) => ({
id: String(item.id || '').trim(),
filename: String(item.filename || '').trim(),
mimeType: String(item.mimeType || '').trim(),
size: Number(item.size || 0),
}))
.filter((item) => item.id && item.filename && item.mimeType && Number.isFinite(item.size));
return normalized.length > 0 ? normalized : undefined;
}
function buildMessage(flags, defaults) {
const timestamp =
typeof flags.timestamp === 'string' && flags.timestamp.trim() ? flags.timestamp.trim() : nowIso();
const messageId =
typeof flags.messageId === 'string' && flags.messageId.trim()
? flags.messageId.trim()
: crypto.randomUUID();
const attachments = normalizeAttachments(flags.attachments);
return {
from:
typeof flags.from === 'string' && flags.from.trim()
? flags.from.trim()
: defaults.from || 'user',
...(defaults.to ? { to: defaults.to } : {}),
text: String(flags.text || ''),
timestamp,
read: defaults.read,
...(typeof flags.summary === 'string' && flags.summary.trim()
? { summary: flags.summary.trim() }
: {}),
...(typeof flags.source === 'string' && flags.source.trim() ? { source: flags.source.trim() } : {}),
...(typeof flags.leadSessionId === 'string' && flags.leadSessionId.trim()
? { leadSessionId: flags.leadSessionId.trim() }
: {}),
...(typeof flags.color === 'string' && flags.color.trim() ? { color: flags.color.trim() } : {}),
...(typeof flags.toolSummary === 'string' && flags.toolSummary.trim()
? { toolSummary: flags.toolSummary.trim() }
: {}),
...(Array.isArray(flags.toolCalls) && flags.toolCalls.length > 0
? {
toolCalls: flags.toolCalls
.filter((item) => item && typeof item === 'object' && typeof item.name === 'string')
.map((item) => ({
name: item.name,
...(typeof item.preview === 'string' ? { preview: item.preview } : {}),
})),
}
: {}),
...(attachments ? { attachments } : {}),
messageId,
};
}
function appendRow(filePath, row) {
const current = readJson(filePath, []);
const list = Array.isArray(current) ? current : [];
list.push(row);
writeJson(filePath, list);
return row;
}
function sendInboxMessage(paths, flags) {
const memberName =
typeof flags.member === 'string' && flags.member.trim()
? flags.member.trim()
: typeof flags.to === 'string' && flags.to.trim()
? flags.to.trim()
: '';
if (!memberName) {
throw new Error('Missing recipient');
}
const payload = buildMessage(flags, {
from: 'user',
to: memberName,
read: false,
});
appendRow(getInboxPath(paths, memberName), payload);
return {
deliveredToInbox: true,
messageId: payload.messageId,
message: payload,
};
}
function appendSentMessage(paths, flags) {
const payload = buildMessage(flags, {
from: 'team-lead',
to: typeof flags.to === 'string' && flags.to.trim() ? flags.to.trim() : undefined,
read: true,
});
appendRow(getSentMessagesPath(paths), payload);
return payload;
}
module.exports = {
appendSentMessage,
sendInboxMessage,
};

View file

@ -1,9 +1,14 @@
const legacy = require('../legacy/teamctl.cli.js'); const messageStore = require('./messageStore.js');
function sendMessage(context, flags) { function sendMessage(context, flags) {
return legacy.sendInboxMessage(context.paths, context.teamName, flags); return messageStore.sendInboxMessage(context.paths, flags);
}
function appendSentMessage(context, flags) {
return messageStore.appendSentMessage(context.paths, flags);
} }
module.exports = { module.exports = {
appendSentMessage,
sendMessage, sendMessage,
}; };

View file

@ -79,10 +79,19 @@ function registerProcess(paths, flags) {
const list = readProcesses(paths); const list = readProcesses(paths);
const existingActiveIndex = list.findIndex((entry) => entry.pid === pid && !entry.stoppedAt); const existingActiveIndex = list.findIndex((entry) => entry.pid === pid && !entry.stoppedAt);
const existingActive = existingActiveIndex >= 0 ? list[existingActiveIndex] : null; const existingActive =
existingActiveIndex >= 0
? {
...list[existingActiveIndex],
...(legacy.isProcessAlive(pid) ? {} : { stoppedAt: nowIso() }),
}
: null;
if (existingActiveIndex >= 0 && existingActive && existingActive.stoppedAt) {
list[existingActiveIndex] = existingActive;
}
const now = nowIso(); const now = nowIso();
const entry = { const entry = {
id: existingActive ? existingActive.id : crypto.randomUUID(), id: existingActive && !existingActive.stoppedAt ? existingActive.id : crypto.randomUUID(),
label, label,
pid, pid,
...(flags.port != null ? { port: Number(flags.port) } : {}), ...(flags.port != null ? { port: Number(flags.port) } : {}),
@ -94,10 +103,10 @@ function registerProcess(paths, flags) {
...(typeof flags.command === 'string' && flags.command.trim() ...(typeof flags.command === 'string' && flags.command.trim()
? { command: flags.command.trim() } ? { command: flags.command.trim() }
: {}), : {}),
registeredAt: existingActive ? existingActive.registeredAt : now, registeredAt: existingActive && !existingActive.stoppedAt ? existingActive.registeredAt : now,
}; };
if (existingActiveIndex >= 0) { if (existingActiveIndex >= 0 && existingActive && !existingActive.stoppedAt) {
list[existingActiveIndex] = entry; list[existingActiveIndex] = entry;
} else { } else {
list.push(entry); list.push(entry);

View file

@ -2,6 +2,57 @@ const kanban = require('./kanban.js');
const messages = require('./messages.js'); const messages = require('./messages.js');
const tasks = require('./tasks.js'); const tasks = require('./tasks.js');
function getReviewer(context, flags) {
if (typeof flags.reviewer === 'string' && flags.reviewer.trim()) {
return flags.reviewer.trim();
}
const state = kanban.getKanbanState(context);
return typeof state.reviewers[0] === 'string' && state.reviewers[0].trim()
? state.reviewers[0].trim()
: null;
}
function requestReview(context, taskId, flags = {}) {
const task = tasks.getTask(context, taskId);
if (task.status !== 'completed') {
throw new Error(`Task #${task.displayId || task.id} must be completed before review`);
}
const from =
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
const reviewer = getReviewer(context, flags);
try {
kanban.setKanbanColumn(context, task.id, 'review');
if (!reviewer) {
return tasks.getTask(context, task.id);
}
messages.sendMessage(context, {
to: reviewer,
from,
text:
`Please review task #${task.displayId || task.id}.\n\n` +
'<agent-block>\n' +
`When approved, use MCP tool review_approve:\n` +
`{ teamName: "${context.teamName}", taskId: "${task.id}", notifyOwner: true }\n\n` +
`If changes are needed, use MCP tool review_request_changes:\n` +
`{ teamName: "${context.teamName}", taskId: "${task.id}", comment: "..." }\n` +
'</agent-block>',
summary: `Review request for #${task.displayId || task.id}`,
source: 'system_notification',
});
return tasks.getTask(context, task.id);
} catch (error) {
try {
kanban.clearKanban(context, task.id);
} catch {
// Best-effort rollback: keep the original error.
}
throw error;
}
}
function approveReview(context, taskId, flags = {}) { function approveReview(context, taskId, flags = {}) {
const task = tasks.getTask(context, taskId); const task = tasks.getTask(context, taskId);
const from = const from =
@ -66,5 +117,6 @@ function requestChanges(context, taskId, flags = {}) {
module.exports = { module.exports = {
approveReview, approveReview,
requestReview,
requestChanges, requestChanges,
}; };

View file

@ -3,6 +3,7 @@ const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const TASK_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']); const TASK_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']);
const REVIEW_STATES = new Set(['none', 'review', 'approved']);
const UUID_TASK_ID_PATTERN = const UUID_TASK_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@ -61,15 +62,43 @@ function normalizeTask(rawTask, filePath) {
typeof rawTask.displayId === 'string' && rawTask.displayId.trim() typeof rawTask.displayId === 'string' && rawTask.displayId.trim()
? rawTask.displayId.trim() ? rawTask.displayId.trim()
: deriveDisplayId(id), : deriveDisplayId(id),
reviewState: normalizeTaskReviewState(rawTask.reviewState),
}; };
return task; return task;
} }
function normalizeTaskReviewState(value) {
return REVIEW_STATES.has(String(value || '').trim()) ? String(value).trim() : 'none';
}
function getOverlayReviewState(overlayTasks, taskId) {
if (!overlayTasks || typeof overlayTasks !== 'object') {
return 'none';
}
const entry = overlayTasks[String(taskId)];
if (!entry || typeof entry !== 'object') {
return 'none';
}
return entry.column === 'review' || entry.column === 'approved' ? entry.column : 'none';
}
function withCompatibleReviewState(task, overlayTasks) {
const explicit = normalizeTaskReviewState(task.reviewState);
return explicit === 'none' ? { ...task, reviewState: getOverlayReviewState(overlayTasks, task.id) } : task;
}
function listRawTasks(paths) { function listRawTasks(paths) {
ensureDir(paths.tasksDir); ensureDir(paths.tasksDir);
const entries = fs.readdirSync(paths.tasksDir); const entries = fs.readdirSync(paths.tasksDir);
const out = []; const out = [];
const overlayState = readJson(paths.kanbanPath, null);
const overlayTasks =
overlayState && typeof overlayState === 'object' && overlayState.tasks && typeof overlayState.tasks === 'object'
? overlayState.tasks
: null;
for (const fileName of entries) { for (const fileName of entries) {
if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue; if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue;
@ -78,7 +107,7 @@ function listRawTasks(paths) {
if (!rawTask) continue; if (!rawTask) continue;
if (rawTask.metadata && rawTask.metadata._internal === true) continue; if (rawTask.metadata && rawTask.metadata._internal === true) continue;
try { try {
out.push(normalizeTask(rawTask, filePath)); out.push(withCompatibleReviewState(normalizeTask(rawTask, filePath), overlayTasks));
} catch { } catch {
// Skip unreadable task rows. // Skip unreadable task rows.
} }
@ -136,7 +165,12 @@ function readTask(paths, taskRef, options = {}) {
if (!rawTask) { if (!rawTask) {
throw new Error(`Task not found: ${String(taskRef)}`); throw new Error(`Task not found: ${String(taskRef)}`);
} }
return normalizeTask(rawTask, taskPath); const overlayState = readJson(paths.kanbanPath, null);
const overlayTasks =
overlayState && typeof overlayState === 'object' && overlayState.tasks && typeof overlayState.tasks === 'object'
? overlayState.tasks
: null;
return withCompatibleReviewState(normalizeTask(rawTask, taskPath), overlayTasks);
} }
function createStatusTransition(history, from, to, actor, timestamp) { function createStatusTransition(history, from, to, actor, timestamp) {
@ -291,6 +325,7 @@ function createTask(paths, input = {}) {
input.needsClarification === 'lead' || input.needsClarification === 'user' input.needsClarification === 'lead' || input.needsClarification === 'user'
? input.needsClarification ? input.needsClarification
: undefined, : undefined,
reviewState: normalizeTaskReviewState(input.reviewState),
deletedAt: deletedAt:
status === 'deleted' && typeof input.deletedAt === 'string' ? input.deletedAt : undefined, status === 'deleted' && typeof input.deletedAt === 'string' ? input.deletedAt : undefined,
attachments: Array.isArray(input.attachments) ? input.attachments : undefined, attachments: Array.isArray(input.attachments) ? input.attachments : undefined,
@ -591,8 +626,14 @@ function formatTaskBriefing(paths, teamName, memberName) {
for (const task of activeTasks) { for (const task of activeTasks) {
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined; const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
const reviewState = kanbanEntry && kanbanEntry.column ? `, review=${kanbanEntry.column}` : ''; const reviewState = kanbanEntry && kanbanEntry.column ? `, review=${kanbanEntry.column}` : '';
const effectiveReviewState =
normalizeTaskReviewState(task.reviewState) !== 'none'
? normalizeTaskReviewState(task.reviewState)
: reviewState
? String(kanbanEntry.column)
: 'none';
lines.push( lines.push(
`${buildTaskReference(task)} [status=${task.status}${reviewState}] ${task.subject}` `${buildTaskReference(task)} [status=${task.status}${effectiveReviewState !== 'none' ? `, review=${effectiveReviewState}` : ''}] ${task.subject}`
); );
if (task.description) lines.push(` Description: ${task.description}`); if (task.description) lines.push(` Description: ${task.description}`);
if (task.blockedBy && task.blockedBy.length > 0) { if (task.blockedBy && task.blockedBy.length > 0) {

View file

@ -44,16 +44,29 @@ describe('agent-teams-controller API', () => {
); );
expect(created.displayId).toHaveLength(8); expect(created.displayId).toHaveLength(8);
expect(created.status).toBe('pending'); expect(created.status).toBe('pending');
expect(created.reviewState).toBe('none');
expect(controller.tasks.getTask(base.id).blocks).toEqual([created.id]); expect(controller.tasks.getTask(base.id).blocks).toEqual([created.id]);
expect(controller.tasks.getTask(created.displayId).blockedBy).toEqual([base.id, dependency.id]); expect(controller.tasks.getTask(created.displayId).blockedBy).toEqual([base.id, dependency.id]);
controller.kanban.addReviewer('alice'); controller.kanban.addReviewer('alice');
controller.kanban.setKanbanColumn(created.id, 'review'); controller.tasks.completeTask(created.id, 'bob');
controller.review.requestReview(created.id, { from: 'alice' });
controller.review.approveReview(created.id, { 'notify-owner': true, from: 'alice' }); controller.review.approveReview(created.id, { 'notify-owner': true, from: 'alice' });
const kanbanState = controller.kanban.getKanbanState(); const kanbanState = controller.kanban.getKanbanState();
expect(kanbanState.reviewers).toEqual(['alice']); expect(kanbanState.reviewers).toEqual(['alice']);
expect(kanbanState.tasks[created.id].column).toBe('approved'); 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 proc = controller.processes.registerProcess({ const proc = controller.processes.registerProcess({
pid: process.pid, pid: process.pid,
@ -65,4 +78,37 @@ describe('agent-teams-controller API', () => {
const stopped = controller.processes.stopProcess({ pid: process.pid }); const stopped = controller.processes.stopProcess({ pid: process.pid });
expect(typeof stopped.stoppedAt).toBe('string'); expect(typeof stopped.stoppedAt).toBe('string');
}); });
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);
});
}); });

View file

@ -39,11 +39,13 @@ declare module 'agent-teams-controller' {
} }
export interface ControllerReviewApi { export interface ControllerReviewApi {
requestReview(taskId: string, flags?: Record<string, unknown>): unknown;
approveReview(taskId: string, flags?: Record<string, unknown>): unknown; approveReview(taskId: string, flags?: Record<string, unknown>): unknown;
requestChanges(taskId: string, flags?: Record<string, unknown>): unknown; requestChanges(taskId: string, flags?: Record<string, unknown>): unknown;
} }
export interface ControllerMessageApi { export interface ControllerMessageApi {
appendSentMessage(flags: Record<string, unknown>): unknown;
sendMessage(flags: Record<string, unknown>): unknown; sendMessage(flags: Record<string, unknown>): unknown;
} }

View file

@ -19,14 +19,39 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
text: z.string().min(1), text: z.string().min(1),
from: z.string().optional(), from: z.string().optional(),
summary: z.string().optional(), summary: z.string().optional(),
source: z.string().optional(),
leadSessionId: z.string().optional(),
attachments: z
.array(
z.object({
id: z.string().min(1),
filename: z.string().min(1),
mimeType: z.string().min(1),
size: z.number().nonnegative(),
})
)
.optional(),
}), }),
execute: async ({ teamName, claudeDir, to, text, from, summary }) => execute: async ({
teamName,
claudeDir,
to,
text,
from,
summary,
source,
leadSessionId,
attachments,
}) =>
jsonTextContent( jsonTextContent(
getController(teamName, claudeDir).messages.sendMessage({ getController(teamName, claudeDir).messages.sendMessage({
to, to,
text, text,
...(from ? { from } : {}), ...(from ? { from } : {}),
...(summary ? { summary } : {}), ...(summary ? { summary } : {}),
...(source ? { source } : {}),
...(leadSessionId ? { leadSessionId } : {}),
...(attachments?.length ? { attachments } : {}),
}) })
), ),
}); });

View file

@ -10,6 +10,24 @@ const toolContextSchema = {
}; };
export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) { export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({
name: 'review_request',
description: 'Move a completed task into review and notify reviewer',
parameters: z.object({
...toolContextSchema,
taskId: z.string().min(1),
from: z.string().optional(),
reviewer: z.string().optional(),
}),
execute: async ({ teamName, claudeDir, taskId, from, reviewer }) =>
jsonTextContent(
getController(teamName, claudeDir).review.requestReview(taskId, {
...(from ? { from } : {}),
...(reviewer ? { reviewer } : {}),
})
),
});
server.addTool({ server.addTool({
name: 'review_approve', name: 'review_approve',
description: 'Approve task review and move kanban state accordingly', description: 'Approve task review and move kanban state accordingly',

View file

@ -98,6 +98,25 @@ describe('agent-teams-mcp tools', () => {
expect(loadedTask.needsClarification).toBe('user'); expect(loadedTask.needsClarification).toBe('user');
expect(loadedTask.comments).toHaveLength(1); expect(loadedTask.comments).toHaveLength(1);
expect(loadedTask.comments[0].attachments).toHaveLength(1); expect(loadedTask.comments[0].attachments).toHaveLength(1);
await getTool('task_set_status').execute({
claudeDir,
teamName,
taskId: createdTask.id,
status: 'completed',
});
const reviewRequested = parseJsonToolResult(
await getTool('review_request').execute({
claudeDir,
teamName,
taskId: createdTask.id,
from: 'lead',
reviewer: 'alice',
})
);
expect(reviewRequested.reviewState).toBe('review');
}); });
it('covers process register/list/stop without legacy stdout leaking into results', async () => { it('covers process register/list/stop without legacy stdout leaking into results', async () => {
@ -141,4 +160,30 @@ describe('agent-teams-mcp tools', () => {
expect(stopped.pid).toBe(pid); expect(stopped.pid).toBe(pid);
expect(typeof stopped.stoppedAt).toBe('string'); expect(typeof stopped.stoppedAt).toBe('string');
}); });
it('persists full message metadata through message_send', async () => {
const claudeDir = makeClaudeDir();
const teamName = 'gamma';
const sent = parseJsonToolResult(
await getTool('message_send').execute({
claudeDir,
teamName,
to: 'alice',
text: 'Check this',
from: 'lead',
summary: 'Metadata test',
source: 'system_notification',
leadSessionId: 'session-42',
attachments: [{ id: 'att-1', filename: 'note.txt', mimeType: 'text/plain', size: 4 }],
})
);
expect(sent.deliveredToInbox).toBe(true);
const inboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows[0].source).toBe('system_notification');
expect(rows[0].leadSessionId).toBe('session-42');
expect(rows[0].attachments[0].filename).toBe('note.txt');
});
}); });

View file

@ -430,6 +430,14 @@ function wireFileWatcherEvents(context: ServiceContext): void {
// --- Inbox change events: relay to lead + native OS notifications --- // --- Inbox change events: relay to lead + native OS notifications ---
if (row.type === 'inbox') { if (row.type === 'inbox') {
if (teamDataService) {
void teamDataService
.reconcileTeamArtifacts(teamName)
.catch((e: unknown) =>
logger.warn(`[FileWatcher] reconcile failed for ${teamName}: ${String(e)}`)
);
}
// Auto-relay ONLY lead-inbox changes into the live lead process. // Auto-relay ONLY lead-inbox changes into the live lead process.
// (Relaying on *any* inbox change causes the lead to process irrelevant status noise.) // (Relaying on *any* inbox change causes the lead to process irrelevant status noise.)
if (teamProvisioningService.isTeamAlive(teamName) && detail.startsWith('inboxes/')) { if (teamProvisioningService.isTeamAlive(teamName) && detail.startsWith('inboxes/')) {
@ -479,6 +487,12 @@ function wireFileWatcherEvents(context: ServiceContext): void {
// --- Task change events: notify lead when teammate starts a task via CLI --- // --- Task change events: notify lead when teammate starts a task via CLI ---
if (row.type === 'task' && detail.endsWith('.json') && teamDataService) { if (row.type === 'task' && detail.endsWith('.json') && teamDataService) {
void teamDataService
.reconcileTeamArtifacts(teamName)
.catch((e: unknown) =>
logger.warn(`[FileWatcher] task reconcile failed for ${teamName}: ${String(e)}`)
);
const taskId = detail.replace('.json', ''); const taskId = detail.replace('.json', '');
void teamDataService void teamDataService
.notifyLeadOnTeammateTaskStart(teamName, taskId) .notifyLeadOnTeammateTaskStart(teamName, taskId)

View file

@ -1,5 +1,4 @@
import { yieldToEventLoop } from '@main/utils/asyncYield'; import { yieldToEventLoop } from '@main/utils/asyncYield';
import { readFileUtf8WithTimeout } from '@main/utils/fsRead';
import { import {
encodePath, encodePath,
extractBaseDir, extractBaseDir,
@ -8,7 +7,6 @@ import {
getTasksBasePath, getTasksBasePath,
getTeamsBasePath, getTeamsBasePath,
} from '@main/utils/pathDecoder'; } from '@main/utils/pathDecoder';
import { isProcessAlive } from '@main/utils/processHealth';
import { killProcessByPid } from '@main/utils/processKill'; import { killProcessByPid } from '@main/utils/processKill';
import { import {
AGENT_BLOCK_CLOSE, AGENT_BLOCK_CLOSE,
@ -17,6 +15,7 @@ import {
} from '@shared/constants/agentBlocks'; } from '@shared/constants/agentBlocks';
import { getMemberColor } from '@shared/constants/memberColors'; import { getMemberColor } from '@shared/constants/memberColors';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { parseNumericSuffixName } from '@shared/utils/teamMemberName'; import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary'; import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
@ -46,7 +45,6 @@ import type {
InboxMessage, InboxMessage,
KanbanColumnId, KanbanColumnId,
KanbanState, KanbanState,
KanbanTaskState,
ResolvedTeamMember, ResolvedTeamMember,
SendMessageRequest, SendMessageRequest,
SendMessageResult, SendMessageResult,
@ -73,7 +71,6 @@ const logger = createLogger('Service:TeamDataService');
const MIN_TEXT_LENGTH = 30; const MIN_TEXT_LENGTH = 30;
const MAX_LEAD_TEXTS = 150; const MAX_LEAD_TEXTS = 150;
const PROCESS_HEALTH_INTERVAL_MS = 2_000; const PROCESS_HEALTH_INTERVAL_MS = 2_000;
const MAX_PROCESSES_FILE_BYTES = 2 * 1024 * 1024;
const TASK_MAP_YIELD_EVERY = 250; const TASK_MAP_YIELD_EVERY = 250;
export class TeamDataService { export class TeamDataService {
@ -86,7 +83,7 @@ export class TeamDataService {
private readonly configReader: TeamConfigReader = new TeamConfigReader(), private readonly configReader: TeamConfigReader = new TeamConfigReader(),
private readonly taskReader: TeamTaskReader = new TeamTaskReader(), private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(), private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
private readonly inboxWriter: TeamInboxWriter = new TeamInboxWriter(), _inboxWriter: TeamInboxWriter = new TeamInboxWriter(),
private readonly taskWriter: TeamTaskWriter = new TeamTaskWriter(), private readonly taskWriter: TeamTaskWriter = new TeamTaskWriter(),
private readonly memberResolver: TeamMemberResolver = new TeamMemberResolver(), private readonly memberResolver: TeamMemberResolver = new TeamMemberResolver(),
private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(), private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(),
@ -108,6 +105,28 @@ export class TeamDataService {
return formatTaskDisplayLabel(task); return formatTaskDisplayLabel(task);
} }
private resolveTaskReviewState(
task: Pick<TeamTask, 'id' | 'reviewState'>,
kanbanState?: Pick<KanbanState, 'tasks'>
): 'none' | 'review' | 'approved' {
const explicit = normalizeReviewState(task.reviewState);
if (explicit !== 'none') {
return explicit;
}
const overlay = kanbanState?.tasks?.[task.id]?.column;
return overlay === 'review' || overlay === 'approved' ? overlay : 'none';
}
private attachKanbanCompatibility(task: TeamTask, kanbanState?: KanbanState): TeamTaskWithKanban {
const reviewState = this.resolveTaskReviewState(task, kanbanState);
return {
...task,
reviewState,
kanbanColumn: getKanbanColumnFromReviewState(reviewState),
};
}
async listTeams(): Promise<TeamSummary[]> { async listTeams(): Promise<TeamSummary[]> {
return this.configReader.listTeams(); return this.configReader.listTeams();
} }
@ -153,11 +172,8 @@ export class TeamDataService {
} }
const info = teamInfoMap.get(task.teamName)!; const info = teamInfoMap.get(task.teamName)!;
const kanban = kanbanByTeam.get(task.teamName); const kanban = kanbanByTeam.get(task.teamName);
const kanbanEntry = kanban?.tasks[task.id]; const reviewState = this.resolveTaskReviewState(task, kanban);
const kanbanColumn = const kanbanColumn = getKanbanColumnFromReviewState(reviewState);
kanbanEntry?.column === 'review' || kanbanEntry?.column === 'approved'
? kanbanEntry.column
: undefined;
// IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields). // IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields).
// Return a "light" task object and defer heavy details to team/task detail views. // Return a "light" task object and defer heavy details to team/task detail views.
@ -176,6 +192,7 @@ export class TeamDataService {
projectPath, projectPath,
needsClarification: task.needsClarification, needsClarification: task.needsClarification,
deletedAt: task.deletedAt, deletedAt: task.deletedAt,
reviewState,
// Intentionally omit description/comments/activeForm/workIntervals/links to keep payload small // Intentionally omit description/comments/activeForm/workIntervals/links to keep payload small
kanbanColumn, kanbanColumn,
teamName: task.teamName, teamName: task.teamName,
@ -258,12 +275,10 @@ export class TeamDataService {
const warnings: string[] = []; const warnings: string[] = [];
let tasks: TeamTask[] = []; let tasks: TeamTask[] = [];
let tasksLoaded = true;
try { try {
tasks = await this.taskReader.getTasks(teamName); tasks = await this.taskReader.getTasks(teamName);
} catch { } catch {
warnings.push('Tasks failed to load'); warnings.push('Tasks failed to load');
tasksLoaded = false;
} }
mark('tasks'); mark('tasks');
@ -407,21 +422,11 @@ export class TeamDataService {
} }
mark('kanbanState'); mark('kanbanState');
if (canRunKanbanGc && tasksLoaded) {
try {
await this.kanbanManager.garbageCollect(teamName, new Set(tasks.map((task) => task.id)));
kanbanState = await this.kanbanManager.getState(teamName);
} catch {
warnings.push('Kanban state cleanup failed');
}
}
mark('kanbanGc'); mark('kanbanGc');
const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) => { const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) =>
const col = kanbanState.tasks[task.id]?.column; this.attachKanbanCompatibility(task, canRunKanbanGc ? kanbanState : undefined)
const kanbanColumn = col === 'review' || col === 'approved' ? col : undefined; );
return { ...task, kanbanColumn };
});
const members = this.memberResolver.resolveMembers( const members = this.memberResolver.resolveMembers(
config, config,
@ -436,25 +441,11 @@ export class TeamDataService {
await this.enrichMemberBranches(members, config); await this.enrichMemberBranches(members, config);
mark('enrichBranches'); mark('enrichBranches');
// Auto-sync: create comments from task-related inbox messages
if (tasksLoaded && messages.length > 0) {
try {
const didSync = await this.syncLinkedComments(teamName, tasks, messages);
if (didSync) {
// Re-read tasks only if new comments were actually written
tasks = await this.taskReader.getTasks(teamName);
}
} catch {
warnings.push('Comment sync from messages failed');
}
}
mark('syncComments'); mark('syncComments');
const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) => { const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) =>
const col = kanbanState.tasks[task.id]?.column; this.attachKanbanCompatibility(task, canRunKanbanGc ? kanbanState : undefined)
const kanbanColumn = col === 'review' || col === 'approved' ? col : undefined; );
return { ...task, kanbanColumn };
});
let processes: TeamProcess[] = []; let processes: TeamProcess[] = [];
try { try {
@ -1090,7 +1081,15 @@ export class TeamDataService {
// non-critical // non-critical
} }
} }
return this.inboxWriter.sendMessage(teamName, enrichedRequest); return this.getController(teamName).messages.sendMessage({
member: enrichedRequest.member,
from: enrichedRequest.from,
text: enrichedRequest.text,
summary: enrichedRequest.summary,
source: enrichedRequest.source,
leadSessionId: enrichedRequest.leadSessionId,
attachments: enrichedRequest.attachments,
}) as SendMessageResult;
} }
private resolveLeadNameFromConfig(config: TeamConfig | null): string { private resolveLeadNameFromConfig(config: TeamConfig | null): string {
@ -1121,8 +1120,6 @@ export class TeamDataService {
summary?: string, summary?: string,
attachments?: AttachmentMeta[] attachments?: AttachmentMeta[]
): Promise<SendMessageResult> { ): Promise<SendMessageResult> {
const messageId = randomUUID();
let leadSessionId: string | undefined; let leadSessionId: string | undefined;
try { try {
const config = await this.configReader.getConfig(teamName); const config = await this.configReader.getConfig(teamName);
@ -1131,20 +1128,20 @@ export class TeamDataService {
// non-critical — proceed without sessionId // non-critical — proceed without sessionId
} }
const msg: InboxMessage = { const msg = this.getController(teamName).messages.appendSentMessage({
from: 'user', from: 'user',
to: leadName, to: leadName,
text, text,
timestamp: new Date().toISOString(),
read: true,
summary, summary,
messageId,
source: 'user_sent', source: 'user_sent',
attachments: attachments?.length ? attachments : undefined, attachments: attachments?.length ? attachments : undefined,
leadSessionId, leadSessionId,
}) as InboxMessage;
return {
deliveredToInbox: false,
deliveredViaStdin: true,
messageId: msg.messageId ?? randomUUID(),
}; };
await this.sentMessagesStore.appendMessage(teamName, msg);
return { deliveredToInbox: false, deliveredViaStdin: true, messageId };
} }
async getLeadMemberName(teamName: string): Promise<string | null> { async getLeadMemberName(teamName: string): Promise<string | null> {
@ -1175,43 +1172,8 @@ export class TeamDataService {
} }
async requestReview(teamName: string, taskId: string): Promise<void> { async requestReview(teamName: string, taskId: string): Promise<void> {
const tasks = await this.taskReader.getTasks(teamName); const leadName = await this.resolveLeadName(teamName);
const task = tasks.find((candidate) => candidate.id === taskId); this.getController(teamName).review.requestReview(taskId, { from: leadName });
if (!task) {
throw new Error(`Task not found: ${taskId}`);
}
if (task.status !== 'completed') {
throw new Error(`Task ${this.getTaskLabel(task)} must be completed before review`);
}
this.getController(teamName).kanban.setKanbanColumn(taskId, 'review');
const state = await this.kanbanManager.getState(teamName);
const reviewer = state.reviewers[0];
if (!reviewer) {
return;
}
try {
const leadName = await this.resolveLeadName(teamName);
await this.sendMessage(teamName, {
member: reviewer,
from: leadName,
text:
`Please review task ${this.getTaskLabel(task)}.\n\n` +
`${AGENT_BLOCK_OPEN}\n` +
`When approved, use MCP tool review_approve:\n` +
`{ teamName: "${teamName}", taskId: "${taskId}", notifyOwner: true }\n\n` +
`If changes are needed, use MCP tool review_request_changes:\n` +
`{ teamName: "${teamName}", taskId: "${taskId}", comment: "..." }\n` +
AGENT_BLOCK_CLOSE,
summary: `Review request for ${this.getTaskLabel(task)}`,
source: 'system_notification',
});
} catch (error) {
this.getController(teamName).kanban.clearKanban(taskId);
throw error;
}
} }
async createTeamConfig(request: TeamCreateConfigRequest): Promise<void> { async createTeamConfig(request: TeamCreateConfigRequest): Promise<void> {
@ -1267,6 +1229,18 @@ export class TeamDataService {
); );
} }
async reconcileTeamArtifacts(teamName: string): Promise<void> {
const tasks = await this.taskReader.getTasks(teamName);
await this.kanbanManager.garbageCollect(teamName, new Set(tasks.map((task) => task.id)));
const messages = await this.inboxReader.getMessages(teamName);
if (messages.length === 0) {
return;
}
await this.syncLinkedComments(teamName, tasks, messages);
}
/** /**
* Scans inbox messages for task-related discussions and auto-creates * Scans inbox messages for task-related discussions and auto-creates
* linked comments on disk. Uses deterministic comment ID for dedup. * linked comments on disk. Uses deterministic comment ID for dedup.
@ -1329,14 +1303,15 @@ export class TeamDataService {
if (existing.some((c) => c.id === commentId)) continue; if (existing.some((c) => c.id === commentId)) continue;
try { try {
await this.taskWriter.addComment(teamName, task.id, msg.text, { this.getController(teamName).tasks.addTaskComment(task.id, {
text: msg.text,
id: commentId, id: commentId,
author: msg.from, from: msg.from,
createdAt: msg.timestamp, createdAt: msg.timestamp,
}); });
synced = true; synced = true;
} catch { } catch {
// Best-effort — don't fail getTeamData() on sync errors // Best-effort — don't fail reconciliation on sync errors
} }
} }
} }
@ -1511,7 +1486,8 @@ export class TeamDataService {
if (patch.op === 'set_column') { if (patch.op === 'set_column') {
if (patch.column === 'review') { if (patch.column === 'review') {
controller.kanban.setKanbanColumn(taskId, 'review'); const leadName = await this.resolveLeadName(teamName);
controller.review.requestReview(taskId, { from: leadName });
} else { } else {
const leadName = await this.resolveLeadName(teamName); const leadName = await this.resolveLeadName(teamName);
controller.review.approveReview(taskId, { controller.review.approveReview(taskId, {

View file

@ -101,6 +101,21 @@ export class TeamInboxReader {
messageId: typeof row.messageId === 'string' ? row.messageId : undefined, messageId: typeof row.messageId === 'string' ? row.messageId : undefined,
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined, source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined, leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
attachments: Array.isArray(row.attachments) ? row.attachments : undefined,
toolSummary: typeof row.toolSummary === 'string' ? row.toolSummary : undefined,
toolCalls: Array.isArray(row.toolCalls)
? (row.toolCalls as unknown[])
.filter(
(tc): tc is { name: string; preview?: string } =>
tc != null &&
typeof tc === 'object' &&
typeof (tc as Record<string, unknown>).name === 'string'
)
.map((tc) => ({
name: tc.name,
preview: typeof tc.preview === 'string' ? tc.preview : undefined,
}))
: undefined,
}); });
} }

View file

@ -24,6 +24,7 @@ import { createLogger } from '@shared/utils/logger';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary'; import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
import * as agentTeamsControllerModule from 'agent-teams-controller';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import * as fs from 'fs'; import * as fs from 'fs';
@ -58,6 +59,7 @@ import type {
} from '@shared/types'; } from '@shared/types';
const logger = createLogger('Service:TeamProvisioning'); const logger = createLogger('Service:TeamProvisioning');
const { createController } = agentTeamsControllerModule;
const RUN_TIMEOUT_MS = 300_000; const RUN_TIMEOUT_MS = 300_000;
const VERIFY_TIMEOUT_MS = 15_000; const VERIFY_TIMEOUT_MS = 15_000;
const VERIFY_POLL_MS = 500; const VERIFY_POLL_MS = 500;
@ -1083,7 +1085,7 @@ export class TeamProvisioningService {
private readonly configReader: TeamConfigReader = new TeamConfigReader(), private readonly configReader: TeamConfigReader = new TeamConfigReader(),
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(), private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(), private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore(), _sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore(),
private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder() private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder()
) {} ) {}
@ -1197,6 +1199,30 @@ export class TeamProvisioningService {
this.teamChangeEmitter = emitter; this.teamChangeEmitter = emitter;
} }
private persistSentMessage(teamName: string, message: InboxMessage): void {
try {
createController({
teamName,
claudeDir: getClaudeBasePath(),
}).messages.appendSentMessage({
from: message.from,
to: message.to,
text: message.text,
timestamp: message.timestamp,
summary: message.summary,
messageId: message.messageId,
source: message.source,
leadSessionId: message.leadSessionId,
attachments: message.attachments,
color: message.color,
toolSummary: message.toolSummary,
toolCalls: message.toolCalls,
});
} catch (error) {
logger.warn(`[${teamName}] sent-message persist failed: ${String(error)}`);
}
}
private toolApprovalEventEmitter: ((event: ToolApprovalEvent) => void) | null = null; private toolApprovalEventEmitter: ((event: ToolApprovalEvent) => void) | null = null;
setToolApprovalEventEmitter(emitter: (event: ToolApprovalEvent) => void): void { setToolApprovalEventEmitter(emitter: (event: ToolApprovalEvent) => void): void {
@ -2611,11 +2637,7 @@ export class TeamProvisioningService {
}; };
this.pushLiveLeadProcessMessage(teamName, relayMsg); this.pushLiveLeadProcessMessage(teamName, relayMsg);
// Persist to disk so relayed replies survive app restart and trigger FileWatcher // Persist to disk so relayed replies survive app restart and trigger FileWatcher
void this.sentMessagesStore this.persistSentMessage(teamName, relayMsg);
.appendMessage(teamName, relayMsg)
.catch((e: unknown) =>
logger.warn(`[${teamName}] sentMessagesStore persist failed: ${String(e)}`)
);
this.teamChangeEmitter?.({ this.teamChangeEmitter?.({
type: 'inbox', type: 'inbox',
teamName, teamName,
@ -2831,13 +2853,7 @@ export class TeamProvisioningService {
}; };
this.pushLiveLeadProcessMessage(run.teamName, msg); this.pushLiveLeadProcessMessage(run.teamName, msg);
void this.sentMessagesStore this.persistSentMessage(run.teamName, msg);
.appendMessage(run.teamName, msg)
.catch((e: unknown) =>
logger.warn(
`[${run.teamName}] sentMessagesStore persist (SendMessage capture) failed: ${String(e)}`
)
);
this.teamChangeEmitter?.({ this.teamChangeEmitter?.({
type: 'inbox', type: 'inbox',
teamName: run.teamName, teamName: run.teamName,

View file

@ -2,6 +2,7 @@ import { yieldToEventLoop } from '@main/utils/asyncYield';
import { readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { readFileUtf8WithTimeout } from '@main/utils/fsRead';
import { getTasksBasePath } from '@main/utils/pathDecoder'; import { getTasksBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { normalizeReviewState } from '@shared/utils/reviewState';
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity'; import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
@ -275,6 +276,7 @@ export class TeamTaskReader {
addedAt: a.addedAt, addedAt: a.addedAt,
})) }))
: undefined, : undefined,
reviewState: normalizeReviewState(parsed.reviewState),
} satisfies Record<keyof TeamTask, unknown>; } satisfies Record<keyof TeamTask, unknown>;
if (task.status === 'deleted') { if (task.status === 'deleted') {
continue; continue;
@ -376,6 +378,7 @@ export class TeamTaskReader {
status: 'deleted', status: 'deleted',
deletedAt: typeof parsed.deletedAt === 'string' ? parsed.deletedAt : undefined, deletedAt: typeof parsed.deletedAt === 'string' ? parsed.deletedAt : undefined,
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined, createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined,
reviewState: normalizeReviewState(parsed.reviewState),
}; };
tasks.push(task); tasks.push(task);

View file

@ -117,6 +117,7 @@ interface ParsedTask {
projectPath?: unknown; projectPath?: unknown;
comments?: unknown; comments?: unknown;
needsClarification?: unknown; needsClarification?: unknown;
reviewState?: unknown;
metadata?: { _internal?: unknown }; metadata?: { _internal?: unknown };
workIntervals?: unknown; workIntervals?: unknown;
statusHistory?: unknown; statusHistory?: unknown;
@ -600,6 +601,12 @@ async function readTasksDirForTeam(
parsed.needsClarification === 'lead' || parsed.needsClarification === 'user' parsed.needsClarification === 'lead' || parsed.needsClarification === 'user'
? (parsed.needsClarification as string) ? (parsed.needsClarification as string)
: undefined; : undefined;
const reviewState =
parsed.reviewState === 'review' ||
parsed.reviewState === 'approved' ||
parsed.reviewState === 'none'
? parsed.reviewState
: 'none';
tasks.push({ tasks.push({
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '', id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
@ -635,6 +642,7 @@ async function readTasksDirForTeam(
projectPath: typeof parsed.projectPath === 'string' ? parsed.projectPath : undefined, projectPath: typeof parsed.projectPath === 'string' ? parsed.projectPath : undefined,
comments: normalizeComments(parsed), comments: normalizeComments(parsed),
needsClarification, needsClarification,
reviewState,
deletedAt: undefined, deletedAt: undefined,
attachments: Array.isArray(parsed.attachments) attachments: Array.isArray(parsed.attachments)
? (parsed.attachments as unknown[]) ? (parsed.attachments as unknown[])

View file

@ -14,10 +14,10 @@ import { NotificationTriggerSettings } from '../NotificationTriggerSettings';
import type { RepositoryDropdownItem, SafeConfig } from '../hooks/useSettingsConfig'; import type { RepositoryDropdownItem, SafeConfig } from '../hooks/useSettingsConfig';
import type { NotificationTrigger } from '@renderer/types/data'; import type { NotificationTrigger } from '@renderer/types/data';
import type { TeamTaskStatus } from '@shared/types'; import type { TeamReviewState, TeamTaskStatus } from '@shared/types';
/** Statuses available for notification filtering — real status values + kanban-only 'approved'. */ /** Notification targets span workflow status plus the explicit review axis. */
type NotifiableStatus = TeamTaskStatus | 'approved'; type NotifiableStatus = TeamTaskStatus | Extract<TeamReviewState, 'approved'>;
// Snooze duration options // Snooze duration options
const SNOOZE_OPTIONS = [ const SNOOZE_OPTIONS = [

View file

@ -9,6 +9,7 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { nameColorSet } from '@renderer/utils/projectColor'; import { nameColorSet } from '@renderer/utils/projectColor';
import { projectColor } from '@renderer/utils/projectColor'; import { projectColor } from '@renderer/utils/projectColor';
import { projectLabelFromPath } from '@renderer/utils/taskGrouping'; import { projectLabelFromPath } from '@renderer/utils/taskGrouping';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { format, isThisYear, isToday, isYesterday } from 'date-fns'; import { format, isThisYear, isToday, isYesterday } from 'date-fns';
import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react'; import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
@ -103,10 +104,11 @@ export const SidebarTaskItem = ({
} }
}, [isRenaming, displaySubject]); }, [isRenaming, displaySubject]);
const reviewColumn = getTaskKanbanColumn(task);
const cfg = const cfg =
task.kanbanColumn === 'approved' reviewColumn === 'approved'
? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const) ? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
: task.kanbanColumn === 'review' : reviewColumn === 'review'
? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const) ? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const)
: (statusConfig[task.status] ?? statusConfig.pending); : (statusConfig[task.status] ?? statusConfig.pending);
const StatusIcon = cfg.icon; const StatusIcon = cfg.icon;

View file

@ -1,6 +1,7 @@
import { useSyncExternalStore } from 'react'; import { useSyncExternalStore } from 'react';
import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/commentReadStorage'; import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/commentReadStorage';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
export type TaskStatusFilterId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved'; export type TaskStatusFilterId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved';
@ -25,17 +26,22 @@ export const defaultTaskFiltersState = (): TaskFiltersState => ({
}); });
export function taskMatchesStatus( export function taskMatchesStatus(
task: { status: string; kanbanColumn?: 'review' | 'approved' }, task: {
status: string;
reviewState?: 'none' | 'review' | 'approved';
kanbanColumn?: 'review' | 'approved';
},
statusIds: Set<TaskStatusFilterId> statusIds: Set<TaskStatusFilterId>
): boolean { ): boolean {
if (statusIds.size === 0) return false; if (statusIds.size === 0) return false;
if (statusIds.size === STATUS_OPTIONS.length) return task.status !== 'deleted'; if (statusIds.size === STATUS_OPTIONS.length) return task.status !== 'deleted';
const inTodo = task.status === 'pending' && !task.kanbanColumn; const kanbanColumn = getTaskKanbanColumn(task);
const inProgress = task.status === 'in_progress' && !task.kanbanColumn; const inTodo = task.status === 'pending' && !kanbanColumn;
const inDone = task.status === 'completed' && !task.kanbanColumn; const inProgress = task.status === 'in_progress' && !kanbanColumn;
const inReview = task.kanbanColumn === 'review'; const inDone = task.status === 'completed' && !kanbanColumn;
const inApproved = task.kanbanColumn === 'approved'; const inReview = kanbanColumn === 'review';
const inApproved = kanbanColumn === 'approved';
return ( return (
(statusIds.has('todo') && inTodo) || (statusIds.has('todo') && inTodo) ||

View file

@ -5,6 +5,7 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity';
import type { TeamTaskWithKanban } from '@shared/types'; import type { TeamTaskWithKanban } from '@shared/types';
@ -25,7 +26,8 @@ const STATUS_COLORS: Record<string, { text: string; bg: string }> = {
}; };
function getEffectiveColumn(task: TeamTaskWithKanban): string { function getEffectiveColumn(task: TeamTaskWithKanban): string {
if (task.kanbanColumn) return task.kanbanColumn; const reviewColumn = getTaskKanbanColumn(task);
if (reviewColumn) return reviewColumn;
if (task.status === 'pending') return 'todo'; if (task.status === 'pending') return 'todo';
if (task.status === 'completed') return 'done'; if (task.status === 'completed') return 'done';
return task.status; return task.status;

View file

@ -28,8 +28,9 @@ import { useStore } from '@renderer/store';
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { AlertTriangle, Search } from 'lucide-react'; import { AlertTriangle, Search } from 'lucide-react';
import type { InlineChip } from '@renderer/types/inlineChip'; import type { InlineChip } from '@renderer/types/inlineChip';
@ -146,7 +147,7 @@ export const CreateTaskDialog = ({
// Only show non-internal, non-deleted tasks as candidates for blocking // Only show non-internal, non-deleted tasks as candidates for blocking
const availableTasks = tasks.filter( const availableTasks = tasks.filter(
(t) => t.status !== 'deleted' && t.kanbanColumn !== 'approved' (t) => t.status !== 'deleted' && getTaskKanbanColumn(t) !== 'approved'
); );
const toggleBlockedBy = (taskId: string): void => { const toggleBlockedBy = (taskId: string): void => {

View file

@ -22,7 +22,6 @@ import { Textarea } from '@renderer/components/ui/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { markAsRead } from '@renderer/services/commentReadStorage'; import { markAsRead } from '@renderer/services/commentReadStorage';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { isImageMimeType } from '@renderer/utils/attachmentUtils'; import { isImageMimeType } from '@renderer/utils/attachmentUtils';
import { import {
buildMemberColorMap, buildMemberColorMap,
@ -30,6 +29,8 @@ import {
TASK_STATUS_LABELS, TASK_STATUS_LABELS,
TASK_STATUS_STYLES, TASK_STATUS_STYLES,
} from '@renderer/utils/memberHelpers'; } from '@renderer/utils/memberHelpers';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { import {
AlignLeft, AlignLeft,
@ -287,7 +288,12 @@ export const TaskDetailDialog = ({
); );
} }
const kanbanColumn = kanbanTaskState?.column ?? currentTask.kanbanColumn; const kanbanColumn =
kanbanTaskState?.column ??
getTaskKanbanColumn({
reviewState: currentTask.reviewState,
kanbanColumn: currentTask.kanbanColumn,
});
const status = currentTask.status; const status = currentTask.status;
const statusStyle = const statusStyle =
kanbanColumn && KANBAN_COLUMN_DISPLAY[kanbanColumn] kanbanColumn && KANBAN_COLUMN_DISPLAY[kanbanColumn]

View file

@ -8,6 +8,7 @@ import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useResizableColumns } from '@renderer/hooks/useResizableColumns'; import { useResizableColumns } from '@renderer/hooks/useResizableColumns';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { import {
CheckCircle2, CheckCircle2,
ClipboardList, ClipboardList,
@ -105,9 +106,12 @@ const COLUMNS: { id: KanbanColumnId; title: string }[] = [
]; ];
function getTaskColumn(task: TeamTask, kanbanState: KanbanState): KanbanColumnId | null { function getTaskColumn(task: TeamTask, kanbanState: KanbanState): KanbanColumnId | null {
const explicit = kanbanState.tasks[task.id]; const explicit = getTaskKanbanColumn({
if (explicit?.column) { reviewState: task.reviewState,
return explicit.column; kanbanColumn: kanbanState.tasks[task.id]?.column,
});
if (explicit) {
return explicit;
} }
if (task.status === 'pending') { if (task.status === 'pending') {

View file

@ -6,6 +6,7 @@ import {
TASK_STATUS_LABELS, TASK_STATUS_LABELS,
TASK_STATUS_STYLES, TASK_STATUS_STYLES,
} from '@renderer/utils/memberHelpers'; } from '@renderer/utils/memberHelpers';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import type { TeamTaskWithKanban } from '@shared/types'; import type { TeamTaskWithKanban } from '@shared/types';
@ -42,7 +43,7 @@ export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): Rea
<div className="max-h-[320px] overflow-y-auto"> <div className="max-h-[320px] overflow-y-auto">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{visibleTasks.map((task) => { {visibleTasks.map((task) => {
const col = task.kanbanColumn; const col = getTaskKanbanColumn(task);
const style = const style =
col && KANBAN_COLUMN_DISPLAY[col] col && KANBAN_COLUMN_DISPLAY[col]
? { bg: KANBAN_COLUMN_DISPLAY[col].bg, text: KANBAN_COLUMN_DISPLAY[col].text } ? { bg: KANBAN_COLUMN_DISPLAY[col].bg, text: KANBAN_COLUMN_DISPLAY[col].text }

View file

@ -1,4 +1,5 @@
import { KANBAN_COLUMN_DISPLAY, TASK_STATUS_LABELS } from '@renderer/utils/memberHelpers'; import { KANBAN_COLUMN_DISPLAY, TASK_STATUS_LABELS } from '@renderer/utils/memberHelpers';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import type { TeamTaskWithKanban } from '@shared/types'; import type { TeamTaskWithKanban } from '@shared/types';
@ -10,6 +11,7 @@ interface TaskRowProps {
export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => { export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? []; const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? []; const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
const kanbanColumn = getTaskKanbanColumn(task);
return ( return (
<tr className="border-t border-[var(--color-border)]"> <tr className="border-t border-[var(--color-border)]">
@ -21,8 +23,8 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
{task.owner ?? 'Unassigned'} {task.owner ?? 'Unassigned'}
</td> </td>
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]"> <td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
{task.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY {kanbanColumn && kanbanColumn in KANBAN_COLUMN_DISPLAY
? KANBAN_COLUMN_DISPLAY[task.kanbanColumn].label ? KANBAN_COLUMN_DISPLAY[kanbanColumn].label
: (TASK_STATUS_LABELS[task.status] ?? task.status)} : (TASK_STATUS_LABELS[task.status] ?? task.status)}
</td> </td>
<td className="px-3 py-2 text-xs"> <td className="px-3 py-2 text-xs">

View file

@ -2,6 +2,7 @@ import { api } from '@renderer/api';
import { normalizePath } from '@renderer/utils/pathNormalize'; import { normalizePath } from '@renderer/utils/pathNormalize';
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
@ -155,7 +156,9 @@ function detectStatusChangeNotifications(
if (!oldTask) continue; if (!oldTask) continue;
// Detect kanbanColumn change to 'approved' (status stays 'completed', column changes) // Detect kanbanColumn change to 'approved' (status stays 'completed', column changes)
const becameApproved = task.kanbanColumn === 'approved' && oldTask.kanbanColumn !== 'approved'; const taskKanbanColumn = getTaskKanbanColumn(task);
const oldTaskKanbanColumn = getTaskKanbanColumn(oldTask);
const becameApproved = taskKanbanColumn === 'approved' && oldTaskKanbanColumn !== 'approved';
const statusChanged = oldTask.status !== task.status; const statusChanged = oldTask.status !== task.status;
if (!statusChanged && !becameApproved) continue; if (!statusChanged && !becameApproved) continue;
@ -511,7 +514,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`); notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`);
} }
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`); notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
if (task.kanbanColumn === 'approved') { if (getTaskKanbanColumn(task) === 'approved') {
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`); notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
} }
} }

View file

@ -56,6 +56,7 @@ export interface TeamSummary {
} }
export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted'; export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted';
export type TeamReviewState = 'none' | 'review' | 'approved';
export interface TaskWorkInterval { export interface TaskWorkInterval {
/** ISO timestamp when task entered in_progress */ /** ISO timestamp when task entered in_progress */
@ -129,6 +130,8 @@ export interface TeamTask {
deletedAt?: string; deletedAt?: string;
/** Attachments associated with this task. Metadata only — actual files stored on disk. */ /** Attachments associated with this task. Metadata only — actual files stored on disk. */
attachments?: TaskAttachmentMeta[]; attachments?: TaskAttachmentMeta[];
/** Separate review lifecycle axis. Persisted on modern tasks, derived for legacy rows when needed. */
reviewState?: TeamReviewState;
} }
/** Task enriched for UI/DTO use (overlay from kanban-state.json). */ /** Task enriched for UI/DTO use (overlay from kanban-state.json). */

View file

@ -0,0 +1,42 @@
import type { TeamReviewState } from '@shared/types';
interface ReviewStateLike {
reviewState?: TeamReviewState | null;
kanbanColumn?: 'review' | 'approved' | null;
status?: string | null;
}
export function normalizeReviewState(value: unknown): TeamReviewState {
return value === 'review' || value === 'approved' ? value : 'none';
}
export function getReviewStateFromTask(task: ReviewStateLike): TeamReviewState {
const explicit = normalizeReviewState(task.reviewState);
if (explicit !== 'none') {
return explicit;
}
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
return task.kanbanColumn;
}
return 'none';
}
export function getKanbanColumnFromReviewState(
reviewState: TeamReviewState
): 'review' | 'approved' | undefined {
return reviewState === 'review' || reviewState === 'approved' ? reviewState : undefined;
}
export function getTaskKanbanColumn(task: ReviewStateLike): 'review' | 'approved' | undefined {
return getKanbanColumnFromReviewState(getReviewStateFromTask(task));
}
export function isApprovedTask(task: ReviewStateLike): boolean {
return getReviewStateFromTask(task) === 'approved';
}
export function isReviewTask(task: ReviewStateLike): boolean {
return getReviewStateFromTask(task) === 'review';
}

View file

@ -39,11 +39,13 @@ declare module 'agent-teams-controller' {
} }
export interface ControllerReviewApi { export interface ControllerReviewApi {
requestReview(taskId: string, flags?: Record<string, unknown>): unknown;
approveReview(taskId: string, flags?: Record<string, unknown>): unknown; approveReview(taskId: string, flags?: Record<string, unknown>): unknown;
requestChanges(taskId: string, flags?: Record<string, unknown>): unknown; requestChanges(taskId: string, flags?: Record<string, unknown>): unknown;
} }
export interface ControllerMessageApi { export interface ControllerMessageApi {
appendSentMessage(flags: Record<string, unknown>): unknown;
sendMessage(flags: Record<string, unknown>): unknown; sendMessage(flags: Record<string, unknown>): unknown;
} }

View file

@ -5,7 +5,7 @@ import { TeamDataService } from '../../../../src/main/services/team/TeamDataServ
import type { InboxMessage, TeamTask } from '../../../../src/shared/types/team'; import type { InboxMessage, TeamTask } from '../../../../src/shared/types/team';
describe('TeamDataService', () => { describe('TeamDataService', () => {
it('runs kanban garbage-collect only after tasks are loaded', async () => { it('keeps getTeamData read-only and skips kanban garbage-collect', async () => {
const order: string[] = []; const order: string[] = [];
const tasks: TeamTask[] = [ const tasks: TeamTask[] = [
{ {
@ -44,10 +44,10 @@ describe('TeamDataService', () => {
); );
await service.getTeamData('my-team'); await service.getTeamData('my-team');
expect(order).toEqual(['tasks', 'gc']); expect(order).toEqual(['tasks']);
}); });
it('does not sync automated comment notifications into task comments', async () => { it('reconciles linked comments outside getTeamData and skips automated notifications', async () => {
const tasks: TeamTask[] = [ const tasks: TeamTask[] = [
{ {
id: '12', id: '12',
@ -106,14 +106,20 @@ describe('TeamDataService', () => {
} as never, } as never,
{ {
readMessages: vi.fn(async () => []), readMessages: vi.fn(async () => []),
} as never } as never,
() =>
({
tasks: {
addTaskComment: addComment,
},
}) as never
); );
await service.getTeamData('my-team'); await service.reconcileTeamArtifacts('my-team');
expect(addComment).not.toHaveBeenCalled(); expect(addComment).not.toHaveBeenCalled();
}); });
it('skips kanban garbage-collect when tasks fail to load', async () => { it('skips reconcile writes when tasks fail to load', async () => {
const garbageCollect = vi.fn(async () => undefined); const garbageCollect = vi.fn(async () => undefined);
const service = new TeamDataService( const service = new TeamDataService(
{ {
@ -140,9 +146,8 @@ describe('TeamDataService', () => {
} as never } as never
); );
const result = await service.getTeamData('my-team'); await expect(service.reconcileTeamArtifacts('my-team')).rejects.toThrow('tasks failed');
expect(garbageCollect).not.toHaveBeenCalled(); expect(garbageCollect).not.toHaveBeenCalled();
expect(result.warnings).toContain('Tasks failed to load');
}); });
it('includes projectPath from config when creating a task', async () => { it('includes projectPath from config when creating a task', async () => {
@ -292,4 +297,79 @@ describe('TeamDataService', () => {
expect(result.related).toEqual(['1', '2']); expect(result.related).toEqual(['1', '2']);
expect(createTaskMock).toHaveBeenCalledWith(expect.objectContaining({ related: ['1', '2'] })); expect(createTaskMock).toHaveBeenCalledWith(expect.objectContaining({ related: ['1', '2'] }));
}); });
it('routes durable inbox writes through controller message API', async () => {
const sendMessageMock = vi.fn(() => ({ deliveredToInbox: true, messageId: 'm-1' }));
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({ name: 'My team', members: [], leadSessionId: 'lead-1' })),
} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
() =>
({
messages: {
sendMessage: sendMessageMock,
},
}) as never
);
const result = await service.sendMessage('my-team', {
member: 'alice',
text: 'hello',
summary: 'ping',
});
expect(result).toEqual({ deliveredToInbox: true, messageId: 'm-1' });
expect(sendMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
member: 'alice',
text: 'hello',
summary: 'ping',
leadSessionId: 'lead-1',
})
);
});
it('delegates review entry to controller review API', async () => {
const requestReviewMock = vi.fn();
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({
name: 'My team',
members: [{ name: 'lead', role: 'team lead' }],
})),
} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
() =>
({
review: {
requestReview: requestReviewMock,
},
}) as never
);
await service.requestReview('my-team', 'task-1');
expect(requestReviewMock).toHaveBeenCalledWith('task-1', { from: 'lead' });
});
}); });

View file

@ -42,6 +42,14 @@ const hoisted = vi.hoisted(() => {
stat, stat,
readFile, readFile,
atomicWrite, atomicWrite,
appendSentMessage: vi.fn((teamName: string, message: Record<string, unknown>) => {
const sentMessagesPath = `/mock/teams/${teamName}/sentMessages.json`;
const current = files.get(sentMessagesPath);
const rows = current ? (JSON.parse(current) as unknown[]) : [];
rows.push(message);
files.set(sentMessagesPath, JSON.stringify(rows));
return message;
}),
setAtomicWriteShouldFail: (next: boolean) => { setAtomicWriteShouldFail: (next: boolean) => {
atomicWriteShouldFail = next; atomicWriteShouldFail = next;
}, },
@ -72,6 +80,15 @@ vi.mock('../../../../src/main/utils/pathDecoder', async (importOriginal) => {
}; };
}); });
vi.mock('agent-teams-controller', () => ({
createController: ({ teamName }: { teamName: string }) => ({
messages: {
appendSentMessage: (message: Record<string, unknown>) =>
hoisted.appendSentMessage(teamName, message),
},
}),
}));
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
function seedConfig(teamName: string): void { function seedConfig(teamName: string): void {
@ -139,6 +156,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
hoisted.files.clear(); hoisted.files.clear();
hoisted.readFile.mockClear(); hoisted.readFile.mockClear();
hoisted.atomicWrite.mockClear(); hoisted.atomicWrite.mockClear();
hoisted.appendSentMessage.mockClear();
hoisted.setAtomicWriteShouldFail(false); hoisted.setAtomicWriteShouldFail(false);
}); });
@ -210,14 +228,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
expect(first).toBe(1); expect(first).toBe(1);
expect(second).toBe(0); expect(second).toBe(0);
expect(writeSpy).toHaveBeenCalledTimes(1); expect(writeSpy).toHaveBeenCalledTimes(1);
expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1);
// Relay now also persists to sentMessages.json via appendMessage() which uses
// atomicWriteAsync — expected to fail here since atomicWriteShouldFail=true.
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('TeamSentMessagesStore'),
expect.stringContaining('Failed to append sent message')
);
vi.mocked(console.error).mockClear();
}); });
it('does not mark as relayed when stdin is not writable', async () => { it('does not mark as relayed when stdin is not writable', async () => {