From 3837ae8d51a70c0be23f05d0b044b128c55c0dc9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:50:11 -0700 Subject: [PATCH] chore(agent-launch): carry agent.launch through main's RPC typing and casting gates The typed-method contract, the generated params catalog and the `assertionStyle: never` casting scan all landed after this branch's base. - AGENT_LAUNCH_METHODS kept an `RpcMethod[]` annotation, which widened its method name to `string` and broke assignability; every sibling infers instead. - `agent.launch` binds a schema under src/main, so it joins the catalog's RPC_METHODS_WITHOUT_SHARED_PARAMS and the parity gate's hand-listed twin. - The now-typed methods make most test casts unnecessary; the few that remain carry the line-specific SAFETY rationale the casting gate requires. --- .../agent-launch-executor.test.ts | 3 +- .../rpc/methods/agent-launch-schemas.ts | 1 + .../methods/agent-launch-worktree-creation.ts | 2 +- .../runtime/rpc/methods/agent-launch.test.ts | 79 ++++++++++--------- src/main/runtime/rpc/methods/agent-launch.ts | 4 +- .../runtime/rpc/rpc-params-type-parity.ts | 6 +- .../rpc-params-catalog.generated.ts | 1 + 7 files changed, 53 insertions(+), 43 deletions(-) diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index 5f4300bf22f..d9364863655 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -50,7 +50,7 @@ function harness(options: { }) const runtime = { getClientSettings: () => - (options.settings === undefined ? STRUCTURED_PREFERENCE : options.settings) as never, + options.settings === undefined ? STRUCTURED_PREFERENCE : options.settings, getStructuredAgentSessionCreateSupport } return { @@ -60,6 +60,7 @@ function harness(options: { createTerminalAgent, run: (intent: AgentLaunchIntent) => executeAgentLaunch({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the two runtime methods the executor reaches, and each test asserts the calls made, so an omitted method throws rather than reading a wrong value. runtime: runtime as unknown as AgentLaunchExecution['runtime'], intent, surfaces: { createStructuredSession, createTerminalAgent }, diff --git a/src/main/runtime/rpc/methods/agent-launch-schemas.ts b/src/main/runtime/rpc/methods/agent-launch-schemas.ts index 009d014c85f..f71be37da3a 100644 --- a/src/main/runtime/rpc/methods/agent-launch-schemas.ts +++ b/src/main/runtime/rpc/methods/agent-launch-schemas.ts @@ -19,6 +19,7 @@ const LaunchAgent = z ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Unknown TUI agent' }) } }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the superRefine above rejects anything isTuiAgent refuses, so the transform only ever runs on a TuiAgent. .transform((value): TuiAgent => value as TuiAgent) export const AgentLaunch = z.object({ diff --git a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts index a1c36f336b4..ecd4a5608e8 100644 --- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts @@ -33,7 +33,7 @@ export function agentLaunchWorkspaceFactory( ): AgentLaunchWorkspaceFactory { return { createWorktree: async ({ create, startupAgent }) => { - // Already validated by `AgentLaunch`; the executor only removed the reserved agent fields. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: already validated by `AgentLaunch`; the executor only removed the reserved agent fields, so the rest of the payload is the parsed shape. const params = create as WorktreeCreateParams const { runtime } = context return runtime.dedupeWorktreeCreate(params.repo, params.clientMutationId, async () => { diff --git a/src/main/runtime/rpc/methods/agent-launch.test.ts b/src/main/runtime/rpc/methods/agent-launch.test.ts index 0f20e800029..c745cff846b 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test.ts @@ -10,7 +10,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcContext } from '../core' const createStructuredSession = vi.fn(async (_args: Record) => ({ @@ -47,7 +46,7 @@ function runtimeStub( (_repo: string, _key: string | undefined, run: () => Promise) => run() ), showRepo: vi.fn(async () => ({ id: 'repo-1' })), - createManagedWorktree: vi.fn(async (args: { startupAgent?: string }) => ({ + createManagedWorktree: vi.fn(async (args: Record) => ({ worktree: { id: 'wt-new' }, startupTerminal: args.startupAgent ? { handle: 'term_agent_first' } : undefined })), @@ -61,22 +60,37 @@ function runtimeStub( type RuntimeStub = ReturnType -function methodNamed(methods: readonly { name: string }[], name: string) { - const found = methods.find((entry) => entry.name === name) +function methodNamed( + methods: readonly TMethod[], + name: TName +): Extract { + const found = methods.find( + (entry): entry is Extract => entry.name === name + ) if (!found) { throw new Error(`missing method ${name}`) } - return found as { name: string; params: { safeParse: (v: unknown) => unknown } | null } & { - handler: (params: unknown, ctx: RpcContext) => unknown - } + return found } const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch') function parseLaunch(params: unknown) { - return AGENT_LAUNCH.params?.safeParse(params) as - | { success: true; data: unknown } - | { success: false; error: { issues: { message: string }[] } } + return AGENT_LAUNCH.params.safeParse(params) +} + +// The one call the stub cannot satisfy structurally; every method it does implement is asserted. +function rpcContext(runtime: RuntimeStub, context: Partial): RpcContext { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the runtime surface these methods reach, so a method it omits throws on call rather than reading a wrong value. + return { runtime, ...context } as unknown as RpcContext +} + +function createArgs(runtime: RuntimeStub): Record { + const [args] = runtime.createManagedWorktree.mock.calls[0] ?? [] + if (!args) { + throw new Error('createManagedWorktree was not called') + } + return args } const CAPABLE_CLIENT: Partial = { @@ -94,10 +108,7 @@ async function launch( if (!parsed.success) { throw new Error(parsed.error.issues[0]?.message ?? 'invalid') } - return AGENT_LAUNCH.handler(parsed.data, { - runtime: runtime as unknown as OrcaRuntimeService, - ...context - } as RpcContext) + return AGENT_LAUNCH.handler(parsed.data, rpcContext(runtime, context)) } const CREATE_LAUNCH = { @@ -173,9 +184,9 @@ describe('what agent.launch accepts', () => { describe('the worktree factory', () => { it('creates a structured launch’s worktree with no startup agent', async () => { const runtime = runtimeStub() - const result = (await launch(CREATE_LAUNCH, runtime)) as { outcome: { kind: string } } + const result = await launch(CREATE_LAUNCH, runtime) - const args = runtime.createManagedWorktree.mock.calls[0]?.[0] as Record + const args = createArgs(runtime) expect(args.startupAgent).toBeUndefined() // Still recorded on the workspace: the launch owns the agent whichever surface it settles on. expect(args.createdWithAgent).toBe('claude') @@ -184,11 +195,9 @@ describe('the worktree factory', () => { it('keeps agent-first creation for a launch the user wants as a terminal', async () => { const runtime = runtimeStub({ settings: {} }) - const result = (await launch(CREATE_LAUNCH, runtime)) as { - outcome: { kind: string; handle: string } - } + const result = await launch(CREATE_LAUNCH, runtime) - const args = runtime.createManagedWorktree.mock.calls[0]?.[0] as Record + const args = createArgs(runtime) expect(args.startupAgent).toBe('claude') expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_agent_first' }) expect(runtime.getStructuredAgentSessionCreateSupport).not.toHaveBeenCalled() @@ -211,7 +220,7 @@ describe('the worktree factory', () => { }, runtime ) - const args = runtime.createManagedWorktree.mock.calls[0]?.[0] as Record + const args = createArgs(runtime) expect(args.startupAgent).toBeUndefined() expect(args.startup).toBeUndefined() }) @@ -220,10 +229,7 @@ describe('the worktree factory', () => { describe('the structured session factory', () => { it('creates the session for the worktree the launch just made, and activates it', async () => { const runtime = runtimeStub() - const result = (await launch(CREATE_LAUNCH, runtime)) as { - outcome: { kind: string; sessionId: string; handle: string } - worktreeId: string - } + const result = await launch(CREATE_LAUNCH, runtime) expect(createStructuredSession).toHaveBeenCalledTimes(1) expect(createStructuredSession.mock.calls[0]?.[0]).toMatchObject({ @@ -257,10 +263,7 @@ describe('the structured session factory', () => { describe('the terminal factory', () => { it('starts the agent through the runtime launcher when the host refuses a session', async () => { const runtime = runtimeStub({ createSupport: { supported: false, reason: 'wsl' } }) - const result = (await launch(CREATE_LAUNCH, runtime)) as { - outcome: { kind: string; handle: string } - receipt: { mode: string; reason: string } - } + const result = await launch(CREATE_LAUNCH, runtime) expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-new', { startupAgent: 'claude' }) expect(createStructuredSession).not.toHaveBeenCalled() @@ -271,10 +274,10 @@ describe('the terminal factory', () => { it('takes an existing workspace without creating one', async () => { const runtime = runtimeStub() - const result = (await launch( + const result = await launch( { agent: 'grok', target: { kind: 'existing', worktree: 'id:wt-7' } }, runtime - )) as { worktreeId: string } + ) expect(runtime.createManagedWorktree).not.toHaveBeenCalled() expect(runtime.showManagedTerminalWorkspace).toHaveBeenCalledWith('id:wt-7') @@ -289,18 +292,18 @@ describe('worktree.create is untouched by any of this', () => { it('still answers a startupAgent create with a PTY agent and its handle', async () => { const runtime = runtimeStub() const create = methodNamed(WORKTREE_METHODS, 'worktree.create') - const parsed = create.params?.safeParse({ + const parsed = create.params.safeParse({ repo: 'id:repo-1', name: 'task', startupAgent: 'claude' - }) as { success: true; data: unknown } - expect(parsed.success).toBe(true) + }) + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? 'invalid') + } - const result = (await create.handler(parsed.data, { - runtime: runtime as unknown as OrcaRuntimeService - } as RpcContext)) as { agentTerminalHandle?: string } + const result = await create.handler(parsed.data, rpcContext(runtime, {})) - expect(result.agentTerminalHandle).toBe('term_agent_first') + expect(result).toMatchObject({ agentTerminalHandle: 'term_agent_first' }) expect(runtime.createManagedWorktree.mock.calls[0]?.[0]).toMatchObject({ startupAgent: 'claude' }) diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index 324d494271a..fec0f5b162e 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -17,7 +17,7 @@ import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/protocol-ver import type { AgentLaunchIntent, AgentLaunchTarget } from '../../../../shared/agent-launch-intent' import { executeAgentLaunch } from '../../../agent-launch/agent-launch-executor' import type { OrcaRuntimeService } from '../../orca-runtime' -import { defineMethod, type RpcContext, type RpcMethod } from '../core' +import { defineMethod, type RpcContext } from '../core' import { AgentLaunch, type AgentLaunchParams } from './agent-launch-schemas' import { agentLaunchSurfaceFactory } from './agent-launch-surfaces' import { agentLaunchWorkspaceFactory } from './agent-launch-worktree-creation' @@ -67,7 +67,7 @@ async function agentLaunchIntent( } } -export const AGENT_LAUNCH_METHODS: RpcMethod[] = [ +export const AGENT_LAUNCH_METHODS = [ defineMethod({ name: 'agent.launch', params: AgentLaunch, diff --git a/src/main/runtime/rpc/rpc-params-type-parity.ts b/src/main/runtime/rpc/rpc-params-type-parity.ts index 263a7fe38cd..ae3a5fcc76f 100644 --- a/src/main/runtime/rpc/rpc-params-type-parity.ts +++ b/src/main/runtime/rpc/rpc-params-type-parity.ts @@ -8,7 +8,11 @@ import type { ALL_RPC_METHODS } from './methods' type RegisteredMethod = (typeof ALL_RPC_METHODS)[number] // These schemas reach into src/main and have no shared catalog entry. -type UncataloguedMethod = 'emulator.install' | 'orchestration.send' | 'orchestration.taskUpdate' +type UncataloguedMethod = + | 'agent.launch' + | 'emulator.install' + | 'orchestration.send' + | 'orchestration.taskUpdate' type IsAny = 0 extends 1 & T ? true : false diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index deae9bc0eaa..7824726bccd 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -1160,6 +1160,7 @@ export const RPC_PARAMS_BY_METHOD = { // Why: these methods bind a schema the shared contract cannot hold because its value // graph reaches into src/main. Listing them keeps the gap visible instead of absent. export const RPC_METHODS_WITHOUT_SHARED_PARAMS: readonly string[] = [ + 'agent.launch', 'emulator.install', 'orchestration.send', 'orchestration.taskUpdate'