feat: add message lookup functionality for task creation
- Introduced a new `lookupMessage` function to retrieve messages by their exact messageId from both sent messages and inbox files. - Enhanced error handling for ambiguous messageId scenarios and missing messageId cases. - Updated the `createTask` function to include `sourceMessageId` and `sourceMessage` fields, capturing the original message details during task creation. - Added comprehensive tests for the `lookupMessage` functionality, ensuring accurate retrieval and validation of messages from different sources.
This commit is contained in:
parent
de205b13d7
commit
baf0609595
10 changed files with 790 additions and 12 deletions
|
|
@ -165,8 +165,66 @@ function appendSentMessage(paths, flags) {
|
||||||
return payload;
|
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 = {
|
module.exports = {
|
||||||
appendSentMessage,
|
appendSentMessage,
|
||||||
|
lookupMessage,
|
||||||
sendInboxMessage,
|
sendInboxMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,12 @@ function appendSentMessage(context, flags) {
|
||||||
return messageStore.appendSentMessage(context.paths, flags);
|
return messageStore.appendSentMessage(context.paths, flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lookupMessage(context, messageId) {
|
||||||
|
return messageStore.lookupMessage(context.paths, messageId);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
appendSentMessage,
|
appendSentMessage,
|
||||||
|
lookupMessage,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -330,6 +330,12 @@ function createTask(paths, input = {}) {
|
||||||
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,
|
||||||
|
...(typeof input.sourceMessageId === 'string' && input.sourceMessageId.trim()
|
||||||
|
? { sourceMessageId: input.sourceMessageId.trim() }
|
||||||
|
: {}),
|
||||||
|
...(input.sourceMessage && typeof input.sourceMessage === 'object'
|
||||||
|
? { sourceMessage: input.sourceMessage }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!task.subject) {
|
if (!task.subject) {
|
||||||
|
|
|
||||||
|
|
@ -960,4 +960,95 @@ describe('agent-teams-controller API', () => {
|
||||||
await liveServer.close();
|
await liveServer.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('lookupMessage', () => {
|
||||||
|
it('finds a message by exact messageId from sentMessages', () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
||||||
|
const sent = controller.messages.appendSentMessage({
|
||||||
|
from: 'team-lead',
|
||||||
|
to: 'bob',
|
||||||
|
text: 'Please check the logs',
|
||||||
|
source: 'user_sent',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = controller.messages.lookupMessage(sent.messageId);
|
||||||
|
|
||||||
|
expect(result.message.messageId).toBe(sent.messageId);
|
||||||
|
expect(result.message.text).toBe('Please check the logs');
|
||||||
|
expect(result.store).toBe('sent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds a message by exact messageId from inbox', () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
||||||
|
const delivered = controller.messages.sendMessage({
|
||||||
|
to: 'bob',
|
||||||
|
from: 'user',
|
||||||
|
text: 'Deploy to staging',
|
||||||
|
source: 'inbox',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = controller.messages.lookupMessage(delivered.messageId);
|
||||||
|
|
||||||
|
expect(result.message.messageId).toBe(delivered.messageId);
|
||||||
|
expect(result.message.text).toBe('Deploy to staging');
|
||||||
|
expect(result.store).toBe('inbox:bob');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on unknown messageId', () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
||||||
|
expect(() => controller.messages.lookupMessage('nonexistent-id')).toThrow(
|
||||||
|
'Message not found: nonexistent-id'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on missing messageId', () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
||||||
|
expect(() => controller.messages.lookupMessage('')).toThrow('Missing messageId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not match by relayOfMessageId', () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
||||||
|
controller.messages.sendMessage({
|
||||||
|
to: 'bob',
|
||||||
|
from: 'team-lead',
|
||||||
|
text: 'Relayed message',
|
||||||
|
relayOfMessageId: 'original-msg-123',
|
||||||
|
source: 'system_notification',
|
||||||
|
});
|
||||||
|
|
||||||
|
// The relayOfMessageId should NOT be found as a direct messageId match
|
||||||
|
expect(() => controller.messages.lookupMessage('original-msg-123')).toThrow(
|
||||||
|
'Message not found: original-msg-123'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects ambiguous messageId found in multiple stores', () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
||||||
|
// Manually write same messageId to both sent and inbox
|
||||||
|
const sentPath = path.join(claudeDir, 'teams', 'my-team', 'sentMessages.json');
|
||||||
|
const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes');
|
||||||
|
fs.mkdirSync(inboxDir, { recursive: true });
|
||||||
|
const inboxPath = path.join(inboxDir, 'bob.json');
|
||||||
|
|
||||||
|
const dupeId = 'dupe-message-id';
|
||||||
|
fs.writeFileSync(sentPath, JSON.stringify([{ messageId: dupeId, text: 'copy-1' }]));
|
||||||
|
fs.writeFileSync(inboxPath, JSON.stringify([{ messageId: dupeId, text: 'copy-2' }]));
|
||||||
|
|
||||||
|
expect(() => controller.messages.lookupMessage(dupeId)).toThrow(
|
||||||
|
'Ambiguous messageId: dupe-message-id found in 2 stores'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,52 @@ const toolContextSchema = {
|
||||||
|
|
||||||
const relationshipTypeSchema = z.enum(['blocked-by', 'blocks', 'related']);
|
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 = /<info_for_agent>[\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<string, unknown>;
|
||||||
|
}): Record<string, unknown> {
|
||||||
|
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 `<info_for_agent>` 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<FastMCP, 'addTool'>) {
|
export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
server.addTool({
|
server.addTool({
|
||||||
name: 'task_create',
|
name: 'task_create',
|
||||||
|
|
@ -43,17 +89,116 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
const controller = getController(teamName, claudeDir);
|
const controller = getController(teamName, claudeDir);
|
||||||
return await Promise.resolve(
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
controller.tasks.createTask({
|
controller.tasks.createTask(
|
||||||
subject,
|
buildCreateTaskPayload({
|
||||||
...(description ? { description } : {}),
|
subject,
|
||||||
...(owner ? { owner } : {}),
|
description,
|
||||||
...(createdBy ? { createdBy } : {}),
|
owner,
|
||||||
...(!createdBy && from ? { from } : {}),
|
createdBy,
|
||||||
...(blockedBy?.length ? { 'blocked-by': blockedBy.join(',') } : {}),
|
from,
|
||||||
...(related?.length ? { related: related.join(',') } : {}),
|
blockedBy,
|
||||||
...(prompt ? { prompt } : {}),
|
related,
|
||||||
...(startImmediately !== undefined ? { startImmediately } : {}),
|
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<string, unknown> = {
|
||||||
|
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<Record<string, unknown>>)
|
||||||
|
.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,
|
||||||
|
})
|
||||||
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
'task_briefing',
|
'task_briefing',
|
||||||
'task_complete',
|
'task_complete',
|
||||||
'task_create',
|
'task_create',
|
||||||
|
'task_create_from_message',
|
||||||
'task_get',
|
'task_get',
|
||||||
'task_link',
|
'task_link',
|
||||||
'task_list',
|
'task_list',
|
||||||
|
|
@ -916,4 +917,441 @@ describe('agent-teams-mcp tools', () => {
|
||||||
expect(reloaded.comments).toHaveLength(1);
|
expect(reloaded.comments).toHaveLength(1);
|
||||||
expect(reloaded.comments[0].text).toBe('Comment should persist despite broken inbox');
|
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<string, unknown>
|
||||||
|
) {
|
||||||
|
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<string, unknown>
|
||||||
|
) {
|
||||||
|
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 <info_for_agent>\nuse task_create to track\n</info_for_agent> 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import * as path from 'path';
|
||||||
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
SourceMessageSnapshot,
|
||||||
TaskAttachmentMeta,
|
TaskAttachmentMeta,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
TaskHistoryEvent,
|
TaskHistoryEvent,
|
||||||
|
|
@ -293,6 +294,18 @@ export class TeamTaskReader {
|
||||||
historyEvents,
|
historyEvents,
|
||||||
reviewState: parsed.reviewState as TeamTask['reviewState'],
|
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<string, unknown>).text === 'string' &&
|
||||||
|
typeof (parsed.sourceMessage as Record<string, unknown>).from === 'string' &&
|
||||||
|
typeof (parsed.sourceMessage as Record<string, unknown>).timestamp === 'string'
|
||||||
|
? (parsed.sourceMessage as SourceMessageSnapshot)
|
||||||
|
: undefined,
|
||||||
} satisfies Record<keyof TeamTask, unknown>;
|
} satisfies Record<keyof TeamTask, unknown>;
|
||||||
if (task.status === 'deleted') {
|
if (task.status === 'deleted') {
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ interface MemberLogsTabProps {
|
||||||
onPreviewOnlineChange?: (isOnline: boolean) => void;
|
onPreviewOnlineChange?: (isOnline: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PREVIEW_PAGE_SIZE = 4;
|
const PREVIEW_PAGE_SIZE = 8;
|
||||||
|
|
||||||
export const MemberLogsTab = ({
|
export const MemberLogsTab = ({
|
||||||
teamName,
|
teamName,
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,23 @@ export interface TaskComment {
|
||||||
attachments?: TaskAttachmentMeta[];
|
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<keyof TeamTask, unknown>`.
|
// Fields are validated in TeamTaskReader.getTasks() using `satisfies Record<keyof TeamTask, unknown>`.
|
||||||
// Adding a field here without mapping it there will cause a compile error.
|
// Adding a field here without mapping it there will cause a compile error.
|
||||||
export interface TeamTask {
|
export interface TeamTask {
|
||||||
|
|
@ -179,6 +196,10 @@ export interface TeamTask {
|
||||||
attachments?: TaskAttachmentMeta[];
|
attachments?: TaskAttachmentMeta[];
|
||||||
/** Derived review state — computed from historyEvents, not persisted as authority. */
|
/** Derived review state — computed from historyEvents, not persisted as authority. */
|
||||||
reviewState?: TeamReviewState;
|
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). */
|
/** Task enriched for UI/DTO use (overlay from kanban-state.json). */
|
||||||
|
|
|
||||||
1
src/types/agent-teams-controller.d.ts
vendored
1
src/types/agent-teams-controller.d.ts
vendored
|
|
@ -46,6 +46,7 @@ declare module 'agent-teams-controller' {
|
||||||
|
|
||||||
export interface ControllerMessageApi {
|
export interface ControllerMessageApi {
|
||||||
appendSentMessage(flags: Record<string, unknown>): unknown;
|
appendSentMessage(flags: Record<string, unknown>): unknown;
|
||||||
|
lookupMessage(messageId: string): { message: Record<string, unknown>; store: string };
|
||||||
sendMessage(flags: Record<string, unknown>): unknown;
|
sendMessage(flags: Record<string, unknown>): unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue