Step 2: rowboat code-mode on the new sessions runtime

Rowboat code sessions (copilot orchestrating the coding agent) now run on the
new sessions/turn runtime instead of the retired LLM run path.

Core:
- TurnEvent gains code-run-event + code-run-permission-request variants;
  ComposeContext + SendMessageOptions gain codeCwd + codePolicy.
- The agent loop threads codeCwd/codePolicy from the turn's composeContext into
  ToolRunContext; RealToolRunner passes them to code_agent_run and stops
  deferring the code-run events — it forwards them onto the turn's event stream.
- turnToChatState/applyOverlay accumulate per-tool code-run events + the pending
  code permission and attach them to the code_agent_run tool call (live-only;
  a completed turn collapses to the tool result).

Code-mode:
- CodeSessionService.create makes a rowboat session a real sessions row (id
  shared) so sessions.sendMessage drives it; delete() also removes that row.
- sessions:sendMessage IPC handler pins the coding agent's agent/cwd/policy from
  the code session meta (server-side source of truth), mirroring the old
  createMessage override.

Rowboat renders in the main chat (App.tsx binds the tab's runId to the code
session id — preserved from dev); the code_agent_run tool card shows the agent's
plan/diffs/permission via the existing CodingRunBlock. Direct mode is unchanged
(its own runtime from step 1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-06-15 11:19:21 +05:30
parent d0ba9fa4a6
commit 15a08da783
12 changed files with 142 additions and 18 deletions

View file

@ -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<ICodeSessionsRepo>('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) => {

View file

@ -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)
})
})

View file

@ -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<typeof deriveTurnStatus> {
export type LiveOverlay = {
text: string
toolOutput: Record<string, string>
// 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<string, CodeRunEvent[]>
codePermission: Record<string, { requestId: string; ask: PermissionAsk } | null>
}
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
}

View file

@ -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')

View file

@ -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<string, z.infer<typeof ToolPermissionRequestEvent>>()
for (const { toolCall, request } of pendingPermissions(turn)) {

View file

@ -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),
})

View file

@ -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<typeof CodeMode> | 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<typeof ApprovalPolicy> | 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

View file

@ -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.

View file

@ -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(() => {});
}

View file

@ -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;
}

View file

@ -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<typeof CodeRunEvent> }
| { type: "code-run-permission-request"; toolCallId: string; requestId: string; ask: z.infer<typeof PermissionAsk> };
// ─── Derived state ──────────────────────────────────────────────────────────

View file

@ -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(),
});