From ac4fae367753b4e711acecb9a1efc8c06bd6b44f Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 3 Sep 2026 22:17:56 -0600 Subject: [PATCH 1/5] feat(chat): wrap the launch prompt in the Chat envelope (round 5, WIP) Co-Authored-By: Claude Fable 5.1 --- apps/server/src/agents/manager.ts | 209 +++++++++++++----- .../server/src/agents/tmux/command-builder.ts | 83 ++++++- apps/server/src/chat/service.ts | 109 ++++++--- apps/server/src/chat/store.ts | 8 +- apps/server/test/chat-service.test.ts | 44 ++++ apps/server/test/db/agent-manager.test.ts | 121 +++++++++- apps/server/test/mcp-handlers.test.ts | 35 +++ apps/server/test/tmux-command-builder.test.ts | 156 +++++++++++++ 8 files changed, 659 insertions(+), 106 deletions(-) diff --git a/apps/server/src/agents/manager.ts b/apps/server/src/agents/manager.ts index cbd032373..330a73259 100644 --- a/apps/server/src/agents/manager.ts +++ b/apps/server/src/agents/manager.ts @@ -59,9 +59,9 @@ import { type SeededMedia, seedInitialMedia } from "./media-seed.js"; import { type Reconciler, createReconciler } from "./reconciler.js"; import { type AgentRuntime, createAgentRuntime } from "./runtime.js"; import { + type ChatLaunchPost, buildAgentCommand, buildLaunchGuidance, - buildStartupPrompt, } from "./tmux/command-builder.js"; import { agentIdFromSessionName, @@ -226,18 +226,25 @@ export type DiffStatsRefresherHandle = { /** * Records what an agent was launched with as the first post of its Chat - * feed. Implemented by `ChatService.recordLaunchContext`; narrowed so the - * manager never imports the chat module. + * feed. Implemented by `ChatService.prepareLaunchContext`; narrowed so the + * manager never imports the chat module. `prepare` resolves the post (its + * attachments and their envelope lines) without writing it, so the CLI's + * first turn can be built from the same id and lines; `record` then writes + * it. Null means the launch carries no context and nothing is recorded. */ export type LaunchContextRecorder = { - recordLaunchContext: (input: { + prepareLaunchContext: (input: { + id: string; agentId: string; text?: string; files?: Array<{ mediaId: number }>; links?: string[]; pins?: Array<{ id: string; type: string; value: string }>; launchedByAgentId?: string | null; - }) => Promise; + }) => Promise<{ + attachmentLines: string[]; + record: () => Promise; + } | null>; }; /** The two settings-backed switches the launch guidance is built from. */ @@ -254,6 +261,31 @@ async function readLaunchGuidanceFlags( /** Upper bound on how long a launch waits for its Chat launch post. */ export const LAUNCH_CONTEXT_WRITE_TIMEOUT_MS = 5_000; +/** + * Upper bound on resolving the launch post before the CLI command is built. + * Unlike the write, this one is on the launch's critical path — the first + * turn's envelope needs the post's id and attachment lines — so a slow or + * hung Chat read gives up and the agent launches with the plain startup + * prompt and no post, rather than the two disagreeing. + */ +export const LAUNCH_CONTEXT_RESOLVE_TIMEOUT_MS = 5_000; + +/** Sentinel for a promise that outlived its bound. */ +const TIMED_OUT = Symbol("timed-out"); + +/** Resolve with the promise's value, or `TIMED_OUT` after `ms`. */ +function withTimeout( + promise: Promise, + ms: number +): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(TIMED_OUT), ms); + timer.unref?.(); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + export class AgentManager { private readonly pool: Pool; private readonly logger: FastifyBaseLogger; @@ -440,15 +472,29 @@ export class AgentManager { throw error; } } - const startupPrompt = buildStartupPrompt( - input.initialPrompt, - p.initialPins, - initialMedia + // The launch post's id is fixed here so the CLI's first turn can carry + // it (the Chat envelope, built by the command builder when the chat + // surface is on). Resolving the post is a small read and happens before + // the command is built; the write itself runs alongside the runtime + // launch, never blocks it, and is only waited on (bounded) so the feed + // has the post when the caller gets the agent back. + const launchPostId = randomUUID(); + const preparedLaunchContext = await this.prepareLaunchContext( + p, + input, + initialMedia, + launchPostId + ); + const chatLaunchPost: ChatLaunchPost | null = preparedLaunchContext + ? { + messageId: launchPostId, + attachmentLines: preparedLaunchContext.attachmentLines, + } + : null; + const launchContextWrite = this.recordLaunchContext( + p.id, + preparedLaunchContext ); - // Best-effort: the write runs alongside the runtime launch, never blocks - // it, and is only waited on (bounded) so the feed has the post when the - // caller gets the agent back. - const launchContextWrite = this.recordLaunchContext(p, input, initialMedia); if (this.config.agentRuntime === "inert") { await this.launchInertAgent({ @@ -480,7 +526,10 @@ export class AgentManager { normalizedBaseBranch: p.normalizedBaseBranch, worktreePathOverride: p.worktreePathOverride, cliSessionId: p.cliSessionId, - startupPrompt, + initialPrompt: input.initialPrompt, + initialPins: p.initialPins, + initialMedia, + chatLaunchPost, persona: input.persona, jobRunId: input.jobRunId, templateId: input.templateId, @@ -493,67 +542,100 @@ export class AgentManager { } /** - * Put the launch context at the top of the Chat feed, so the feed opens - * with it. Best-effort and detached from the launch: the write is started - * alongside the runtime launch, a failure is logged and never fails the - * launch (the prompt still reaches the CLI), and the returned promise - * settles after at most `LAUNCH_CONTEXT_WRITE_TIMEOUT_MS` so a slow or hung - * Chat write cannot hold the agent start. A write that outlives the wait - * still lands (and announces itself) whenever it completes. + * Resolve the launch context for the Chat feed's launch post: the + * attachments and the envelope lines the CLI's first turn lists. A + * launch with nothing to record, a terminal agent, or no recorder gives + * null. A resolution failure is logged and treated as no context — the + * launch continues with the plain startup prompt and no post, so the + * pane and the feed never disagree. * * Only an explicit `launchedByAgentId` attributes the post to an agent — * the agent-authenticated launch paths set it. `parentAgentId` is never * used for attribution: the create route accepts it from the request body. */ - private recordLaunchContext( + private async prepareLaunchContext( p: PreparedCreateInputs, input: CreateAgentInput, - initialMedia: Array<{ mediaId: number }> - ): Promise { + initialMedia: Array<{ mediaId: number }>, + launchPostId: string + ): Promise<{ + attachmentLines: string[]; + record: () => Promise; + } | null> { const recorder = this.launchContextRecorder; - if (!recorder || p.type === "terminal") return Promise.resolve(); + if (!recorder || p.type === "terminal") return null; + const resolve = recorder + .prepareLaunchContext({ + id: launchPostId, + agentId: p.id, + text: input.launchContext?.prompt ?? input.initialPrompt, + files: initialMedia.map((media) => ({ mediaId: media.mediaId })), + links: input.launchContext?.links ?? [], + pins: p.initialPins.map((pin) => ({ + id: pin.id ?? "", + type: pin.type, + value: pin.value, + })), + launchedByAgentId: input.launchedByAgentId ?? null, + }) + .catch((error: unknown) => { + this.logger.warn( + { err: error, agentId: p.id }, + "chat: failed to resolve launch context; launching without it" + ); + return null; + }); + const prepared = await withTimeout( + resolve, + LAUNCH_CONTEXT_RESOLVE_TIMEOUT_MS + ); + if (prepared === TIMED_OUT) { + this.logger.warn( + { agentId: p.id, timeoutMs: LAUNCH_CONTEXT_RESOLVE_TIMEOUT_MS }, + "chat: launch context did not resolve in time; launching without it" + ); + return null; + } + return prepared; + } + + /** + * Put the prepared launch context at the top of the Chat feed, so the + * feed opens with it. Best-effort and detached from the launch: the write + * is started alongside the runtime launch, a failure is logged and never + * fails the launch (the prompt still reaches the CLI), and the returned + * promise settles after at most `LAUNCH_CONTEXT_WRITE_TIMEOUT_MS` so a + * slow or hung Chat write cannot hold the agent start. A write that + * outlives the wait still lands (and announces itself) whenever it + * completes. + */ + private recordLaunchContext( + agentId: string, + prepared: { record: () => Promise } | null + ): Promise { + if (!prepared) return Promise.resolve(); const write = Promise.resolve() - .then(() => - recorder.recordLaunchContext({ - agentId: p.id, - text: input.launchContext?.prompt ?? input.initialPrompt, - files: initialMedia.map((media) => ({ mediaId: media.mediaId })), - links: input.launchContext?.links ?? [], - pins: p.initialPins.map((pin) => ({ - id: pin.id ?? "", - type: pin.type, - value: pin.value, - })), - launchedByAgentId: input.launchedByAgentId ?? null, - }) - ) + .then(() => prepared.record()) .then( () => "written" as const, (error: unknown) => { this.logger.warn( - { err: error, agentId: p.id }, + { err: error, agentId }, "chat: failed to record launch context" ); return "failed" as const; } ); - let timer: ReturnType | undefined; - const timeout = new Promise<"timeout">((resolve) => { - timer = setTimeout( - () => resolve("timeout"), - LAUNCH_CONTEXT_WRITE_TIMEOUT_MS - ); - timer.unref?.(); - }); - return Promise.race([write, timeout]).then((outcome) => { - clearTimeout(timer); - if (outcome === "timeout") { - this.logger.warn( - { agentId: p.id, timeoutMs: LAUNCH_CONTEXT_WRITE_TIMEOUT_MS }, - "chat: launch context write still pending; launch continues without it" - ); + return withTimeout(write, LAUNCH_CONTEXT_WRITE_TIMEOUT_MS).then( + (outcome) => { + if (outcome === TIMED_OUT) { + this.logger.warn( + { agentId, timeoutMs: LAUNCH_CONTEXT_WRITE_TIMEOUT_MS }, + "chat: launch context write still pending; launch continues without it" + ); + } } - }); + ); } private async prepareCreateInputs( @@ -783,7 +865,10 @@ export class AgentManager { normalizedBaseBranch: string | undefined; worktreePathOverride: string | undefined; cliSessionId: string | null; - startupPrompt: string | undefined; + initialPrompt: string | undefined; + initialPins: AgentPin[]; + initialMedia: SeededMedia[]; + chatLaunchPost: ChatLaunchPost | null; persona: string | undefined; jobRunId: string | undefined; templateId: string | undefined; @@ -803,7 +888,10 @@ export class AgentManager { useWorktree, createNewBranch, cliSessionId, - startupPrompt, + initialPrompt, + initialPins, + initialMedia, + chatLaunchPost, } = opts; try { @@ -837,7 +925,10 @@ export class AgentManager { autoReview: !opts.persona && !opts.jobRunId && opts.autoReview, trimmedGuidance, chatSurface, - initialPrompt: startupPrompt, + initialPrompt, + initialPins, + initialMedia, + chatLaunchPost, personalityPrompt: personality?.prompt ?? null, model, } diff --git a/apps/server/src/agents/tmux/command-builder.ts b/apps/server/src/agents/tmux/command-builder.ts index 1086caf37..0b6266a07 100644 --- a/apps/server/src/agents/tmux/command-builder.ts +++ b/apps/server/src/agents/tmux/command-builder.ts @@ -5,6 +5,7 @@ import { createJobMcpToken, createReleaseUpdateToken, } from "../../auth.js"; +import { buildChatEnvelope } from "../../chat/envelope.js"; import type { AppConfig } from "../../config.js"; import { PLUGIN_AGENT_TYPES } from "../../shared/agent-types.js"; import { buildCursorDispatchToolGuidance } from "../../shared/mcp/cursor-dispatch-guidance.js"; @@ -87,6 +88,60 @@ function stripModelArgs(args: string[]): string[] { return filtered; } +/** A startup file as `seedInitialMedia` reports it, for the first turn. */ +export type StartupMedia = { + fileName: string; + displayName: string; + source: string; + description: string | null; +}; + +/** + * The Chat feed's launch post, fixed before the CLI command is built so the + * first turn can carry its id. `attachmentLines` are the recorder's own + * envelope lines for the startup files, links and pins — one source, so the + * pane and the post agree. + */ +export type ChatLaunchPost = { + messageId: string; + attachmentLines: string[]; +}; + +export type StartupTurnInput = { + initialPrompt?: string; + initialPins?: AgentPin[]; + initialMedia?: StartupMedia[]; + chatLaunchPost?: ChatLaunchPost | null; +}; + +/** + * The agent's first user turn. With the chat surface on and a launch post + * recorded, the prompt is wrapped in the same `--- DISPATCH CHAT ---` + * envelope a Chat message is injected with (id = the launch post, the + * attachments listed the same way, the trailer pointing the agent at + * dispatch_chat_post), so an agent started from the Chat tab knows to answer + * there. Job runs never wrap (their prompt is a system-prompt append), and + * with the flag off — or nothing recorded — the plain startup prompt is used. + */ +export function buildStartupTurn( + startup: StartupTurnInput, + opts: { chatSurface?: boolean; jobRunId?: string } +): string | undefined { + const post = startup.chatLaunchPost; + if (opts.chatSurface && !opts.jobRunId && post) { + return buildChatEnvelope( + post.messageId, + startup.initialPrompt?.trim() ?? "", + post.attachmentLines + ); + } + return buildStartupPrompt( + startup.initialPrompt, + startup.initialPins ?? [], + startup.initialMedia ?? [] + ); +} + /** * Compose the first user-message-style prompt handed to the agent on * launch — formats `initialPrompt`, `initialPins`, and `initialMedia` into @@ -98,12 +153,7 @@ function stripModelArgs(args: string[]): string[] { export function buildStartupPrompt( initialPrompt: string | undefined, initialPins: AgentPin[], - initialMedia: Array<{ - fileName: string; - displayName: string; - source: string; - description: string | null; - }> + initialMedia: StartupMedia[] ): string | undefined { const trimmedPrompt = initialPrompt?.trim() || ""; if (initialPins.length === 0 && initialMedia.length === 0) { @@ -341,7 +391,14 @@ type BuildAgentCommandOptions = { autoReview?: boolean; trimmedGuidance?: boolean; chatSurface?: boolean; + /** + * Raw first-turn inputs; `buildStartupTurn` composes them (envelope or + * plain startup prompt) using `chatSurface` and `jobRunId`. + */ initialPrompt?: string; + initialPins?: AgentPin[]; + initialMedia?: StartupMedia[]; + chatLaunchPost?: ChatLaunchPost | null; personalityPrompt?: string | null; model?: string; }; @@ -362,12 +419,24 @@ export function buildAgentCommand( autoReview, trimmedGuidance, chatSurface, - initialPrompt, + initialPrompt: rawInitialPrompt, + initialPins, + initialMedia, + chatLaunchPost, personalityPrompt, model, }: BuildAgentCommandOptions = {} ): string { const agentId = agentIdFromSessionName(sessionName); + const initialPrompt = buildStartupTurn( + { + initialPrompt: rawInitialPrompt, + initialPins, + initialMedia, + chatLaunchPost, + }, + { chatSurface, jobRunId } + ); const launchGuidance = buildLaunchGuidance(agentId, { agentType: type, jobRunId, diff --git a/apps/server/src/chat/service.ts b/apps/server/src/chat/service.ts index e7c87d89e..367d688ac 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import path from "node:path"; import type { Pool } from "pg"; @@ -117,6 +118,11 @@ export class ChatConflictError extends ChatServiceError { * not shown twice). */ export type ChatLaunchContextInput = { + /** + * The post's id, when the caller needs it before the write — the launch + * path fixes it so the CLI's first turn can carry it in its envelope. + */ + id?: string; agentId: string; /** The initial prompt as the person (or launching agent) wrote it. */ text?: string; @@ -127,6 +133,16 @@ export type ChatLaunchContextInput = { launchedByAgentId?: string | null; }; +/** A launch post resolved but not yet written; see `prepareLaunchContext`. */ +export type PreparedLaunchContext = { + /** The post's id, known before the write. */ + id: string; + /** One envelope line per resolved attachment, in the post's order. */ + attachmentLines: string[]; + /** Write the post and announce the feed change. */ + record: () => Promise; +}; + export type ChatAnswerInput = { value: string; /** Only consulted for a freeform answer; an option's label wins otherwise. */ @@ -422,20 +438,17 @@ export class ChatService { } /** - * Record the context an agent was launched with as one user post at the - * top of its feed: the initial prompt as text, plus a file attachment per - * startup file, a link per startup link, and a pin per initial pin. The - * prompt reaches the CLI through the normal launch path, so the post is - * `delivered: true` and nothing is injected. A launch with no context at - * all records nothing and returns null. - * - * When another agent did the launching, `launchedByAgentId` is stored so - * the web can attribute the post to it; the row stays a user post so the - * unread and question counts (agent posts only) are unaffected. + * Resolve a launch's context without writing it: the attachments (file + * by mediaId, pin verified on the agent, link as given) and the envelope + * lines that describe them — the same lines `sendUserMessage` injects, so + * the pane and the post agree — plus a `record` that performs the write. + * The launch path builds the CLI's first turn from `id` and + * `attachmentLines` while `record` runs alongside the runtime start. + * A launch with no context at all resolves to null and records nothing. */ - async recordLaunchContext( + async prepareLaunchContext( input: ChatLaunchContextInput - ): Promise { + ): Promise { const text = input.text ?? ""; const links = (input.links ?? []).filter((url) => url.trim().length > 0); const linkSet = new Set(links); @@ -464,28 +477,56 @@ export class ChatService { // rather than refuse the launch, and let the sidebar show the rest. inputs.length = CHAT_ATTACHMENTS_MAX; } - const attachments = - inputs.length > 0 - ? await this.resolveAttachmentsFor( - await this.requireAgent(input.agentId), - inputs - ) - : []; - const message = await this.store.insert({ - agentId: input.agentId, - authorKind: "user", - kind: "reply", - text: - text.length > CHAT_MESSAGE_MAX_CHARS - ? text.slice(0, CHAT_MESSAGE_MAX_CHARS) - : text, - attachments, - delivered: true, - origin: "launch", - launchedByAgentId: input.launchedByAgentId ?? null, - }); - this.publishChanged(input.agentId); - return message; + let attachments: ChatAttachment[] = []; + let attachmentLines: string[] = []; + if (inputs.length > 0) { + const agent = await this.requireAgent(input.agentId); + attachments = await this.resolveAttachmentsFor(agent, inputs); + attachmentLines = this.describeAttachments(agent, attachments); + } + const id = input.id ?? randomUUID(); + return { + id, + attachmentLines, + record: async () => { + const message = await this.store.insert({ + id, + agentId: input.agentId, + authorKind: "user", + kind: "reply", + text: + text.length > CHAT_MESSAGE_MAX_CHARS + ? text.slice(0, CHAT_MESSAGE_MAX_CHARS) + : text, + attachments, + delivered: true, + origin: "launch", + launchedByAgentId: input.launchedByAgentId ?? null, + }); + this.publishChanged(input.agentId); + return message; + }, + }; + } + + /** + * Record the context an agent was launched with as one user post at the + * top of its feed: the initial prompt as text, plus a file attachment per + * startup file, a link per startup link, and a pin per initial pin. The + * prompt reaches the CLI through the normal launch path (wrapped in the + * Chat envelope when the chat surface is on), so the post is + * `delivered: true` and nothing is injected. A launch with no context at + * all records nothing and returns null. + * + * When another agent did the launching, `launchedByAgentId` is stored so + * the web can attribute the post to it; the row stays a user post so the + * unread and question counts (agent posts only) are unaffected. + */ + async recordLaunchContext( + input: ChatLaunchContextInput + ): Promise { + const prepared = await this.prepareLaunchContext(input); + return prepared ? prepared.record() : null; } // ------------------------------------------------------------------------- diff --git a/apps/server/src/chat/store.ts b/apps/server/src/chat/store.ts index af6a8e567..818e0b3a9 100644 --- a/apps/server/src/chat/store.ts +++ b/apps/server/src/chat/store.ts @@ -20,6 +20,12 @@ export type Queryable = { }; export type InsertChatMessageInput = { + /** + * Explicit row id. Launch posts fix it before the write so the pane + * envelope built alongside can carry it; everything else lets the store + * mint one. + */ + id?: string; agentId: string; authorKind: ChatAuthorKind; kind?: ChatMessageKind; @@ -107,7 +113,7 @@ export class ChatStore { VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9, $10, $11) RETURNING *`, [ - randomUUID(), + input.id ?? randomUUID(), input.agentId, input.authorKind, input.kind ?? "reply", diff --git a/apps/server/test/chat-service.test.ts b/apps/server/test/chat-service.test.ts index bb35ae726..970e2d9cb 100644 --- a/apps/server/test/chat-service.test.ts +++ b/apps/server/test/chat-service.test.ts @@ -146,6 +146,50 @@ describe("ChatService.recordLaunchContext", () => { }); }); +describe("ChatService.prepareLaunchContext", () => { + it("resolves the post's id and envelope lines before anything is written", async () => { + const result = await pool.query<{ id: number }>( + `INSERT INTO media (agent_id, file_name, source, size_bytes) + VALUES ($1, 'brief-2026.md', 'user', 300) RETURNING id`, + [A] + ); + const mediaId = result.rows[0].id; + const prepared = await service.prepareLaunchContext({ + id: "8a4f9e60-1111-4222-8333-444455556666", + agentId: A, + text: "Build the widget", + files: [{ mediaId }], + links: ["https://example.com/spec"], + pins: [{ id: "pin_1", type: "string", value: "DIS-42" }], + }); + expect(prepared?.id).toBe("8a4f9e60-1111-4222-8333-444455556666"); + // The same lines sendUserMessage injects, so pane and post agree. + expect(prepared?.attachmentLines).toEqual([ + "- file: /media-root/agt_chat_svc/brief-2026.md (text/markdown, 300 B)", + "- link: https://example.com/spec", + "- pin: URL — http://x", + ]); + // Nothing written and nothing announced until record() runs. + expect(published).toEqual([]); + const rows = await pool.query( + "SELECT id FROM agent_chat_messages WHERE agent_id = $1", + [A] + ); + expect(rows.rows).toHaveLength(0); + + const message = await prepared!.record(); + expect(message.id).toBe("8a4f9e60-1111-4222-8333-444455556666"); + expect(message).toMatchObject({ origin: "launch", delivered: true }); + expect(published).toEqual([{ type: "chat.changed", agentId: A }]); + }); + + it("returns null for a launch with no context", async () => { + expect( + await service.prepareLaunchContext({ agentId: A, text: " " }) + ).toBeNull(); + }); +}); + describe("ChatService.post", () => { it("persists an agent message and publishes chat.changed", async () => { const message = await service.post(A, { text: "hello", kind: "update" }); diff --git a/apps/server/test/db/agent-manager.test.ts b/apps/server/test/db/agent-manager.test.ts index 218062bb8..696c99549 100644 --- a/apps/server/test/db/agent-manager.test.ts +++ b/apps/server/test/db/agent-manager.test.ts @@ -38,8 +38,12 @@ vi.mock("../../src/shared/lib/run-command.js", () => ({ })); // We need to dynamically import AgentManager AFTER the mock is in place -const { AgentManager, AgentError, LAUNCH_CONTEXT_WRITE_TIMEOUT_MS } = - await import("../../src/agents/manager.js"); +const { + AgentManager, + AgentError, + LAUNCH_CONTEXT_RESOLVE_TIMEOUT_MS, + LAUNCH_CONTEXT_WRITE_TIMEOUT_MS, +} = await import("../../src/agents/manager.js"); const { ChatService } = await import("../../src/chat/service.js"); const { createAgentMcpToken } = await import("../../src/auth.js"); const execFileAsync = promisify(execFile); @@ -239,6 +243,7 @@ describe("AgentManager", () => { [agentId] ); return result.rows as Array<{ + id: string; author_kind: string; kind: string; text: string; @@ -376,7 +381,74 @@ describe("AgentManager", () => { }); }); - it("starts the runtime without waiting on a recorder that never resolves", async () => { + it("hands the CLI the same post id and attachment lines when the chat surface is on", async () => { + await pool.query( + `INSERT INTO settings (key, value, updated_at) + VALUES ('chat_surface_enabled', 'true', NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value` + ); + try { + const agent = await manager.createAgent({ + cwd: "/tmp", + type: "claude", + useWorktree: false, + initialPrompt: "Build the widget", + launchContext: { links: ["https://example.com/spec"] }, + initialFiles: [ + { + fileName: "brief.md", + originalName: "brief.md", + buffer: Buffer.from("# brief"), + source: "text", + }, + ], + }); + const posts = await launchPosts(agent.id); + expect(posts).toHaveLength(1); + const setupScript = await readFile( + `/tmp/dispatch_setup_${agent.id}.sh`, + "utf-8" + ); + // The envelope the CLI receives names the post that was written, + // so the agent's reply threads onto the launch post in the feed. + expect(setupScript).toContain( + `--- DISPATCH CHAT (id: ${posts[0].id}) ---` + ); + expect(setupScript).toContain(`replyTo: "${posts[0].id}"`); + expect(setupScript).toContain("Build the widget"); + // Attachment lines come from the recorder, so pane and post agree. + const media = await pool.query<{ file_name: string }>( + `SELECT file_name FROM media WHERE agent_id = $1`, + [agent.id] + ); + expect(setupScript).toContain( + `- file: ${path.join(testConfig.mediaRoot, agent.id, media.rows[0].file_name)} (text/markdown, 7 B)` + ); + expect(setupScript).toContain("- link: https://example.com/spec"); + } finally { + await pool.query( + `DELETE FROM settings WHERE key = 'chat_surface_enabled'` + ); + } + }); + + it("leaves the first turn unwrapped when the chat surface is off", async () => { + const agent = await manager.createAgent({ + cwd: "/tmp", + type: "claude", + useWorktree: false, + initialPrompt: "Build the widget", + }); + expect(await launchPosts(agent.id)).toHaveLength(1); + const setupScript = await readFile( + `/tmp/dispatch_setup_${agent.id}.sh`, + "utf-8" + ); + expect(setupScript).not.toContain("DISPATCH CHAT"); + expect(setupScript).toContain("Build the widget"); + }); + + it("starts the runtime without waiting on a write that never resolves", async () => { const warn = vi.fn(); const stuckManager = new AgentManager( pool, @@ -388,9 +460,12 @@ describe("AgentManager", () => { // (DB now() vs Date.now() skew made the old created_at filter flaky). let recordedAgentId: string | null = null; stuckManager.attachLaunchContextRecorder({ - recordLaunchContext: (input) => { + prepareLaunchContext: async (input) => { recordedAgentId = input.agentId; - return new Promise(() => {}); + return { + attachmentLines: [], + record: () => new Promise(() => {}), + }; }, }); @@ -431,6 +506,42 @@ describe("AgentManager", () => { ); expect(await launchPosts(agent.id)).toEqual([]); }, 15_000); + + it("launches unwrapped when the post never resolves", async () => { + // Resolving the post is on the critical path (the first turn needs + // its id), so a hung read gives up: no post, no envelope, and the + // launch still happens. + const warn = vi.fn(); + const stuckManager = new AgentManager( + pool, + { ...noopLogger, warn, child: () => noopLogger } as never, + testConfig + ); + stuckManager.attachLaunchContextRecorder({ + prepareLaunchContext: () => new Promise(() => {}), + }); + + const agent = await stuckManager.createAgent({ + cwd: "/tmp", + type: "claude", + useWorktree: false, + initialPrompt: "Go", + }); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: agent.id, + timeoutMs: LAUNCH_CONTEXT_RESOLVE_TIMEOUT_MS, + }), + expect.stringContaining("did not resolve in time") + ); + expect(await launchPosts(agent.id)).toEqual([]); + const setupScript = await readFile( + `/tmp/dispatch_setup_${agent.id}.sh`, + "utf-8" + ); + expect(setupScript).not.toContain("DISPATCH CHAT"); + expect(setupScript).toContain("Go"); + }, 15_000); }); it("de-duplicates initialPins by case-insensitive label (last write wins)", async () => { diff --git a/apps/server/test/mcp-handlers.test.ts b/apps/server/test/mcp-handlers.test.ts index e1a0a0006..8a15e1d2a 100644 --- a/apps/server/test/mcp-handlers.test.ts +++ b/apps/server/test/mcp-handlers.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildStartupTurn } from "../src/agents/tmux/command-builder.js"; + vi.mock("../src/shared/git/worktree.js", () => ({ resolveHeadSha: vi.fn(async () => "abc123def456"), })); @@ -1291,6 +1293,39 @@ describe("createMcpHandlers", () => { ); }); + it("wraps the child's whole prompt, launch header included, in the Chat envelope", async () => { + await handlers.launchAgent("agt_test1", { + name: "worker", + prompt: "Investigate the flaky test.", + }); + + // The child's first turn is built from what createAgent was handed: + // with the chat surface on it is wrapped whole, so the launch header + // the CLI needs stays inside the envelope, while the feed post keeps + // the prompt as the launcher wrote it. + const created = deps.agentManager.createAgent.mock.calls[0][0]; + const turn = buildStartupTurn( + { + initialPrompt: created.initialPrompt, + chatLaunchPost: { messageId: "post-1", attachmentLines: [] }, + }, + { chatSurface: true } + ); + expect(turn).toBe( + [ + "--- 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.', + ].join("\n") + ); + expect(turn).toContain('You were launched by Dispatch agent "agt_test1"'); + expect(turn).toContain("Investigate the flaky test."); + expect(created.launchContext).toEqual({ + prompt: "Investigate the flaky test.", + }); + }); + it("includes the launching agent id in the child initial prompt", async () => { await handlers.launchAgent("agt_test1", { name: "worker", diff --git a/apps/server/test/tmux-command-builder.test.ts b/apps/server/test/tmux-command-builder.test.ts index a51c4b086..6f75ff2db 100644 --- a/apps/server/test/tmux-command-builder.test.ts +++ b/apps/server/test/tmux-command-builder.test.ts @@ -5,6 +5,7 @@ import { buildAgentCommand, buildLaunchGuidance, buildStartupPrompt, + buildStartupTurn, normalizeAgentArgsForType, } from "../src/agents/tmux/command-builder.js"; import { dispatchMcpUrl } from "../src/agents/tmux/mcp-url.js"; @@ -164,6 +165,77 @@ describe("buildStartupPrompt", () => { }); }); +describe("buildStartupTurn — the Chat launch envelope", () => { + const POST_ID = "11111111-2222-3333-4444-555555555555"; + const startup = { + initialPrompt: "Build the widget", + initialPins: [ + { label: "Ticket", value: "DIS-42", type: "string" as const }, + ], + initialMedia: [ + { + fileName: "brief-2026.md", + displayName: "brief.md", + source: "text", + description: null, + }, + ], + chatLaunchPost: { + messageId: POST_ID, + attachmentLines: [ + "- file: /media/agt_x/brief-2026.md (text/markdown, 300 B)", + "- pin: Ticket — DIS-42", + ], + }, + }; + + it("wraps the prompt with the launch post id and the recorder's attachment lines", () => { + expect(buildStartupTurn(startup, { chatSurface: true })).toBe( + [ + `--- DISPATCH CHAT (id: ${POST_ID}) ---`, + "Build the widget", + "", + "Attachments:", + "- file: /media/agt_x/brief-2026.md (text/markdown, 300 B)", + "- pin: Ticket — DIS-42", + "--- 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_ID}"); terminal output alone will not reach them.`, + ].join("\n") + ); + }); + + it("falls back to the plain startup prompt with the flag off", () => { + const turn = buildStartupTurn(startup, { chatSurface: false }); + expect(turn).not.toContain("DISPATCH CHAT"); + expect(turn).toBe( + buildStartupPrompt( + startup.initialPrompt, + startup.initialPins, + startup.initialMedia + ) + ); + }); + + it("falls back when nothing was recorded, even with the flag on", () => { + const turn = buildStartupTurn( + { ...startup, chatLaunchPost: null }, + { chatSurface: true } + ); + expect(turn).not.toContain("DISPATCH CHAT"); + expect(turn).toContain("Build the widget"); + }); + + it("never wraps a job run — its prompt is a system-prompt append", () => { + expect( + buildStartupTurn(startup, { chatSurface: true, jobRunId: "run_abc" }) + ).not.toContain("DISPATCH CHAT"); + }); + + it("returns undefined for a launch with no context at all", () => { + expect(buildStartupTurn({}, { chatSurface: true })).toBeUndefined(); + }); +}); + describe("buildAgentCommand", () => { it("for terminal type, drops the user into an interactive login shell (no CLI args)", () => { const cmd = buildAgentCommand( @@ -554,6 +626,90 @@ describe("buildAgentCommand", () => { expect(flagCount).toBe(1); }); + it("for claude with the chat surface on, the first turn is the DISPATCH CHAT envelope", () => { + const cmd = buildAgentCommand( + baseConfig, + "claude", + "standard", + [], + "/tmp/media", + SESSION, + false, + { + chatSurface: true, + initialPrompt: "Build the widget", + chatLaunchPost: { + messageId: "abc-123", + attachmentLines: ["- link: https://example.com/spec"], + }, + } + ); + expect(cmd).toContain("-- '--- DISPATCH CHAT (id: abc-123) ---"); + expect(cmd).toContain("Build the widget"); + expect(cmd).toContain("- link: https://example.com/spec"); + expect(cmd).toContain('replyTo: "abc-123"'); + }); + + it("for claude with the chat surface off, the first turn is the plain startup prompt", () => { + const cmd = buildAgentCommand( + baseConfig, + "claude", + "standard", + [], + "/tmp/media", + SESSION, + false, + { + chatSurface: false, + initialPrompt: "Build the widget", + chatLaunchPost: { + messageId: "abc-123", + attachmentLines: ["- link: https://example.com/spec"], + }, + } + ); + expect(cmd).not.toContain("DISPATCH CHAT"); + expect(cmd).toContain("-- 'Build the widget'"); + }); + + it("for codex, the envelope is appended after the guidance as the first turn", () => { + const cmd = buildAgentCommand( + baseConfig, + "codex", + "standard", + [], + "/tmp/media", + SESSION, + false, + { + chatSurface: true, + initialPrompt: "Build the widget", + chatLaunchPost: { messageId: "abc-123", attachmentLines: [] }, + } + ); + expect(cmd).toContain("--- DISPATCH CHAT (id: abc-123) ---"); + expect(cmd).toContain("Dispatch startup rules"); + }); + + it("for terminal type, no prompt is passed at all", () => { + const cmd = buildAgentCommand( + baseConfig, + "terminal", + "standard", + [], + "/tmp/media", + SESSION, + false, + { + chatSurface: true, + initialPrompt: "Build the widget", + chatLaunchPost: { messageId: "abc-123", attachmentLines: [] }, + } + ); + expect(cmd).not.toContain("DISPATCH CHAT"); + expect(cmd).not.toContain("Build the widget"); + }); + it("for codex, personalityPrompt is folded into the startup prompt", () => { const cmd = buildAgentCommand( baseConfig, From 1270e000ff61239e36908ba0acab32ed1daf4c13 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 3 Sep 2026 22:28:00 -0600 Subject: [PATCH 2/5] feat(chat): launch prompt as a Chat message; no Chat for terminal sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 5 of the chat surface. With the flag on and a launch post recorded, the CLI's first user turn is that post wrapped in the same DISPATCH CHAT envelope a Chat message is injected with — its id, its attachment lines, and the trailer pointing the agent at dispatch_chat_post — so an agent started from the Chat tab answers there instead of in the terminal. The post id is minted before the command is built and handed to ChatService.prepareLaunchContext, which resolves the attachments and hands back both the envelope lines it will store and the write itself, so the pane and the feed describe the same attachments and the envelope names the row that was actually written. Resolving is bounded (it is on the launch's critical path): on timeout the agent launches unwrapped with no post. Unwrapped otherwise too — flag off, no context, job runs, terminal agents. Web: a terminal session has no CLI to chat with, so agentSupportsChat is ANDed with the flag once in agents-view and the narrowed value drives the tab label, the pane, the split-pane normaliser and the /chat redirect. Co-Authored-By: Claude Fable 5.1 --- .../src/components/app/agents-view-header.tsx | 4 ++ apps/web/src/components/app/agents-view.tsx | 27 ++++++---- .../app/center-pane-tab-bar.test.tsx | 4 ++ .../components/app/center-pane-tab-bar.tsx | 5 +- .../hooks/use-agents-view-routing.test.tsx | 14 +++++ apps/web/src/hooks/use-agents-view-routing.ts | 18 +++++-- apps/web/src/hooks/use-center-pane-layout.ts | 5 +- apps/web/src/hooks/use-split-pane.test.ts | 2 +- apps/web/src/hooks/use-split-pane.ts | 13 +++-- apps/web/src/lib/center-tabs.test.ts | 22 ++++++++ apps/web/src/lib/center-tabs.ts | 11 ++++ docs/03-api-spec.md | 2 +- docs/chat-surface-plan.md | 51 +++++++++++++++++++ 13 files changed, 158 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/app/agents-view-header.tsx b/apps/web/src/components/app/agents-view-header.tsx index 34788360d..486844708 100644 --- a/apps/web/src/components/app/agents-view-header.tsx +++ b/apps/web/src/components/app/agents-view-header.tsx @@ -29,6 +29,8 @@ type AgentsViewHeaderProps = { * highlighted for a frame before flipping to Chat/Console. */ centerTabResolved?: boolean; + /** The chat surface as it applies to this agent (see `agentSupportsChat`). */ + chatEnabled: boolean; chatUnreadCount?: number; isSplit: boolean; splitState: SplitPaneState; @@ -54,6 +56,7 @@ export function AgentsViewHeader({ focusedDiffStats, activeTab, centerTabResolved = true, + chatEnabled, chatUnreadCount = 0, isSplit, splitState, @@ -134,6 +137,7 @@ export function AgentsViewHeader({ isSplit={isSplit} splitState={splitState} isMobile={isMobile} + chatEnabled={chatEnabled} chatUnreadCount={chatUnreadCount} /> ) : null} diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index da3d35b82..21d14abf1 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -43,7 +43,7 @@ import { import { GlassSidebar } from "@/components/ui/glass-sidebar"; import { uploadAgentMedia } from "@/lib/media-upload"; import { type AgentType } from "@/lib/agent-types"; -import { terminalHostTab } from "@/lib/center-tabs"; +import { agentSupportsChat, terminalHostTab } from "@/lib/center-tabs"; import { type IdeType } from "@/lib/ide-types"; import { type ThemeId } from "@/hooks/use-theme"; import { cn } from "@/lib/utils"; @@ -135,8 +135,9 @@ export function AgentsView({ routeAgentId, agentsLoaded, validatedSelectedAgentId, + routeAgentType: selectedAgent?.type ?? null, }); - const { enabled: chatEnabled } = useChatSurfaceEnabled(); + const { enabled: chatSurfaceEnabled } = useChatSurfaceEnabled(); // The terminal DOM stays mounted (hidden) across tab switches so tmux // output keeps flowing into it, but it must not mount at all until the // route has settled on a tab: on a fresh navigation the Console would @@ -144,12 +145,6 @@ export function AgentsView({ // it stays armed — a later agent switch must not tear xterm down. const [terminalArmed, setTerminalArmed] = useState(false); if (centerTabResolved && !terminalArmed) setTerminalArmed(true); - const activeTab: CenterTab = changesMatch - ? "changes" - : whiteboardMatch - ? "whiteboard" - : terminalHostTab(chatEnabled); - const [createOpen, setCreateOpen] = useState(false); const [requestedCreateType, setRequestedCreateType] = useState(null); @@ -236,6 +231,16 @@ export function AgentsView({ const focusedAgent = focusedAgentId ? (agents.find((agent) => agent.id === focusedAgentId) ?? null) : null; + // The flag as it applies to the agent in focus: a terminal session has no + // CLI to chat with, so it keeps the plain Terminal tab and Console-only + // pane however the flag is set. + const chatEnabled = + chatSurfaceEnabled && agentSupportsChat(focusedAgent?.type); + const activeTab: CenterTab = changesMatch + ? "changes" + : whiteboardMatch + ? "whiteboard" + : terminalHostTab(chatEnabled); const whiteboardAgentDrew = useAtomValue( whiteboardAgentDrewAtomFamily(focusedAgentId ?? "") @@ -261,6 +266,7 @@ export function AgentsView({ focusedAgentId, isMobile, activeTab, + chatEnabled, }); // The focused agent's direct children, whose pins and media the sidebar @@ -308,7 +314,9 @@ export function AgentsView({ } = useMedia(focusedAgentId, mediaPanelOpen, focusedSubAgents); const unreadMessageCount = useAgentUnreadCount(focusedAgentId); - const chatUnreadCount = useAgentChatUnread(focusedAgentId).unread; + // No Chat view, no unread badge: a terminal session's feed is never read. + const chatUnreadCountRaw = useAgentChatUnread(focusedAgentId).unread; + const chatUnreadCount = chatEnabled ? chatUnreadCountRaw : 0; const markMessagesRead = useMarkMessagesRead(focusedAgentId); // Closed-sidebar external signal for #2019: reuses the same surfaces query @@ -724,6 +732,7 @@ export function AgentsView({ focusedDiffStats={focusedDiffStats} activeTab={activeTab} centerTabResolved={centerTabResolved} + chatEnabled={chatEnabled} chatUnreadCount={chatUnreadCount} isSplit={isSplit} splitState={splitState} diff --git a/apps/web/src/components/app/center-pane-tab-bar.test.tsx b/apps/web/src/components/app/center-pane-tab-bar.test.tsx index e93ee46aa..7696424fc 100644 --- a/apps/web/src/components/app/center-pane-tab-bar.test.tsx +++ b/apps/web/src/components/app/center-pane-tab-bar.test.tsx @@ -30,6 +30,7 @@ describe("CenterPaneTabBar", () => { render( { render( { render( { render( ([splitState.left, splitState.right]) : new Set(); diff --git a/apps/web/src/hooks/use-agents-view-routing.test.tsx b/apps/web/src/hooks/use-agents-view-routing.test.tsx index c13dd0a85..1a41525bd 100644 --- a/apps/web/src/hooks/use-agents-view-routing.test.tsx +++ b/apps/web/src/hooks/use-agents-view-routing.test.tsx @@ -21,6 +21,7 @@ type RoutingProps = { routeAgentId: string | undefined; agentsLoaded: boolean; validatedSelectedAgentId: string | null; + routeAgentType?: string | null; }; // The hook is exercised against a real MemoryRouter so the useMatch patterns @@ -236,6 +237,19 @@ describe("useAgentsViewRouting", () => { expect(result.current.centerTabResolved).toBe(true); }); + it("sends an old /chat link for a terminal session to its Console", () => { + // A terminal session has no CLI to chat with: the route still + // collapses onto the agent, but the view is left as it was. + chatFlag.enabled = true; + getDefaultStore().set(agentPaneViewAtomFamily("agt_1"), "console"); + const { pathname } = renderRouting("/agents/agt_1/chat", { + ...loaded, + routeAgentType: "terminal", + }); + expect(pathname()).toBe("/agents/agt_1"); + expect(viewOf("agt_1")).toBe("console"); + }); + it("waits for the flag to load before redirecting", () => { chatFlag.enabled = false; chatFlag.loaded = false; diff --git a/apps/web/src/hooks/use-agents-view-routing.ts b/apps/web/src/hooks/use-agents-view-routing.ts index 9c34b43b6..52e34bf9d 100644 --- a/apps/web/src/hooks/use-agents-view-routing.ts +++ b/apps/web/src/hooks/use-agents-view-routing.ts @@ -4,19 +4,29 @@ import { useLocation, useMatch, useNavigate } from "react-router-dom"; import { useChatSurfaceEnabled } from "@/hooks/use-chat-surface-enabled"; import { agentRoute } from "@/lib/agent-routes"; -import { type CenterTab, centerTabRoute } from "@/lib/center-tabs"; +import { + type CenterTab, + agentSupportsChat, + centerTabRoute, +} from "@/lib/center-tabs"; import { agentPaneViewAtomFamily } from "@/lib/store"; type UseAgentsViewRoutingOptions = { routeAgentId: string | undefined; agentsLoaded: boolean; validatedSelectedAgentId: string | null; + /** + * The type of the agent the route names, once known. A terminal session + * has no Chat view, so an old /chat link lands on its Console. + */ + routeAgentType?: string | null; }; export function useAgentsViewRouting({ routeAgentId, agentsLoaded, validatedSelectedAgentId, + routeAgentType, }: UseAgentsViewRoutingOptions) { const navigate = useNavigate(); const location = useLocation(); @@ -49,7 +59,8 @@ export function useAgentsViewRouting({ // `/agents/:id/chat` was the Chat tab's own route in round 1. The Chat // view now lives inside the Agent tab at the bare agent route, so an old // link (or bookmark) lands there with the view set to Chat. With the flag - // off the route has nothing to render and falls back to the terminal. + // off — or for a terminal session, which has no Chat view — the route has + // nothing to render and falls back to the terminal. // // The redirect is decided during render (`pendingTabRedirect`) and only // performed in the effect below, so the view can hold the center pane on @@ -62,7 +73,7 @@ export function useAgentsViewRouting({ if (!agentsLoaded || !validatedSelectedAgentId) return; if (!chatFlagLoaded) return; if (!chatMatch) return; - if (chatEnabled) { + if (chatEnabled && agentSupportsChat(routeAgentType)) { store.set(agentPaneViewAtomFamily(routeAgentId), "chat"); } navigate( @@ -77,6 +88,7 @@ export function useAgentsViewRouting({ location.search, navigate, routeAgentId, + routeAgentType, store, validatedSelectedAgentId, ]); diff --git a/apps/web/src/hooks/use-center-pane-layout.ts b/apps/web/src/hooks/use-center-pane-layout.ts index cdab5897b..1b448a65a 100644 --- a/apps/web/src/hooks/use-center-pane-layout.ts +++ b/apps/web/src/hooks/use-center-pane-layout.ts @@ -15,6 +15,8 @@ type UseCenterPaneLayoutArgs = { isMobile: boolean; /** The tab currently shown full-width; the drop target for a dragged tab. */ activeTab: CenterTab; + /** The chat surface as it applies to this agent (see `agentSupportsChat`). */ + chatEnabled: boolean; }; /** @@ -27,11 +29,12 @@ export function useCenterPaneLayout({ focusedAgentId, isMobile, activeTab, + chatEnabled, }: UseCenterPaneLayoutArgs) { const [isDraggingTab, setIsDraggingTab] = useState(false); const { splitState, isSplit, exitSplit, updateSizes, handleTabDrop } = - useSplitPane(focusedAgentId, isMobile); + useSplitPane(focusedAgentId, isMobile, chatEnabled); const splitLeftRef = useRef(null); const splitButtonRef = useRef(null); diff --git a/apps/web/src/hooks/use-split-pane.test.ts b/apps/web/src/hooks/use-split-pane.test.ts index 81d1b1666..5cd8c8ad4 100644 --- a/apps/web/src/hooks/use-split-pane.test.ts +++ b/apps/web/src/hooks/use-split-pane.test.ts @@ -124,7 +124,7 @@ describe("useSplitPane persistence", () => { function renderPane(agentId: string, chatEnabled = true) { H.chatEnabled = chatEnabled; const store = createStore(); - return renderHook(() => useSplitPane(agentId, false), { + return renderHook(() => useSplitPane(agentId, false, chatEnabled), { wrapper: ({ children }: { children: ReactNode }) => createElement(Provider, { store }, children), }); diff --git a/apps/web/src/hooks/use-split-pane.ts b/apps/web/src/hooks/use-split-pane.ts index cc8d54eb7..ba2911d26 100644 --- a/apps/web/src/hooks/use-split-pane.ts +++ b/apps/web/src/hooks/use-split-pane.ts @@ -1,7 +1,6 @@ import { useCallback, useMemo } from "react"; import { useAtom } from "jotai"; -import { useChatSurfaceEnabled } from "@/hooks/use-chat-surface-enabled"; import { type LegacyCenterTab, terminalHostTab } from "@/lib/center-tabs"; import { type CenterTab, @@ -46,8 +45,16 @@ export function normalizeSplitPaneState( }; } -export function useSplitPane(agentId: string | null, isMobile: boolean) { - const { enabled: chatEnabled } = useChatSurfaceEnabled(); +/** + * `chatEnabled` is the flag as it applies to *this* agent (off for a + * terminal session), so a split saved with the Chat pane folds back onto + * the Console for one. + */ +export function useSplitPane( + agentId: string | null, + isMobile: boolean, + chatEnabled: boolean +) { const atom = agentId ? splitPaneStateAtomFamily(agentId) : inactiveSplitPaneStateAtom; diff --git a/apps/web/src/lib/center-tabs.test.ts b/apps/web/src/lib/center-tabs.test.ts index 16677470d..b10d247ac 100644 --- a/apps/web/src/lib/center-tabs.test.ts +++ b/apps/web/src/lib/center-tabs.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { CENTER_TABS, + agentSupportsChat, centerTabLabel, centerTabRoute, centerTabs, @@ -58,3 +59,24 @@ describe("center tabs registry", () => { expect(isCenterTab(3)).toBe(false); }); }); + +describe("agentSupportsChat", () => { + it("is false only for a terminal session", () => { + // A terminal session is a shell: nothing posts to its feed and nobody + // reads one, so it keeps the plain Terminal tab whatever the flag says. + expect(agentSupportsChat("claude")).toBe(true); + expect(agentSupportsChat("codex")).toBe(true); + expect(agentSupportsChat(null)).toBe(true); + expect(agentSupportsChat(undefined)).toBe(true); + expect(agentSupportsChat("terminal")).toBe(false); + }); + + it("folds a terminal session's split back onto the Console", () => { + // The caller ANDs the flag with this, so the split-pane normaliser sees + // the flag as off for a terminal session. + const narrowed = (flagOn: boolean, agentType: string) => + flagOn && agentSupportsChat(agentType); + expect(terminalHostTab(narrowed(true, "terminal"))).toBe("terminal"); + expect(terminalHostTab(narrowed(true, "claude"))).toBe("agent"); + }); +}); diff --git a/apps/web/src/lib/center-tabs.ts b/apps/web/src/lib/center-tabs.ts index 1b42c8984..cc6244a64 100644 --- a/apps/web/src/lib/center-tabs.ts +++ b/apps/web/src/lib/center-tabs.ts @@ -83,6 +83,17 @@ export function centerTabRoute(agentId: string, tab: CenterTab): string { return centerTabDef(tab).route(agentId); } +/** + * Whether an agent can be chatted with at all. A terminal session is a + * shell, not a CLI agent: there is nothing to post to the feed and nothing + * to read it, so it keeps the plain Terminal tab whatever the flag says. + */ +export function agentSupportsChat( + agentType: string | null | undefined +): boolean { + return agentType !== "terminal"; +} + /** The id the terminal-hosting tab goes by under this flag value. */ export function terminalHostTab(chatEnabled: boolean): CenterTab { return chatEnabled ? "agent" : "terminal"; diff --git a/docs/03-api-spec.md b/docs/03-api-spec.md index a970d2cbd..5ed961e6c 100644 --- a/docs/03-api-spec.md +++ b/docs/03-api-spec.md @@ -233,7 +233,7 @@ The Chat tab feed (`docs/chat-surface-plan.md`). Wire types live in `packages/sh The feed is composed at read time from `agent_chat_messages`, `agent_events`, `agent_messages` (both directions), and `media`. `limit` defaults to 200 (max 500); the response carries `hasMore`, `unreadCount`, and an opaque `nextCursor` — pass it back as `cursor` to page backwards (it encodes the boundary row's exact timestamp, source, and id, so rows sharing a timestamp are never dropped or repeated). The two write routes return `409` when the agent has no tmux session (same rule as `inject-text`); they respond as soon as the message is queued, with `delivered: null` (pending) until the pane write settles, at which point the row flips to `true`/`false` and `chat.changed` fires. `answer` resolves the chosen option from the stored question (unknown values are `400` unless `allowFreeform`) and returns `409` once a question has been answered; its optional `attachments` take the same shape and cap (`CHAT_ATTACHMENTS_MAX`, 20) as `messages`, are resolved the same way (`400` for an unknown `mediaId` or pin), and are stored on the reply message and listed in its envelope. `read` accepts an optional `upTo` message id (`400` if present but not a UUID). Every write publishes the `chat.changed` SSE event. Agents post to the feed with the `dispatch_chat_post` / `dispatch_chat_update` MCP tools; `file` attachments name a `fileName` returned by `dispatch_share_file`. -Launching an agent with context records one launch post in its feed: a user message with `origin: "launch"`, `delivered: true`, the initial prompt as `text`, and attachments for each startup file (`file`), startup link (`link`), and initial pin (`pin`; a url pin made from one of the links is not repeated). Both `origin` and `launchedByAgentId` are absent on every other message. When another agent created the agent (`dispatch_launch_agent`), `launchedByAgentId` names it and the web attributes the post to that agent; the MCP path stores the prompt as the launcher wrote it, without the launch header the CLI receives. A launch with no prompt, files, links, or pins, and any terminal agent, records nothing. +Launching an agent with context records one launch post in its feed: a user message with `origin: "launch"`, `delivered: true`, the initial prompt as `text`, and attachments for each startup file (`file`), startup link (`link`), and initial pin (`pin`; a url pin made from one of the links is not repeated). Both `origin` and `launchedByAgentId` are absent on every other message. When another agent created the agent (`dispatch_launch_agent`), `launchedByAgentId` names it and the web attributes the post to that agent; the MCP path stores the prompt as the launcher wrote it, without the launch header the CLI receives. A launch with no prompt, files, links, or pins, and any terminal agent, records nothing. With the `chat_surface_enabled` flag on, the CLI's first user turn is that post wrapped in the same `--- DISPATCH CHAT (id: …) ---` envelope a Chat message is injected with — the post's id, its attachment lines, and the trailer pointing the agent at `dispatch_chat_post` — so an agent launched from the Chat tab replies there. With the flag off, on a job run (whose prompt is a system-prompt append), or with no launch context, the first turn is the plain startup prompt as before. User messages take up to 20 `attachments` (`ChatUserAttachmentInput`): `{ type: "file", mediaId }` for a file uploaded first via `POST /agents/:id/media`, `{ type: "pin", pinId }` for one of the agent's pins, or `{ type: "link", url, title? }`. The body is zod-validated (`400` on shape errors, unknown media or pins); `text` may be blank when at least one attachment is present. The stored message carries the resolved `ChatAttachment[]`, and the injected envelope lists each one after the text (`- file: (, )`, `- pin: