diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 1218c81a..c01fb014 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -635,7 +635,23 @@ export function setupIpcHandlers(agentRuntime: AgentRuntime) { return { sessions: await agentRuntime.sessions.listSessions(args ?? undefined) }; }, 'sessions:sendMessage': async (_event, args) => { - const handle = await agentRuntime.sessions.sendMessage(args.sessionId, args.messages, args.options); + // Rowboat code sessions pin the coding agent's agent/cwd/policy from the + // session meta (the single source of truth), so EVERY send drives + // code_agent_run in the right place regardless of the composer chip. + let options = args.options; + const codeMeta = await container + .resolve('codeSessionsRepo') + .get(args.sessionId) + .catch(() => null); + if (codeMeta) { + options = { + ...options, + codeMode: codeMeta.agent, + codeCwd: codeMeta.cwd, + codePolicy: codeMeta.policy, + }; + } + const handle = await agentRuntime.sessions.sendMessage(args.sessionId, args.messages, options); return { turnId: handle.id }; }, 'sessions:getHistory': async (_event, args) => { diff --git a/apps/x/apps/renderer/src/lib/agent-turn-view.test.ts b/apps/x/apps/renderer/src/lib/agent-turn-view.test.ts index da2e9d95..717d8c48 100644 --- a/apps/x/apps/renderer/src/lib/agent-turn-view.test.ts +++ b/apps/x/apps/renderer/src/lib/agent-turn-view.test.ts @@ -157,6 +157,17 @@ describe('applyOverlay', () => { overlay = applyOverlay(overlay, { type: 'tool-output', toolCallId: 'tc1', chunk: 'line1\n' }) overlay = applyOverlay(overlay, { type: 'tool-output', toolCallId: 'tc1', chunk: 'line2' }) overlay = applyOverlay(overlay, { type: 'tool-result', toolCallId: 'tc1' }) - expect(overlay).toEqual({ text: 'Hello', toolOutput: { tc1: 'line1\nline2' } }) + expect(overlay).toEqual({ text: 'Hello', toolOutput: { tc1: 'line1\nline2' }, codeRunEvents: {}, codePermission: {} }) + }) + + it('accumulates code-run events + pending permission per tool call, clears permission on result', () => { + let overlay = emptyOverlay() + overlay = applyOverlay(overlay, { type: 'code-run-event', toolCallId: 'tc1', event: { type: 'plan', entries: [] } }) + overlay = applyOverlay(overlay, { type: 'code-run-permission-request', toolCallId: 'tc1', requestId: 'r1', ask: { title: 'Run?', isRead: false } }) + expect(overlay.codeRunEvents.tc1).toHaveLength(1) + expect(overlay.codePermission.tc1).toEqual({ requestId: 'r1', ask: { title: 'Run?', isRead: false } }) + overlay = applyOverlay(overlay, { type: 'tool-result', toolCallId: 'tc1' }) + expect(overlay.codePermission.tc1).toBeNull() + expect(overlay.codeRunEvents.tc1).toHaveLength(1) }) }) diff --git a/apps/x/apps/renderer/src/lib/agent-turn-view.ts b/apps/x/apps/renderer/src/lib/agent-turn-view.ts index 4f69c706..f8db1114 100644 --- a/apps/x/apps/renderer/src/lib/agent-turn-view.ts +++ b/apps/x/apps/renderer/src/lib/agent-turn-view.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import type { AgentLoopTurn, TurnEvent } from '@x/shared/src/agent-turn.js' import { deriveToolCallState, deriveTurnStatus, toolCallParts } from '@x/shared/src/agent-turn.js' import type { Message, ToolCallPart } from '@x/shared/src/message.js' +import type { CodeRunEvent, PermissionAsk } from '@x/shared/src/code-mode.js' import type { ChatMessage, ConversationItem, ToolCall } from './chat-conversation.js' // Pure derivation of the chat view model from a turn. A turn snapshot → @@ -165,9 +166,18 @@ export function turnStatus(turn: Turn): ReturnType { export type LiveOverlay = { text: string toolOutput: Record + // code_agent_run (rowboat) live activity, keyed by the owning tool call id. + // These are live-only: a completed turn collapses to the tool's final result. + codeRunEvents: Record + codePermission: Record } -export const emptyOverlay = (): LiveOverlay => ({ text: '', toolOutput: {} }) +export const emptyOverlay = (): LiveOverlay => ({ + text: '', + toolOutput: {}, + codeRunEvents: {}, + codePermission: {}, +}) // Accumulate a live event onto the overlay. A fresh state snapshot supersedes // the overlay (the committed transcript now includes what was streaming), so @@ -184,6 +194,31 @@ export function applyOverlay(overlay: LiveOverlay, event: TurnEvent): LiveOverla [event.toolCallId]: (overlay.toolOutput[event.toolCallId] ?? '') + event.chunk, }, } + case 'code-run-event': + return { + ...overlay, + codeRunEvents: { + ...overlay.codeRunEvents, + [event.toolCallId]: [...(overlay.codeRunEvents[event.toolCallId] ?? []), event.event], + }, + } + case 'code-run-permission-request': + return { + ...overlay, + codePermission: { + ...overlay.codePermission, + [event.toolCallId]: { requestId: event.requestId, ask: event.ask }, + }, + } + case 'tool-result': + // The ACP call resolved — drop any lingering code permission card for it. + if (overlay.codePermission[event.toolCallId]) { + return { + ...overlay, + codePermission: { ...overlay.codePermission, [event.toolCallId]: null }, + } + } + return overlay default: return overlay } diff --git a/apps/x/apps/renderer/src/lib/session-chat-state.test.ts b/apps/x/apps/renderer/src/lib/session-chat-state.test.ts index f96870f5..ac77b57b 100644 --- a/apps/x/apps/renderer/src/lib/session-chat-state.test.ts +++ b/apps/x/apps/renderer/src/lib/session-chat-state.test.ts @@ -54,7 +54,7 @@ describe('turnToChatState', () => { { role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'tc1', toolName: 'executeCommand', arguments: {} }] }, ], }), - { text: '', toolOutput: { tc1: 'line1\nline2' } }, + { ...emptyOverlay(), toolOutput: { tc1: 'line1\nline2' } }, ) const tool = state.conversation.find(isToolCall) expect(tool?.streamingOutput).toBe('line1\nline2') diff --git a/apps/x/apps/renderer/src/lib/session-chat-state.ts b/apps/x/apps/renderer/src/lib/session-chat-state.ts index c3be8712..ffe583c3 100644 --- a/apps/x/apps/renderer/src/lib/session-chat-state.ts +++ b/apps/x/apps/renderer/src/lib/session-chat-state.ts @@ -45,11 +45,18 @@ export function turnToChatState(turn: Turn, overlay: LiveOverlay): SessionChatSt const status = deriveTurnStatus(turn) const parts = toolCallParts(turn) - const conversation = buildConversation(turn).map((item) => - isToolCall(item) && overlay.toolOutput[item.id] - ? { ...item, streamingOutput: overlay.toolOutput[item.id] } - : item, - ) + const conversation = buildConversation(turn).map((item) => { + if (!isToolCall(item)) return item + const codeRunEvents = overlay.codeRunEvents[item.id] + const codePermission = overlay.codePermission[item.id] + if (!overlay.toolOutput[item.id] && !codeRunEvents && codePermission === undefined) return item + return { + ...item, + ...(overlay.toolOutput[item.id] ? { streamingOutput: overlay.toolOutput[item.id] } : {}), + ...(codeRunEvents ? { codeRunEvents } : {}), + ...(codePermission !== undefined ? { pendingCodePermission: codePermission } : {}), + } + }) const allPermissionRequests = new Map>() for (const { toolCall, request } of pendingPermissions(turn)) { diff --git a/apps/x/packages/core/src/agent-loop/agent-loop.ts b/apps/x/packages/core/src/agent-loop/agent-loop.ts index 2815bd53..1d17755f 100644 --- a/apps/x/packages/core/src/agent-loop/agent-loop.ts +++ b/apps/x/packages/core/src/agent-loop/agent-loop.ts @@ -387,6 +387,8 @@ export class AgentLoopImpl implements AgentLoop { turnId, agentId: turn.agentId, codeMode: turn.composeContext?.codeMode ?? null, + codeCwd: turn.composeContext?.codeCwd ?? null, + codePolicy: turn.composeContext?.codePolicy ?? null, signal, emit: (event) => this.emit(stream, meta, event), }) diff --git a/apps/x/packages/core/src/agent-loop/tool-runner.ts b/apps/x/packages/core/src/agent-loop/tool-runner.ts index baf95873..57ca857d 100644 --- a/apps/x/packages/core/src/agent-loop/tool-runner.ts +++ b/apps/x/packages/core/src/agent-loop/tool-runner.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { CodeMode, ToolCallPart } from "@x/shared/dist/message.js"; +import { ApprovalPolicy } from "@x/shared/dist/code-mode.js"; import type { ToolDefinition, TurnEvent } from "./types.js"; export type ToolRunResult = @@ -15,6 +16,10 @@ export type ToolRunContext = { // The turn's code-mode chip (null = off). The code_agent_run tool honors // this over the model's argument so toggling the chip switches agents. codeMode: z.infer | null; + // Rowboat code sessions pin the coding agent's cwd + approval policy; + // code_agent_run honors these over the model's args. null = unset. + codeCwd: string | null; + codePolicy: z.infer | null; signal: AbortSignal; // Forward a live event onto the turn's stream while the tool runs (e.g. a // `tool-output` chunk). Best-effort and never persisted — drop it and the diff --git a/apps/x/packages/core/src/agent-runtime/real-tool-runner.ts b/apps/x/packages/core/src/agent-runtime/real-tool-runner.ts index 7feda1cd..ea83d532 100644 --- a/apps/x/packages/core/src/agent-runtime/real-tool-runner.ts +++ b/apps/x/packages/core/src/agent-runtime/real-tool-runner.ts @@ -68,12 +68,27 @@ export class RealToolRunner implements ToolRunner { toolCallId: event.toolCallId, chunk: event.output, }); + } else if (event.type === "code-run-event") { + // code_agent_run's rich ACP activity (rowboat mode): nest + // it under the owning tool call so the UI renders it. + ctx.emit({ + type: "code-run-event", + toolCallId: event.toolCallId, + event: event.event, + }); + } else if (event.type === "code-run-permission-request") { + ctx.emit({ + type: "code-run-permission-request", + toolCallId: event.toolCallId, + requestId: event.requestId, + ask: event.ask, + }); } - // Other run events (code-run-*) are deferred — the channel - // exists; deeper plumbing lands with code_agent_run. return Promise.resolve(); }, codeMode: ctx.codeMode, + codeCwd: ctx.codeCwd, + codePolicy: ctx.codePolicy, }; // A thrown error propagates: the loop catches it (re-checking abort) // and records it as an error ToolMessage, never a turn error. diff --git a/apps/x/packages/core/src/code-mode/sessions/service.ts b/apps/x/packages/core/src/code-mode/sessions/service.ts index a7ca1206..24758ca4 100644 --- a/apps/x/packages/core/src/code-mode/sessions/service.ts +++ b/apps/x/packages/core/src/code-mode/sessions/service.ts @@ -109,11 +109,21 @@ export class CodeSessionService { const project = await this.codeProjectsRepo.get(args.projectId); if (!project) throw new Error(`Unknown project: ${args.projectId}`); - // The session id is its own opaque key — code-mode owns its event log - // (codeEventStore) and metadata (codeSessionsRepo) under this id; no run - // record is minted. Rowboat mode reuses this id as a sessions-runtime - // session id (wired in a later step). - const sessionId = crypto.randomUUID(); + const title = args.title?.trim() || `${project.name} session`; + + // Direct mode owns its id outright (code-mode's own event store/bus). + // Rowboat mode is driven by the copilot on the new sessions runtime, so + // the session must exist there — create it and adopt its id, so the + // renderer can sessions:sendMessage against this same id. + let sessionId: string; + if (args.mode === 'rowboat') { + const { getAgentRuntime } = await import('../../agent-runtime/index.js'); + const { sessions } = await getAgentRuntime(); + const created = await sessions.createSession({ agentId: 'copilot', title }); + sessionId = created.id; + } else { + sessionId = crypto.randomUUID(); + } let cwd = project.path; let worktree: CodeSession['worktree']; @@ -132,7 +142,7 @@ export class CodeSessionService { const session: CodeSession = { id: sessionId, projectId: project.id, - title: args.title?.trim() || `${project.name} session`, + title, agent: args.agent, mode: args.mode, policy: args.policy, @@ -346,6 +356,13 @@ export class CodeSessionService { await clearStoredSession(sessionId); await this.codeSessionsRepo.remove(sessionId); await this.codeEventStore.delete(sessionId).catch(() => {}); + // Rowboat sessions also have a sessions-runtime row + turns — drop them. + if (session?.mode === 'rowboat') { + const { getAgentRuntime } = await import('../../agent-runtime/index.js'); + await getAgentRuntime() + .then(({ sessions }) => sessions.deleteSession(sessionId)) + .catch(() => {}); + } await fs.rm(path.join(WorkDir, 'config', `workdir-${sessionId}.json`), { force: true }).catch(() => {}); } diff --git a/apps/x/packages/core/src/sessions/sessions.ts b/apps/x/packages/core/src/sessions/sessions.ts index 9e30b3bd..1c14133b 100644 --- a/apps/x/packages/core/src/sessions/sessions.ts +++ b/apps/x/packages/core/src/sessions/sessions.ts @@ -50,6 +50,8 @@ function composeContextFromOptions( if (options.voiceOutput !== undefined) compose.voiceOutput = options.voiceOutput; if (options.searchEnabled !== undefined) compose.searchEnabled = options.searchEnabled; if (options.codeMode !== undefined) compose.codeMode = options.codeMode; + if (options.codeCwd !== undefined) compose.codeCwd = options.codeCwd; + if (options.codePolicy !== undefined) compose.codePolicy = options.codePolicy; return Object.keys(compose).length > 0 ? compose : null; } diff --git a/apps/x/packages/shared/src/agent-turn.ts b/apps/x/packages/shared/src/agent-turn.ts index b86e52ef..cb9f4ec7 100644 --- a/apps/x/packages/shared/src/agent-turn.ts +++ b/apps/x/packages/shared/src/agent-turn.ts @@ -7,6 +7,7 @@ import { ToolCallPart, VoiceOutputMode, } from "./message.js"; +import { ApprovalPolicy, CodeRunEvent, PermissionAsk } from "./code-mode.js"; // ─── Persisted fact schemas ───────────────────────────────────────────────── // @@ -83,6 +84,10 @@ export const ComposeContext = z.object({ voiceOutput: VoiceOutputMode.optional(), searchEnabled: z.boolean().optional(), codeMode: CodeMode.optional(), + // Code-section (rowboat) turns pin the coding agent's working directory and + // approval policy; code_agent_run honors these over the model's args. + codeCwd: z.string().optional(), + codePolicy: ApprovalPolicy.optional(), }); export const AgentLoopTurn = z.object({ @@ -168,7 +173,12 @@ export type TurnEvent = // recorded as a ToolMessage; this is purely for the UI to watch in real time. | { type: "tool-output"; toolCallId: string; chunk: string } | { type: "tool-result"; toolCallId: string } - | { type: "permission-requested"; toolCallId: string }; + | { type: "permission-requested"; toolCallId: string } + // Rich code-agent activity streamed by code_agent_run (rowboat mode): the + // ACP agent's tool calls / plan / diffs, and its mid-run approval asks. Both + // carry the owning tool call id so the UI nests them under that tool card. + | { type: "code-run-event"; toolCallId: string; event: z.infer } + | { type: "code-run-permission-request"; toolCallId: string; requestId: string; ask: z.infer }; // ─── Derived state ────────────────────────────────────────────────────────── diff --git a/apps/x/packages/shared/src/sessions.ts b/apps/x/packages/shared/src/sessions.ts index 82a12f79..c2b69372 100644 --- a/apps/x/packages/shared/src/sessions.ts +++ b/apps/x/packages/shared/src/sessions.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { CodeMode, MiddlePaneContext, VoiceOutputMode } from "./message.js"; +import { ApprovalPolicy } from "./code-mode.js"; import { AgentLoopTurn, PermissionMode, type TurnEvent } from "./agent-turn.js"; // A session is a grouping label plus a title — an ordered chain of turns, @@ -36,6 +37,9 @@ export const SendMessageOptions = z.object({ voiceOutput: VoiceOutputMode.optional(), searchEnabled: z.boolean().optional(), codeMode: CodeMode.optional(), + // Rowboat code sessions pin the coding agent's cwd + approval policy. + codeCwd: z.string().optional(), + codePolicy: ApprovalPolicy.optional(), middlePaneContext: MiddlePaneContext.optional(), });