Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions apps/server/src/agents/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,13 @@ type CreateAgentInput = {
* what the CLI receives: `prompt` is the message as the person or launching
* agent wrote it (the MCP launch path wraps `initialPrompt` in a header the
* feed should not repeat); `links` are the raw startup URLs the route also
* turned into url pins. Defaults to `initialPrompt` and no links.
* turned into url pins. Internal/generated startup prompts are deliberately
* omitted unless a caller explicitly supplies their user-authored context.
*/
launchContext?: { prompt?: string; links?: string[] };
launchContext?: {
prompt?: string;
links?: string[];
};
initialPins?: AgentPin[];
initialFiles?: Array<{
fileName: string;
Expand Down Expand Up @@ -590,7 +594,7 @@ export class AgentManager {
return {
id: launchPostId,
agentId: p.id,
text: input.launchContext?.prompt ?? input.initialPrompt,
text: input.launchContext?.prompt,
files: initialMedia.map((media) => ({ mediaId: media.mediaId })),
links: input.launchContext?.links ?? [],
pins: p.initialPins.map((pin) => ({
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/agents/tmux/command-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ export function buildStartupPrompt(
* and attachment schema.
*/
export const CHAT_SURFACE_GUIDANCE_RULE =
"Send user-facing replies and questions with dispatch_chat_post; use kind: question with options for finite choices. Terminal output remains in Console.";
"The user is reading Chat, not Console. Send every user-facing reply and question with dispatch_chat_post; use kind: question with options for finite choices.";

/**
* Build the numbered launch guidance text shared by all CLI agent types.
Expand Down
6 changes: 3 additions & 3 deletions apps/server/src/chat/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ export function escapeEnvelopeMarkers(text: string): string {

/**
* The pane-injection envelope wrapping a user's Chat message. The trailing
* line tells the agent how to answer so the reply lands back in the Chat
* tab (docs/chat-surface-plan.md, "Injection envelope").
* line gives the minimum routing reminder needed to thread the reply back
* into Chat; the persistent launch guidance explains why.
*
* `attachmentLines` (one `- kind: …` line each) are listed after the text and
* before the closing marker so the agent can act on them. A blank text with
Expand All @@ -79,7 +79,7 @@ export function buildChatEnvelope(
`--- DISPATCH CHAT (id: ${messageId}) ---`,
...(body.length > 0 ? [safeBody] : []),
"--- END DISPATCH CHAT ---",
`The user is reading the Chat tab, not this terminal — they only see what you post with dispatch_chat_post. Reply there (replyTo: "${messageId}"); terminal output alone will not reach them.`,
`The user only sees Chat — reply with dispatch_chat_post (replyTo: "${messageId}").`,
].join("\n");
}

Expand Down
19 changes: 14 additions & 5 deletions apps/server/src/chat/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,17 +223,25 @@ async function listAgentMessageEntries(
recipient_agent_id: string;
sender_name: string;
recipient_name: string;
involves_child_agent: boolean;
content: string;
delivered: boolean | null;
created_at: Date;
at_key: string;
}>(
`SELECT id, sender_agent_id, recipient_agent_id, sender_name,
recipient_name, content, delivered, created_at,
`SELECT m.id, m.sender_agent_id, m.recipient_agent_id, m.sender_name,
m.recipient_name,
EXISTS (
SELECT 1
FROM agents child
WHERE child.id IN (m.sender_agent_id, m.recipient_agent_id)
AND child.parent_agent_id = $1
) AS involves_child_agent,
m.content, m.delivered, m.created_at,
${AT_KEY_SQL} AS at_key
FROM agent_messages
WHERE (sender_agent_id = $1 OR recipient_agent_id = $1) ${clause}
ORDER BY created_at DESC, id DESC
FROM agent_messages m
WHERE (m.sender_agent_id = $1 OR m.recipient_agent_id = $1) ${clause}
ORDER BY m.created_at DESC, m.id DESC
LIMIT $${params.length}`,
params
);
Expand All @@ -246,6 +254,7 @@ async function listAgentMessageEntries(
senderName: row.sender_name,
recipientAgentId: row.recipient_agent_id,
recipientName: row.recipient_name,
involvesChildAgent: row.involves_child_agent,
content: row.content,
delivered: row.delivered,
at: row.created_at.toISOString(),
Expand Down
8 changes: 4 additions & 4 deletions apps/server/src/jobs/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,9 @@ export class JobService {
model: agentConfig.model ?? undefined,
cwd: job.directory,
agentArgs: buildAgentArgs(agentType, prompt, agentConfig.fullAccess),
// The CLI receives the prompt through agentArgs; the Chat feed's
// launch post needs it stated separately.
launchContext: { prompt },
// The CLI receives generated job-run scaffolding through agentArgs;
// Chat shows only the user-authored job prompt.
launchContext: { prompt: resolvedPrompt },
fullAccess: agentConfig.fullAccess,
...templateWorktreeConfig(agentConfig),
jobRunId: run.id,
Expand Down Expand Up @@ -471,7 +471,7 @@ export class JobService {
prompt,
agentConfig.fullAccess
),
launchContext: { prompt },
launchContext: { prompt: resolvedPrompt },
fullAccess: agentConfig.fullAccess,
...templateWorktreeConfig(agentConfig),
jobRunId: run.id,
Expand Down
8 changes: 7 additions & 1 deletion apps/server/src/routes/agents/crud-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,13 @@ export async function registerAgentCrudRoutes(
!isTerminalAgent && typeof body.initialPrompt === "string"
? body.initialPrompt.trim() || undefined
: undefined,
launchContext: { links: !isTerminalAgent ? (startupLinks ?? []) : [] },
launchContext: {
prompt:
!isTerminalAgent && typeof body.initialPrompt === "string"
? body.initialPrompt.trim() || undefined
: undefined,
links: !isTerminalAgent ? (startupLinks ?? []) : [],
},
initialPins: !isTerminalAgent ? startupPins : [],
initialFiles: !isTerminalAgent ? startupFiles : [],
});
Expand Down
5 changes: 3 additions & 2 deletions apps/server/src/server/mcp-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,8 +646,9 @@ async function handleLaunchAgent(
launchedByAgentId: agentId,
cliSessionId,
initialPrompt: buildLaunchedAgentInitialPrompt(agentId, prompt, child),
// The feed shows the prompt as the launcher wrote it, not the header.
launchContext: { prompt },
// The feed shows the prompt as the launcher wrote it, not the rendered
// template instructions or the launch header.
launchContext: { prompt: input.prompt },
templateId: input.templateId,
});

Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/templates/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,9 @@ export class TemplateService {
model: resolvedModel,
cwd,
initialPrompt: finalPrompt,
launchContext: { links: !isTerminal ? (input.startupLinks ?? []) : [] },
launchContext: {
links: !isTerminal ? (input.startupLinks ?? []) : [],
},
fullAccess: !isTerminal && template.fullAccess,
...(isTerminal
? { useWorktree: false }
Expand Down
35 changes: 32 additions & 3 deletions apps/server/test/chat-feed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,18 @@ let store: ChatStore;

const A = "agt_feed_a";
const OTHER = "agt_feed_other";
const ARCHIVED_CHILD = "agt_feed_archived_child";

beforeAll(async () => {
pool = await setupTestDb();
await runTestMigrations();
store = new ChatStore(pool);
await pool.query(
`INSERT INTO agents (id, name, cwd, status)
VALUES ($1, 'Feed A', '/tmp', 'running'), ($2, 'Other', '/tmp', 'running')`,
[A, OTHER]
`INSERT INTO agents (id, name, cwd, status, parent_agent_id, deleted_at)
VALUES ($1, 'Feed A', '/tmp', 'running', NULL, NULL),
($2, 'Other', '/tmp', 'running', NULL, NULL),
($3, 'Archived child', '/tmp', 'stopped', $1, NOW())`,
[A, OTHER, ARCHIVED_CHILD]
);
});

Expand Down Expand Up @@ -154,6 +157,32 @@ describe("composeChatFeed", () => {
expect(page3.entries.map((e) => e.type)).toEqual(["status"]);
});

it("marks both directions of archived child conversations", async () => {
await pool.query(
`INSERT INTO agent_messages
(id, sender_agent_id, recipient_agent_id, sender_name, recipient_name,
content, delivered, created_at)
VALUES (gen_random_uuid(), $2, $1, 'Archived child', 'Feed A', 'in', true, $3),
(gen_random_uuid(), $1, $2, 'Feed A', 'Archived child', 'out', true, $4),
(gen_random_uuid(), $5, $1, 'Other', 'Feed A', 'peer', true, $4)`,
[A, ARCHIVED_CHILD, at(10), at(11), OTHER]
);

const messages = (await composeChatFeed(store, A)).entries.filter(
(entry) => entry.type === "agent_message"
);
expect(messages).toHaveLength(3);
expect(
messages
.map((entry) => [entry.content, entry.involvesChildAgent])
.sort(([left], [right]) => String(left).localeCompare(String(right)))
).toEqual([
["in", true],
["out", true],
["peer", false],
]);
});

it("never drops or repeats rows that share a timestamp across sources", async () => {
// Nine rows at the same instant (three per source kind plus chat) with
// microsecond-identical created_at, paged two at a time.
Expand Down
2 changes: 1 addition & 1 deletion apps/server/test/chat-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,7 @@ describe("chat routes with a deliverable terminal", () => {
`--- DISPATCH CHAT (id: ${body.message.id}) ---`,
"please do X",
"--- END DISPATCH CHAT ---",
`The user is reading the Chat tab, not this terminal — they only see what you post with dispatch_chat_post. Reply there (replyTo: "${body.message.id}"); terminal output alone will not reach them.`,
`The user only sees Chat — reply with dispatch_chat_post (replyTo: "${body.message.id}").`,
].join("\n")
);
expect(published).toEqual([
Expand Down
2 changes: 1 addition & 1 deletion apps/server/test/chat-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,7 +713,7 @@ describe("ChatService user workflows", () => {
"- pin: URL — http://x",
"- link: https://example.com/spec — Spec",
"--- END DISPATCH CHAT ---",
`The user is reading the Chat tab, not this terminal — they only see what you post with dispatch_chat_post. Reply there (replyTo: "${res.message.id}"); terminal output alone will not reach them.`,
`The user only sees Chat — reply with dispatch_chat_post (replyTo: "${res.message.id}").`,
].join("\n")
);
});
Expand Down
36 changes: 34 additions & 2 deletions apps/server/test/db/agent-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,10 @@ describe("AgentManager", () => {
cwd: "/tmp",
useWorktree: false,
initialPrompt: "Build the widget",
launchContext: { links: ["https://example.com/spec"] },
launchContext: {
prompt: "Build the widget",
links: ["https://example.com/spec"],
},
initialPins: [
{
label: "example.com",
Expand Down Expand Up @@ -353,6 +356,7 @@ describe("AgentManager", () => {
useWorktree: false,
launchedByAgentId: parent.id,
initialPrompt: "Go",
launchContext: { prompt: "Go" },
});
expect((await launchPosts(independent.id))[0]).toMatchObject({
launched_by_agent_id: parent.id,
Expand All @@ -373,6 +377,7 @@ describe("AgentManager", () => {
useWorktree: false,
parentAgentId: parent.id,
initialPrompt: "Pretend I am the parent",
launchContext: { prompt: "Pretend I am the parent" },
});
expect((await launchPosts(child.id))[0]).toMatchObject({
author_kind: "user",
Expand Down Expand Up @@ -456,7 +461,10 @@ describe("AgentManager", () => {
type: "claude",
useWorktree: false,
initialPrompt: "Build the widget",
launchContext: { links: ["https://example.com/spec"] },
launchContext: {
prompt: "Build the widget",
links: ["https://example.com/spec"],
},
initialFiles: [
{
fileName: "brief.md",
Expand Down Expand Up @@ -501,6 +509,7 @@ describe("AgentManager", () => {
type: "claude",
useWorktree: false,
initialPrompt: "Build the widget",
launchContext: { prompt: "Build the widget" },
});
expect(await launchPosts(agent.id)).toHaveLength(1);
const setupScript = await readFile(
Expand All @@ -511,6 +520,27 @@ describe("AgentManager", () => {
expect(setupScript).toContain("Build the widget");
});

it("keeps generated startup prompts out of Chat while retaining Chat guidance", async () => {
await withChatSurface(async () => {
const agent = await manager.createAgent({
cwd: "/tmp",
type: "claude",
useWorktree: false,
initialPrompt: "Internal launch instructions",
});
expect(await launchPosts(agent.id)).toEqual([]);
const setupScript = await readFile(
`/tmp/dispatch_setup_${agent.id}.sh`,
"utf-8"
);
expect(setupScript).toContain("Internal launch instructions");
expect(setupScript).toContain(
"The user is reading Chat, not Console."
);
expect(setupScript).not.toContain("--- DISPATCH CHAT");
});
});

it("starts the runtime without waiting on a write that never resolves", async () => {
const warn = vi.fn();
const stuckManager = new AgentManager(
Expand Down Expand Up @@ -586,6 +616,7 @@ describe("AgentManager", () => {
type: "claude",
useWorktree: false,
initialPrompt: "Go",
launchContext: { prompt: "Go" },
});
expect(warn).toHaveBeenCalledWith(
expect.objectContaining({
Expand Down Expand Up @@ -703,6 +734,7 @@ describe("AgentManager", () => {
type: "claude",
useWorktree: false,
initialPrompt: "Go",
launchContext: { prompt: "Go" },
});
expect(warn).toHaveBeenCalledWith(
expect.objectContaining({ agentId: agent.id }),
Expand Down
9 changes: 3 additions & 6 deletions apps/server/test/jobs-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,12 +326,9 @@ describe("POST /api/v1/jobs/run", () => {
origin: "launch",
attachments: [],
});
// The post carries the same prompt the CLI receives through agentArgs:
// the job header plus the job's own prompt.
expect(posts.rows[0].text).toContain(`Run ID: ${body.runId}`);
expect(posts.rows[0].text).toContain(
"\nJob prompt:\nSweep the stale branches"
);
// Chat shows only the user-authored prompt, not generated job lifecycle
// scaffolding (which still reaches the CLI through agentArgs).
expect(posts.rows[0].text).toBe("Sweep the stale branches");
await ctx.pool.query(
`UPDATE job_runs SET status = 'completed' WHERE id = $1`,
[body.runId]
Expand Down
2 changes: 1 addition & 1 deletion apps/server/test/jobs/continuation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,7 @@ describe("continuation jobs", () => {
useWorktree: true,
baseBranch: "main",
launchContext: {
prompt: expect.stringContaining("Continuation chain: chain-1"),
prompt: "Continue the work.",
},
// No stored branch name, so each iteration gets its own generated branch
// rather than colliding with the previous iteration's.
Expand Down
4 changes: 2 additions & 2 deletions apps/server/test/jobs/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,9 +319,9 @@ describe("JobService", () => {
expect.objectContaining({
jobRunId: result.runId,
name: `job-Rename_Test-${result.runId.slice(0, 8)}`,
// The Chat launch post gets the job prompt the CLI receives.
// The Chat launch post gets only the user-authored job prompt.
launchContext: {
prompt: expect.stringContaining(`Run ID: ${result.runId}`),
prompt: "Test prompt",
},
})
);
Expand Down
5 changes: 4 additions & 1 deletion apps/server/test/mcp-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1316,7 +1316,7 @@ describe("createMcpHandlers", () => {
"--- DISPATCH CHAT (id: post-1) ---",
created.initialPrompt,
"--- END DISPATCH CHAT ---",
'The user is reading the Chat tab, not this terminal — they only see what you post with dispatch_chat_post. Reply there (replyTo: "post-1"); terminal output alone will not reach them.',
'The user only sees Chat — reply with dispatch_chat_post (replyTo: "post-1").',
].join("\n")
);
expect(turn).toContain('You were launched by Dispatch agent "agt_test1"');
Expand Down Expand Up @@ -1561,6 +1561,9 @@ describe("createMcpHandlers", () => {
expect(initialPrompt).toContain("Build this idea:");
expect(initialPrompt).toContain("fix the launch bug");
expect(initialPrompt).not.toContain("{{D:");
expect(
deps.agentManager.createAgent.mock.calls[0][0].launchContext
).toEqual({ prompt: "fix the launch bug" });
});

it("appends the caller's prompt to the template's prompt", async () => {
Expand Down
5 changes: 3 additions & 2 deletions apps/server/test/template-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,15 +307,16 @@ describe("POST /api/v1/templates/:id/launch", () => {
value: "https://example.com/spec",
}),
]);
// ...but the launch post shows the URL once, as a link attachment.
// ...but the launch post shows the URL once, as a link attachment, while
// template runtime instructions stay out of Chat.
const posts = await ctx.pool.query(
`SELECT text, origin, attachments FROM agent_chat_messages
WHERE agent_id = $1`,
[agent.id]
);
expect(posts.rows).toHaveLength(1);
expect(posts.rows[0]).toMatchObject({
text: "Read the spec",
text: "",
origin: "launch",
attachments: [{ type: "link", url: "https://example.com/spec" }],
});
Expand Down
Loading
Loading