feat: enhance task creation and message handling with exact messageId support

- Updated `lookupMessage` function to enforce exact messageId matching, rejecting ambiguous cases.
- Improved documentation for `task_create_from_message` to clarify its requirements and usage.
- Enhanced `buildAssignmentMessage` and related functions to include messageId in task notifications for better provenance tracking.
- Added tests to validate the integration of messageId in task creation and relay processes, ensuring accurate message handling.
This commit is contained in:
iliya 2026-03-16 13:39:35 +02:00
parent 40f836cecc
commit fadd4e04c5
7 changed files with 475 additions and 372 deletions

View file

@ -168,11 +168,11 @@ function appendSentMessage(paths, flags) {
/** /**
* Exact readonly lookup by messageId across sent messages and all inbox files. * Exact readonly lookup by messageId across sent messages and all inbox files.
* *
* Rules: * Used by task_create_from_message to resolve provenance. Lookup is exact-messageId
* - Match only rows where row.messageId === requestedMessageId. * only and must never resolve by relayOfMessageId, text matching, or active context.
* - Ignore rows where only relayOfMessageId matches. * Must reject ambiguous matches (same messageId in multiple stores) instead of guessing.
* - If more than one exact match exists, reject as ambiguous. *
* - Returns { message, store } or throws. * Returns { message, store } or throws.
*/ */
function lookupMessage(paths, messageId) { function lookupMessage(paths, messageId) {
const id = typeof messageId === 'string' ? messageId.trim() : ''; const id = typeof messageId === 'string' ? messageId.trim() : '';

View file

@ -37,11 +37,11 @@ function quoteMarkdown(text) {
function buildAssignmentMessage(context, task, options = {}) { function buildAssignmentMessage(context, task, options = {}) {
const description = const description =
typeof options.description === 'string' && options.description.trim() typeof options.description === 'string' && options.description.trim() ?
? options.description.trim() options.description.trim() :
: typeof task.description === 'string' && task.description.trim() typeof task.description === 'string' && task.description.trim() ?
? task.description.trim() task.description.trim() :
: ''; '';
const prompt = const prompt =
typeof options.prompt === 'string' && options.prompt.trim() ? options.prompt.trim() : ''; typeof options.prompt === 'string' && options.prompt.trim() ? options.prompt.trim() : '';
const taskLabel = `#${task.displayId || task.id}`; const taskLabel = `#${task.displayId || task.id}`;
@ -78,8 +78,7 @@ function buildAssignmentMessage(context, task, options = {}) {
function buildCommentNotificationMessage(context, task, comment) { function buildCommentNotificationMessage(context, task, comment) {
const taskLabel = `#${task.displayId || task.id}`; const taskLabel = `#${task.displayId || task.id}`;
return [ return [
`**Comment on task ${taskLabel}**`, `**Comment on task ${taskLabel}** _${task.subject}_`,
`> ${task.subject}`,
``, ``,
quoteMarkdown(comment.text), quoteMarkdown(comment.text),
``, ``,
@ -235,10 +234,8 @@ function updateTaskFields(context, taskId, fields) {
function addTaskComment(context, taskId, flags) { function addTaskComment(context, taskId, flags) {
const result = taskStore.addTaskComment(context.paths, taskId, flags.text, { const result = taskStore.addTaskComment(context.paths, taskId, flags.text, {
author: author: typeof flags.from === 'string' && flags.from.trim() ?
typeof flags.from === 'string' && flags.from.trim() flags.from.trim() : runtimeHelpers.inferLeadName(context.paths),
? flags.from.trim()
: runtimeHelpers.inferLeadName(context.paths),
...(flags.id ? { id: flags.id } : {}), ...(flags.id ? { id: flags.id } : {}),
...(flags.createdAt ? { createdAt: flags.createdAt } : {}), ...(flags.createdAt ? { createdAt: flags.createdAt } : {}),
...(flags.type ? { type: flags.type } : {}), ...(flags.type ? { type: flags.type } : {}),
@ -342,9 +339,9 @@ function resolveLanguageName(code, systemLocale) {
function buildMemberLanguageInstruction(config) { function buildMemberLanguageInstruction(config) {
const configured = const configured =
config && typeof config.language === 'string' && config.language.trim() config && typeof config.language === 'string' && config.language.trim() ?
? config.language.trim() config.language.trim() :
: ''; '';
if (!configured) { if (!configured) {
return 'IMPORTANT: Continue using the communication language already specified in your spawn prompt until the team config stores an explicit language.'; return 'IMPORTANT: Continue using the communication language already specified in your spawn prompt until the team config stores an explicit language.';
} }
@ -486,21 +483,21 @@ async function memberBriefing(context, memberName) {
const effectiveMember = member; const effectiveMember = member;
const role = const role =
typeof effectiveMember.role === 'string' && effectiveMember.role.trim() typeof effectiveMember.role === 'string' && effectiveMember.role.trim() ?
? effectiveMember.role.trim() effectiveMember.role.trim() :
: typeof effectiveMember.agentType === 'string' && effectiveMember.agentType.trim() typeof effectiveMember.agentType === 'string' && effectiveMember.agentType.trim() ?
? effectiveMember.agentType.trim() effectiveMember.agentType.trim() :
: 'team member'; 'team member';
const workflow = const workflow =
typeof effectiveMember.workflow === 'string' && effectiveMember.workflow.trim() typeof effectiveMember.workflow === 'string' && effectiveMember.workflow.trim() ?
? effectiveMember.workflow.trim() effectiveMember.workflow.trim() :
: ''; '';
const cwd = const cwd =
typeof effectiveMember.cwd === 'string' && effectiveMember.cwd.trim() typeof effectiveMember.cwd === 'string' && effectiveMember.cwd.trim() ?
? effectiveMember.cwd.trim() effectiveMember.cwd.trim() :
: typeof config.projectPath === 'string' && config.projectPath.trim() typeof config.projectPath === 'string' && config.projectPath.trim() ?
? config.projectPath.trim() config.projectPath.trim() :
: ''; '';
const activeProcesses = processStore const activeProcesses = processStore
.listProcesses(context.paths) .listProcesses(context.paths)

View file

@ -18,8 +18,11 @@ const relationshipTypeSchema = z.enum(['blocked-by', 'blocks', 'related']);
const USER_ORIGINATED_SOURCES = new Set(['user_sent']); const USER_ORIGINATED_SOURCES = new Set(['user_sent']);
/** /**
* Shared payload builder for both task_create and task_create_from_message. * Shared payload builder for task_create and task_create_from_message.
* Keeps the canonical create-task shape in one place to avoid divergence. *
* Both tools MUST stay semantically aligned any new field added to task_create
* that also applies to message-derived tasks must be added here, not duplicated.
* Do not turn this into a repo-wide abstraction; keep it local to MCP tools.
*/ */
function buildCreateTaskPayload(params: { function buildCreateTaskPayload(params: {
subject: string; subject: string;
@ -99,6 +102,14 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
}, },
}); });
/*
* task_create_from_message creates a task from an exact persisted user message.
*
* This is NOT a heuristic "current context" resolver. It requires an exact messageId
* that points to a persisted row in sentMessages.json or an inbox file.
* Must reject relay copies, non-user sources, and ambiguous matches.
* Must not auto-generate subject or infer importState from attachments.
*/
server.addTool({ server.addTool({
name: 'task_create_from_message', name: 'task_create_from_message',
description: description:

View file

@ -1518,9 +1518,12 @@ export class TeamMemberLogsFinder {
.replace(/\\\\/g, '\\') .replace(/\\\\/g, '\\')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.trim(); .trim();
if (!raw) return null;
return raw.length > 1500 ? raw.slice(0, 1500) + '...' : raw; return raw.length > 1500 ? raw.slice(0, 1500) + '...' : raw;
} }
// Fallback: top-level string content // Fallback: top-level string content — skip lines with tool_use to avoid
// matching file content from Write/Edit tool inputs.
if (line.includes('"tool_use"')) return null;
const contentMatch = /"content"\s*:\s*"((?:[^"\\]|\\.){1,400})/.exec(line); const contentMatch = /"content"\s*:\s*"((?:[^"\\]|\\.){1,400})/.exec(line);
if (contentMatch?.[1]) { if (contentMatch?.[1]) {
const raw = contentMatch[1] const raw = contentMatch[1]
@ -1530,6 +1533,7 @@ export class TeamMemberLogsFinder {
.replace(/\\\\/g, '\\') .replace(/\\\\/g, '\\')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.trim(); .trim();
if (!raw) return null;
return raw.length > 1500 ? raw.slice(0, 1500) + '...' : raw; return raw.length > 1500 ? raw.slice(0, 1500) + '...' : raw;
} }
return null; return null;

View file

@ -736,6 +736,7 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
``, ``,
`Task board operations — use MCP tools directly:`, `Task board operations — use MCP tools directly:`,
`- Create task: task_create { teamName: "${teamName}", subject: "...", description?: "...", owner?: "<actual-member-name>", createdBy?: "<your-name>", blockedBy?: ["1","2"], related?: ["3"] }`, `- Create task: task_create { teamName: "${teamName}", subject: "...", description?: "...", owner?: "<actual-member-name>", createdBy?: "<your-name>", blockedBy?: ["1","2"], related?: ["3"] }`,
`- Create task from user message (preferred when you have a MessageId from a relayed inbox message): task_create_from_message { teamName: "${teamName}", messageId: "<exact-messageId>", subject: "...", owner?: "<member>", createdBy?: "<your-name>", blockedBy?: ["1","2"], related?: ["3"] }`,
`- Assign/reassign owner: task_set_owner { teamName: "${teamName}", taskId: "<id>", owner: "<member-name>" }`, `- Assign/reassign owner: task_set_owner { teamName: "${teamName}", taskId: "<id>", owner: "<member-name>" }`,
`- Clear owner: task_set_owner { teamName: "${teamName}", taskId: "<id>", owner: null }`, `- Clear owner: task_set_owner { teamName: "${teamName}", taskId: "<id>", owner: null }`,
`- Start task (preferred over set-status): task_start { teamName: "${teamName}", taskId: "<id>" }`, `- Start task (preferred over set-status): task_start { teamName: "${teamName}", taskId: "<id>" }`,
@ -3614,6 +3615,7 @@ export class TeamProvisioningService {
return [ return [
`${idx + 1}) From: ${m.from || 'unknown'}`, `${idx + 1}) From: ${m.from || 'unknown'}`,
` Timestamp: ${m.timestamp}`, ` Timestamp: ${m.timestamp}`,
` MessageId: ${m.messageId}`,
...(summaryLine ? [` ${summaryLine}`] : []), ...(summaryLine ? [` ${summaryLine}`] : []),
...(typeof m.source === 'string' && m.source.trim() ...(typeof m.source === 'string' && m.source.trim()
? [` Source: ${m.source.trim()}`] ? [` Source: ${m.source.trim()}`]
@ -3831,6 +3833,7 @@ export class TeamProvisioningService {
`IMPORTANT: Your text response here is shown to the user. Always include a brief human-readable summary (e.g. "Delegated to carol." or "No action needed."). Do NOT respond with only an agent-only block.`, `IMPORTANT: Your text response here is shown to the user. Always include a brief human-readable summary (e.g. "Delegated to carol." or "No action needed."). Do NOT respond with only an agent-only block.`,
AGENT_BLOCK_OPEN, AGENT_BLOCK_OPEN,
`Internal note: for task assignments, prefer task_create and rely on the board/runtime notification path instead of sending a separate SendMessage for the same assignment.`, `Internal note: for task assignments, prefer task_create and rely on the board/runtime notification path instead of sending a separate SendMessage for the same assignment.`,
`When creating a task from a user message that has a MessageId field, prefer task_create_from_message with that exact messageId for reliable provenance. Only use task_create_from_message when you have an explicit MessageId — never guess or fabricate one.`,
`If a message below is marked Source: system_notification and its summary looks like "Comment on #...", treat it as a task-comment notification that REQUIRES an on-task reply via task_add_comment. Do NOT treat a direct message as a sufficient substitute.`, `If a message below is marked Source: system_notification and its summary looks like "Comment on #...", treat it as a task-comment notification that REQUIRES an on-task reply via task_add_comment. Do NOT treat a direct message as a sufficient substitute.`,
`If a message below is marked Source: cross_team, CALL the MCP tool named cross_team_send. Do NOT use SendMessage or message_send for cross-team replies.`, `If a message below is marked Source: cross_team, CALL the MCP tool named cross_team_send. Do NOT use SendMessage or message_send for cross-team replies.`,
`NEVER set recipient="cross_team_send" or to="cross_team_send". "cross_team_send" is a tool name, not a teammate.`, `NEVER set recipient="cross_team_send" or to="cross_team_send". "cross_team_send" is a tool name, not a teammate.`,
@ -3860,6 +3863,7 @@ export class TeamProvisioningService {
return [ return [
`${idx + 1}) From: ${m.from || 'unknown'}`, `${idx + 1}) From: ${m.from || 'unknown'}`,
` Timestamp: ${m.timestamp}`, ` Timestamp: ${m.timestamp}`,
` MessageId: ${m.messageId}`,
...(summaryLine ? [` ${summaryLine}`] : []), ...(summaryLine ? [` ${summaryLine}`] : []),
...(typeof m.source === 'string' && m.source.trim() ...(typeof m.source === 'string' && m.source.trim()
? [` Source: ${m.source.trim()}`] ? [` Source: ${m.source.trim()}`]

View file

@ -128,6 +128,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
); );
expect(prompt).toContain('task_start'); expect(prompt).toContain('task_start');
expect(prompt).toContain('task_complete'); expect(prompt).toContain('task_complete');
expect(prompt).toContain('task_create_from_message');
expect(prompt).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):'); expect(prompt).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
expect(prompt).toContain('ASK: Strict read-only conversation mode.'); expect(prompt).toContain('ASK: Strict read-only conversation mode.');
expect(prompt).toContain('DELEGATE: Strict orchestration mode for leads.'); expect(prompt).toContain('DELEGATE: Strict orchestration mode for leads.');

View file

@ -773,4 +773,90 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
expect(relayed).toBe(0); expect(relayed).toBe(0);
expect(writeSpy).toHaveBeenCalledTimes(0); expect(writeSpy).toHaveBeenCalledTimes(0);
}); });
it('includes MessageId in lead inbox relay prompt for provenance', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedLeadInbox(teamName, [
{
from: 'user',
text: 'Build the authentication module',
timestamp: '2026-02-23T14:00:00.000Z',
read: false,
summary: 'Auth module request',
messageId: 'msg-provenance-001',
source: 'user_sent',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const relayPromise = service.relayLeadInboxMessages(teamName);
const run = await waitForCapture(service);
(service as any).handleStreamJsonMessage(run, {
type: 'assistant',
content: [{ type: 'text', text: 'Creating task.' }],
});
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
await relayPromise;
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
expect(payload).toContain('MessageId: msg-provenance-001');
expect(payload).toContain('Build the authentication module');
});
it('includes MessageId in member inbox relay prompt for provenance', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedMemberInbox(teamName, 'alice', [
{
from: 'bob',
text: 'Please review my changes',
timestamp: '2026-02-23T15:00:00.000Z',
read: false,
summary: 'Review request',
messageId: 'msg-member-relay-001',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
await service.relayMemberInboxMessages(teamName, 'alice');
expect(writeSpy).toHaveBeenCalledTimes(1);
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
expect(payload).toContain('MessageId: msg-member-relay-001');
expect(payload).toContain('Please review my changes');
});
it('lead inbox relay prompt mentions task_create_from_message for user messages with messageId', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedLeadInbox(teamName, [
{
from: 'user',
text: 'Implement dark mode',
timestamp: '2026-02-23T16:00:00.000Z',
read: false,
summary: 'Dark mode',
messageId: 'msg-task-pref-001',
source: 'user_sent',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const relayPromise = service.relayLeadInboxMessages(teamName);
const run = await waitForCapture(service);
(service as any).handleStreamJsonMessage(run, {
type: 'assistant',
content: [{ type: 'text', text: 'Got it.' }],
});
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
await relayPromise;
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
expect(payload).toContain('task_create_from_message');
expect(payload).toContain('MessageId');
});
}); });