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:
parent
40f836cecc
commit
fadd4e04c5
7 changed files with 475 additions and 372 deletions
|
|
@ -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() : '';
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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()}`]
|
||||||
|
|
|
||||||
|
|
@ -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.');
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue