diff --git a/agent-teams-controller/src/internal/messageStore.js b/agent-teams-controller/src/internal/messageStore.js index 11884cf4..ea40a088 100644 --- a/agent-teams-controller/src/internal/messageStore.js +++ b/agent-teams-controller/src/internal/messageStore.js @@ -165,8 +165,66 @@ function appendSentMessage(paths, flags) { return payload; } +/** + * Exact readonly lookup by messageId across sent messages and all inbox files. + * + * Rules: + * - Match only rows where row.messageId === requestedMessageId. + * - Ignore rows where only relayOfMessageId matches. + * - If more than one exact match exists, reject as ambiguous. + * - Returns { message, store } or throws. + */ +function lookupMessage(paths, messageId) { + const id = typeof messageId === 'string' ? messageId.trim() : ''; + if (!id) { + throw new Error('Missing messageId'); + } + + const matches = []; + + // 1. Search sentMessages.json + const sentRows = readJson(getSentMessagesPath(paths), []); + if (Array.isArray(sentRows)) { + for (const row of sentRows) { + if (row && row.messageId === id) { + matches.push({ message: row, store: 'sent' }); + } + } + } + + // 2. Search all inbox files + const inboxDir = path.join(paths.teamDir, 'inboxes'); + let inboxFiles = []; + try { + inboxFiles = fs.readdirSync(inboxDir).filter((f) => f.endsWith('.json')); + } catch { + // No inboxes directory — that's fine. + } + + for (const file of inboxFiles) { + const rows = readJson(path.join(inboxDir, file), []); + if (!Array.isArray(rows)) continue; + for (const row of rows) { + if (row && row.messageId === id) { + matches.push({ message: row, store: `inbox:${file.replace('.json', '')}` }); + } + } + } + + if (matches.length === 0) { + throw new Error(`Message not found: ${id}`); + } + + if (matches.length > 1) { + throw new Error(`Ambiguous messageId: ${id} found in ${matches.length} stores`); + } + + return matches[0]; +} + module.exports = { appendSentMessage, + lookupMessage, sendInboxMessage, }; diff --git a/agent-teams-controller/src/internal/messages.js b/agent-teams-controller/src/internal/messages.js index c2101a77..9aab43f4 100644 --- a/agent-teams-controller/src/internal/messages.js +++ b/agent-teams-controller/src/internal/messages.js @@ -8,7 +8,12 @@ function appendSentMessage(context, flags) { return messageStore.appendSentMessage(context.paths, flags); } +function lookupMessage(context, messageId) { + return messageStore.lookupMessage(context.paths, messageId); +} + module.exports = { appendSentMessage, + lookupMessage, sendMessage, }; diff --git a/agent-teams-controller/src/internal/taskStore.js b/agent-teams-controller/src/internal/taskStore.js index c8c87abc..af44be5d 100644 --- a/agent-teams-controller/src/internal/taskStore.js +++ b/agent-teams-controller/src/internal/taskStore.js @@ -330,6 +330,12 @@ function createTask(paths, input = {}) { deletedAt: status === 'deleted' && typeof input.deletedAt === 'string' ? input.deletedAt : undefined, attachments: Array.isArray(input.attachments) ? input.attachments : undefined, + ...(typeof input.sourceMessageId === 'string' && input.sourceMessageId.trim() + ? { sourceMessageId: input.sourceMessageId.trim() } + : {}), + ...(input.sourceMessage && typeof input.sourceMessage === 'object' + ? { sourceMessage: input.sourceMessage } + : {}), }); if (!task.subject) { diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index d2286938..dbce5f19 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -960,4 +960,95 @@ describe('agent-teams-controller API', () => { 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 2 stores' + ); + }); + }); }); diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts index fc61924d..84c22e2d 100644 --- a/mcp-server/src/tools/taskTools.ts +++ b/mcp-server/src/tools/taskTools.ts @@ -11,6 +11,52 @@ const toolContextSchema = { const relationshipTypeSchema = z.enum(['blocked-by', 'blocks', 'related']); +/** Agent-only block tag used for hidden instructions — must be stripped from provenance snapshots. */ +const AGENT_BLOCK_RE = /[\s\S]*?<\/info_for_agent>/g; + +/** Allowed message source types for task_create_from_message provenance. Fail closed — only explicit user-originated sources. */ +const USER_ORIGINATED_SOURCES = new Set(['user_sent', 'inbox']); + +/** + * Shared payload builder for both task_create and task_create_from_message. + * Keeps the canonical create-task shape in one place to avoid divergence. + */ +function buildCreateTaskPayload(params: { + subject: string; + description?: string; + owner?: string; + createdBy?: string; + from?: string; + blockedBy?: string[]; + related?: string[]; + prompt?: string; + startImmediately?: boolean; + sourceMessageId?: string; + sourceMessage?: Record; +}): Record { + return { + subject: params.subject, + ...(params.description ? { description: params.description } : {}), + ...(params.owner ? { owner: params.owner } : {}), + ...(params.createdBy ? { createdBy: params.createdBy } : {}), + ...(!params.createdBy && params.from ? { from: params.from } : {}), + ...(params.blockedBy?.length ? { 'blocked-by': params.blockedBy.join(',') } : {}), + ...(params.related?.length ? { related: params.related.join(',') } : {}), + ...(params.prompt ? { prompt: params.prompt } : {}), + ...(params.startImmediately !== undefined ? { startImmediately: params.startImmediately } : {}), + ...(params.sourceMessageId ? { sourceMessageId: params.sourceMessageId } : {}), + ...(params.sourceMessage ? { sourceMessage: params.sourceMessage } : {}), + }; +} + +/** + * Strip agent-only `` blocks from message text. + * Returns trimmed text with agent blocks removed. + */ +function stripAgentBlocks(text: string): string { + return text.replace(AGENT_BLOCK_RE, '').trim(); +} + export function registerTaskTools(server: Pick) { server.addTool({ name: 'task_create', @@ -43,17 +89,116 @@ export function registerTaskTools(server: Pick) { const controller = getController(teamName, claudeDir); return await Promise.resolve( jsonTextContent( - controller.tasks.createTask({ - subject, - ...(description ? { description } : {}), - ...(owner ? { owner } : {}), - ...(createdBy ? { createdBy } : {}), - ...(!createdBy && from ? { from } : {}), - ...(blockedBy?.length ? { 'blocked-by': blockedBy.join(',') } : {}), - ...(related?.length ? { related: related.join(',') } : {}), - ...(prompt ? { prompt } : {}), - ...(startImmediately !== undefined ? { startImmediately } : {}), - }) + controller.tasks.createTask( + buildCreateTaskPayload({ + subject, + description, + owner, + createdBy, + from, + blockedBy, + related, + prompt, + startImmediately, + }) + ) + ) + ); + }, + }); + + server.addTool({ + name: 'task_create_from_message', + description: + 'Create a task from a persisted user message. Resolves the message by exact messageId, builds sanitized provenance, and creates the task through the canonical path.', + parameters: z.object({ + ...toolContextSchema, + messageId: z.string().min(1), + subject: z.string().min(1), + description: z.string().optional(), + owner: z.string().optional(), + blockedBy: z.array(z.string().min(1)).optional(), + related: z.array(z.string().min(1)).optional(), + prompt: z.string().optional(), + startImmediately: z.boolean().optional(), + }), + execute: async ({ + teamName, + claudeDir, + messageId, + subject, + description, + owner, + blockedBy, + related, + prompt, + startImmediately, + }) => { + const controller = getController(teamName, claudeDir); + + // 1. Lookup message by exact messageId + const { message } = controller.messages.lookupMessage(messageId); + + // 2. Reject if message source is not user-originated + const source = typeof message.source === 'string' ? message.source : ''; + if (!USER_ORIGINATED_SOURCES.has(source)) { + throw new Error( + `Message source "${source}" is not user-originated. Only user_sent and inbox messages are eligible.` + ); + } + + // 3. Reject relay copies explicitly + if (typeof message.relayOfMessageId === 'string' && message.relayOfMessageId.trim()) { + throw new Error( + 'Cannot create task from a relay copy. Use the original message instead.' + ); + } + + // 4. Build sanitized source snapshot + const rawText = typeof message.text === 'string' ? message.text : ''; + const sanitizedText = stripAgentBlocks(rawText); + + const sourceMessage: Record = { + text: sanitizedText, + from: typeof message.from === 'string' ? message.from : 'unknown', + timestamp: typeof message.timestamp === 'string' ? message.timestamp : '', + ...(source ? { source } : {}), + }; + + // Preserve attachment metadata by reference only — no blob copying + if (Array.isArray(message.attachments) && message.attachments.length > 0) { + sourceMessage.attachments = (message.attachments as Array>) + .filter( + (a) => + a && + typeof a === 'object' && + typeof a.id === 'string' && + typeof a.filename === 'string' + ) + .map((a) => ({ + id: String(a.id), + filename: String(a.filename), + mimeType: typeof a.mimeType === 'string' ? a.mimeType : '', + size: typeof a.size === 'number' ? a.size : 0, + })); + } + + // 5. Forward into canonical create-task path + return await Promise.resolve( + jsonTextContent( + controller.tasks.createTask( + buildCreateTaskPayload({ + subject, + description, + owner, + blockedBy, + related, + prompt, + startImmediately, + sourceMessageId: messageId, + sourceMessage, + }) + ) ) ); }, diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index a4a15149..d8742c24 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -55,6 +55,7 @@ describe('agent-teams-mcp tools', () => { 'task_briefing', 'task_complete', 'task_create', + 'task_create_from_message', 'task_get', 'task_link', 'task_list', @@ -916,4 +917,441 @@ describe('agent-teams-mcp tools', () => { expect(reloaded.comments).toHaveLength(1); expect(reloaded.comments[0].text).toBe('Comment should persist despite broken inbox'); }); + + describe('task_create_from_message', () => { + function writeSentMessage( + claudeDir: string, + teamName: string, + message: Record + ) { + const sentPath = path.join(claudeDir, 'teams', teamName, 'sentMessages.json'); + const teamDir = path.join(claudeDir, 'teams', teamName); + fs.mkdirSync(teamDir, { recursive: true }); + const existing = fs.existsSync(sentPath) + ? JSON.parse(fs.readFileSync(sentPath, 'utf8')) + : []; + existing.push(message); + fs.writeFileSync(sentPath, JSON.stringify(existing, null, 2)); + } + + function writeInboxMessage( + claudeDir: string, + teamName: string, + memberName: string, + message: Record + ) { + const inboxDir = path.join(claudeDir, 'teams', teamName, 'inboxes'); + fs.mkdirSync(inboxDir, { recursive: true }); + const inboxPath = path.join(inboxDir, `${memberName}.json`); + const existing = fs.existsSync(inboxPath) + ? JSON.parse(fs.readFileSync(inboxPath, 'utf8')) + : []; + existing.push(message); + fs.writeFileSync(inboxPath, JSON.stringify(existing, null, 2)); + } + + it('creates a task from a valid user message with provenance', async () => { + const claudeDir = makeClaudeDir(); + const teamName = 'msg-team'; + fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); + writeTeamConfig(claudeDir, teamName, { + members: [{ name: 'lead', role: 'team-lead' }], + }); + + const messageId = 'msg-user-001'; + writeSentMessage(claudeDir, teamName, { + messageId, + from: 'user', + to: 'team-lead', + text: 'Please implement the login page', + timestamp: '2026-03-15T10:00:00.000Z', + source: 'user_sent', + }); + + const created = parseJsonToolResult( + await getTool('task_create_from_message').execute({ + claudeDir, + teamName, + messageId, + subject: 'Implement login page', + owner: 'lead', + }) + ); + + expect(created.subject).toBe('Implement login page'); + expect(created.owner).toBe('lead'); + expect(created.sourceMessageId).toBe(messageId); + expect(created.sourceMessage).toBeDefined(); + expect(created.sourceMessage.text).toBe('Please implement the login page'); + expect(created.sourceMessage.from).toBe('user'); + expect(created.sourceMessage.timestamp).toBe('2026-03-15T10:00:00.000Z'); + expect(created.sourceMessage.source).toBe('user_sent'); + }); + + it('strips agent-only blocks from source text', async () => { + const claudeDir = makeClaudeDir(); + const teamName = 'strip-team'; + fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); + writeTeamConfig(claudeDir, teamName, { + members: [{ name: 'lead', role: 'team-lead' }], + }); + + const messageId = 'msg-with-agent-blocks'; + writeSentMessage(claudeDir, teamName, { + messageId, + from: 'user', + text: 'Fix the bug \nuse task_create to track\n in the API', + timestamp: '2026-03-15T11:00:00.000Z', + source: 'user_sent', + }); + + const created = parseJsonToolResult( + await getTool('task_create_from_message').execute({ + claudeDir, + teamName, + messageId, + subject: 'Fix API bug', + }) + ); + + expect(created.sourceMessage.text).toBe('Fix the bug in the API'); + expect(created.sourceMessage.text).not.toContain('info_for_agent'); + }); + + it('rejects unknown messageId', async () => { + const claudeDir = makeClaudeDir(); + const teamName = 'unknown-msg'; + fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); + writeTeamConfig(claudeDir, teamName, { + members: [{ name: 'lead', role: 'team-lead' }], + }); + + await expect( + getTool('task_create_from_message').execute({ + claudeDir, + teamName, + messageId: 'nonexistent-msg', + subject: 'Should fail', + }) + ).rejects.toThrow('Message not found: nonexistent-msg'); + }); + + it('rejects non-user-originated message sources', async () => { + const claudeDir = makeClaudeDir(); + const teamName = 'source-reject'; + fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); + writeTeamConfig(claudeDir, teamName, { + members: [{ name: 'lead', role: 'team-lead' }], + }); + + const messageId = 'msg-system-001'; + writeSentMessage(claudeDir, teamName, { + messageId, + from: 'system', + text: 'System generated notification', + timestamp: '2026-03-15T12:00:00.000Z', + source: 'system_notification', + }); + + await expect( + getTool('task_create_from_message').execute({ + claudeDir, + teamName, + messageId, + subject: 'Should fail', + }) + ).rejects.toThrow('not user-originated'); + }); + + it('rejects lead_process and cross_team sources explicitly', async () => { + const claudeDir = makeClaudeDir(); + const teamName = 'source-reject-2'; + fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); + writeTeamConfig(claudeDir, teamName, { + members: [{ name: 'lead', role: 'team-lead' }], + }); + + writeSentMessage(claudeDir, teamName, { + messageId: 'msg-lead-001', + from: 'team-lead', + text: 'Lead process message', + timestamp: '2026-03-15T12:01:00.000Z', + source: 'lead_process', + }); + + writeSentMessage(claudeDir, teamName, { + messageId: 'msg-cross-001', + from: 'other-team.lead', + text: 'Cross team message', + timestamp: '2026-03-15T12:02:00.000Z', + source: 'cross_team', + }); + + await expect( + getTool('task_create_from_message').execute({ + claudeDir, + teamName, + messageId: 'msg-lead-001', + subject: 'Should fail', + }) + ).rejects.toThrow('not user-originated'); + + await expect( + getTool('task_create_from_message').execute({ + claudeDir, + teamName, + messageId: 'msg-cross-001', + subject: 'Should fail', + }) + ).rejects.toThrow('not user-originated'); + }); + + it('rejects messages without an explicit source field (fail closed)', async () => { + const claudeDir = makeClaudeDir(); + const teamName = 'no-source'; + fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); + writeTeamConfig(claudeDir, teamName, { + members: [{ name: 'lead', role: 'team-lead' }], + }); + + writeSentMessage(claudeDir, teamName, { + messageId: 'msg-no-source', + from: 'user', + text: 'Old message without source field', + timestamp: '2026-03-15T12:03:00.000Z', + // no source field + }); + + await expect( + getTool('task_create_from_message').execute({ + claudeDir, + teamName, + messageId: 'msg-no-source', + subject: 'Should fail', + }) + ).rejects.toThrow('not user-originated'); + }); + + it('rejects relay copies', async () => { + const claudeDir = makeClaudeDir(); + const teamName = 'relay-reject'; + fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); + writeTeamConfig(claudeDir, teamName, { + members: [{ name: 'lead', role: 'team-lead' }], + }); + + const messageId = 'msg-relay-001'; + writeSentMessage(claudeDir, teamName, { + messageId, + from: 'user', + text: 'Relayed content', + timestamp: '2026-03-15T13:00:00.000Z', + source: 'user_sent', + relayOfMessageId: 'original-msg-999', + }); + + await expect( + getTool('task_create_from_message').execute({ + claudeDir, + teamName, + messageId, + subject: 'Should fail', + }) + ).rejects.toThrow('relay copy'); + }); + + it('preserves attachment metadata without blob copying', async () => { + const claudeDir = makeClaudeDir(); + const teamName = 'attach-meta'; + fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); + writeTeamConfig(claudeDir, teamName, { + members: [{ name: 'lead', role: 'team-lead' }], + }); + + const messageId = 'msg-attach-001'; + writeInboxMessage(claudeDir, teamName, 'lead', { + messageId, + from: 'user', + to: 'lead', + text: 'See attached screenshot', + timestamp: '2026-03-15T14:00:00.000Z', + source: 'inbox', + attachments: [ + { id: 'att-1', filename: 'screenshot.png', mimeType: 'image/png', size: 42000 }, + ], + }); + + const created = parseJsonToolResult( + await getTool('task_create_from_message').execute({ + claudeDir, + teamName, + messageId, + subject: 'Review screenshot', + }) + ); + + expect(created.sourceMessage.attachments).toHaveLength(1); + expect(created.sourceMessage.attachments[0].id).toBe('att-1'); + expect(created.sourceMessage.attachments[0].filename).toBe('screenshot.png'); + expect(created.sourceMessage.attachments[0].mimeType).toBe('image/png'); + expect(created.sourceMessage.attachments[0].size).toBe(42000); + }); + + it('produces the same canonical task shape as task_create plus provenance', async () => { + const claudeDir = makeClaudeDir(); + const teamName = 'parity-check'; + fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); + writeTeamConfig(claudeDir, teamName, { + members: [{ name: 'lead', role: 'team-lead' }], + }); + + const messageId = 'msg-parity-001'; + writeSentMessage(claudeDir, teamName, { + messageId, + from: 'user', + text: 'Build the dashboard', + timestamp: '2026-03-15T15:00:00.000Z', + source: 'user_sent', + }); + + const fromMessage = parseJsonToolResult( + await getTool('task_create_from_message').execute({ + claudeDir, + teamName, + messageId, + subject: 'Build dashboard', + description: 'Create the main dashboard view', + owner: 'lead', + }) + ); + + const regular = parseJsonToolResult( + await getTool('task_create').execute({ + claudeDir, + teamName, + subject: 'Build dashboard (regular)', + description: 'Create the main dashboard view', + owner: 'lead', + }) + ); + + // Both have the same canonical shape + expect(fromMessage.status).toBe(regular.status); + expect(fromMessage.historyEvents).toHaveLength(regular.historyEvents.length); + expect(typeof fromMessage.id).toBe(typeof regular.id); + expect(typeof fromMessage.displayId).toBe(typeof regular.displayId); + + // Only the from_message task has provenance + expect(fromMessage.sourceMessageId).toBe(messageId); + expect(fromMessage.sourceMessage).toBeDefined(); + expect(regular.sourceMessageId).toBeUndefined(); + expect(regular.sourceMessage).toBeUndefined(); + }); + + it('survives create → persist → read round-trip with provenance intact', async () => { + const claudeDir = makeClaudeDir(); + const teamName = 'roundtrip'; + fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); + writeTeamConfig(claudeDir, teamName, { + members: [{ name: 'lead', role: 'team-lead' }], + }); + + const messageId = 'msg-roundtrip-001'; + writeSentMessage(claudeDir, teamName, { + messageId, + from: 'user', + text: 'Roundtrip test message', + timestamp: '2026-03-15T16:00:00.000Z', + source: 'user_sent', + attachments: [ + { id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 }, + ], + }); + + const created = parseJsonToolResult( + await getTool('task_create_from_message').execute({ + claudeDir, + teamName, + messageId, + subject: 'Roundtrip task', + description: 'Test persistence', + }) + ); + + // Re-read from disk via task_get to verify persistence + const reloaded = parseJsonToolResult( + await getTool('task_get').execute({ + claudeDir, + teamName, + taskId: created.id, + }) + ); + + expect(reloaded.sourceMessageId).toBe(messageId); + expect(reloaded.sourceMessage).toBeDefined(); + expect(reloaded.sourceMessage.text).toBe('Roundtrip test message'); + expect(reloaded.sourceMessage.from).toBe('user'); + expect(reloaded.sourceMessage.timestamp).toBe('2026-03-15T16:00:00.000Z'); + expect(reloaded.sourceMessage.source).toBe('user_sent'); + expect(reloaded.sourceMessage.attachments).toHaveLength(1); + expect(reloaded.sourceMessage.attachments[0].id).toBe('att-rt'); + }); + + it('old tasks without provenance continue to read normally', async () => { + const claudeDir = makeClaudeDir(); + const teamName = 'legacy'; + fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true }); + writeTeamConfig(claudeDir, teamName, { + members: [{ name: 'lead', role: 'team-lead' }], + }); + + // Create a regular task (no provenance) + const regular = parseJsonToolResult( + await getTool('task_create').execute({ + claudeDir, + teamName, + subject: 'Legacy task without provenance', + }) + ); + + // Re-read — should work without provenance fields + const reloaded = parseJsonToolResult( + await getTool('task_get').execute({ + claudeDir, + teamName, + taskId: regular.id, + }) + ); + + expect(reloaded.subject).toBe('Legacy task without provenance'); + expect(reloaded.sourceMessageId).toBeUndefined(); + expect(reloaded.sourceMessage).toBeUndefined(); + }); + + it('validates zod schema rejects missing required fields', () => { + expect( + getTool('task_create_from_message').parameters?.safeParse({ + teamName: 'demo', + messageId: 'msg-1', + // subject is missing + }).success + ).toBe(false); + + expect( + getTool('task_create_from_message').parameters?.safeParse({ + teamName: 'demo', + // messageId is missing + subject: 'Test', + }).success + ).toBe(false); + + expect( + getTool('task_create_from_message').parameters?.safeParse({ + teamName: 'demo', + messageId: 'msg-1', + subject: 'Valid', + }).success + ).toBe(true); + }); + }); }); diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 7e65425e..45b84829 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -10,6 +10,7 @@ import * as path from 'path'; import { getTeamFsWorkerClient } from './TeamFsWorkerClient'; import type { + SourceMessageSnapshot, TaskAttachmentMeta, TaskComment, TaskHistoryEvent, @@ -293,6 +294,18 @@ export class TeamTaskReader { historyEvents, reviewState: parsed.reviewState as TeamTask['reviewState'], }), + sourceMessageId: + typeof parsed.sourceMessageId === 'string' && parsed.sourceMessageId.trim() + ? parsed.sourceMessageId.trim() + : undefined, + sourceMessage: + parsed.sourceMessage && + typeof parsed.sourceMessage === 'object' && + typeof (parsed.sourceMessage as Record).text === 'string' && + typeof (parsed.sourceMessage as Record).from === 'string' && + typeof (parsed.sourceMessage as Record).timestamp === 'string' + ? (parsed.sourceMessage as SourceMessageSnapshot) + : undefined, } satisfies Record; if (task.status === 'deleted') { continue; diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index 928b90f0..8074ce5f 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -92,7 +92,7 @@ interface MemberLogsTabProps { onPreviewOnlineChange?: (isOnline: boolean) => void; } -const PREVIEW_PAGE_SIZE = 4; +const PREVIEW_PAGE_SIZE = 8; export const MemberLogsTab = ({ teamName, diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 3168770f..dd6c4014 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -134,6 +134,23 @@ export interface TaskComment { attachments?: TaskAttachmentMeta[]; } +/** + * Snapshot of a user message captured at task-creation time. + * Stored as provenance — the original message identity is `sourceMessageId`. + */ +export interface SourceMessageSnapshot { + /** Sanitized message text (agent-only blocks stripped). */ + text: string; + /** Who sent the message. */ + from: string; + /** ISO timestamp of the original message. */ + timestamp: string; + /** Message source type (e.g. "user_sent", "inbox"). */ + source?: string; + /** Attachment metadata references (IDs only, no blobs). */ + attachments?: Array<{ id: string; filename: string; mimeType: string; size: number }>; +} + // Fields are validated in TeamTaskReader.getTasks() using `satisfies Record`. // Adding a field here without mapping it there will cause a compile error. export interface TeamTask { @@ -179,6 +196,10 @@ export interface TeamTask { attachments?: TaskAttachmentMeta[]; /** Derived review state — computed from historyEvents, not persisted as authority. */ reviewState?: TeamReviewState; + /** Exact messageId of the user message this task was created from. */ + sourceMessageId?: string; + /** Snapshot of the source message at creation time (sanitized, no blobs). */ + sourceMessage?: SourceMessageSnapshot; } /** Task enriched for UI/DTO use (overlay from kanban-state.json). */ diff --git a/src/types/agent-teams-controller.d.ts b/src/types/agent-teams-controller.d.ts index 04043d6f..411551ad 100644 --- a/src/types/agent-teams-controller.d.ts +++ b/src/types/agent-teams-controller.d.ts @@ -46,6 +46,7 @@ declare module 'agent-teams-controller' { export interface ControllerMessageApi { appendSentMessage(flags: Record): unknown; + lookupMessage(messageId: string): { message: Record; store: string }; sendMessage(flags: Record): unknown; }