mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-15 20:05:16 +02:00
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:
parent
d0ba9fa4a6
commit
15a08da783
12 changed files with 142 additions and 18 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue