Files
orca/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts
T
Brennan BensonandMerge Sim ba4e79c250 fix(runtime): apply the structured-chat setting to every RPC caller (#18700)
* fix(runtime): apply the structured-chat setting to every RPC caller

supportsStructuredAgentSessions only consulted experimentalStructuredNativeChat
when clientKind === 'mobile', so identical host settings admitted desktop and
in-process callers while refusing a phone. The server branched on client surface.

The setting is now one rule for every caller. The negotiated capability stays a
wire term asked of remote clients only, so a capability-less in-process caller is
still admitted on the setting alone.

Making the projection's structuredNativeChatEnabled argument required surfaced
eight call sites that passed `undefined` for non-mobile clients; they now read the
host setting, so tab projection follows the same single rule.

Announced behaviour change: with the flag off, session.tabs.list/listAll no longer
restore structured tabs for desktop. The desktop renderer already discards them in
that state, and startup record/lease reconciliation is unaffected.

* fix(runtime): keep structured session cleanup available

* test(runtime): enable structured chat in desktop projection fixture

* test(agent-session): settle merged fixtures against the all-clients structured policy

The merge with main left three fixtures written for the old mobile-only rule:
a duplicate getClientSettings key, a create fixture with no host settings at
all, and a projection call whose 'old client' is now the mobile fallback-title
case.

* fix(native-chat): let an admitted caller close a chat after the setting is off

Turning `experimentalStructuredNativeChat` off revoked admission for every
`agentSession.*` method, including `close`. A chat opened while the setting was
on stays mounted, so its owner was left with a live provider child and an X
button that answered `structured_agent_session_unsupported`.

Split the surface by what a method does to work in flight rather than by how it
sounds, and write that rule where the gate lives so the next method lands on the
right side: starting, extending, retaining or reading needs admission; stopping
or retiring work the caller already owns does not. Moves `close` and `cancel`
onto the cleanup gate alongside `unsubscribe` and `release`.

The tightening is unchanged - the cleanup gate still demands the negotiated wire
capability and never creates a host, so an incapable client still cannot see the
surface and no method that starts work is reachable with the setting off.

Extracts the dispatcher harness and the method-to-gate table into fixtures so
the new admission suite can share them without a max-lines disable.

* Drop a duplicate lastActivityAt key carried in from main

The main commit this branch merged (fb322046e8) had two lastActivityAt
properties in the same object literal at both journal stubs, which fails
TS1117 and oxlint. Upstream has since kept only the later value; match it.

Not introduced here, but merged in, so it has to be fixed here.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-06 23:32:00 -07:00

243 lines
8.7 KiB
TypeScript

// The create route's pre-commit boundary: a failure before `attach` must reach the client as a
// refusal it can classify, and a failure at or after `attach` must not.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope'
import { isDefinitiveAgentSessionCreateRefusal } from '../../../../shared/agent-session-definitive-refusal'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import type { OrcaRuntimeService } from '../../orca-runtime'
import type { RpcResponse } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session'
const SESSION = 'session-alpha'
const OPERATION = '1800000000000-00000000000000000000000000000001'
const WORKTREE = 'id:workspace-1'
const STRUCTURED_CLIENT = {
clientKind: 'runtime' as const,
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
function createParams(overrides: Record<string, unknown> = {}) {
return {
envelope: {
sessionId: SESSION,
clientOperationId: OPERATION,
expectedRuntimeFence: null,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: SESSION,
fields: { worktree: WORKTREE, agent: 'codex' }
}),
...(overrides.envelope as Record<string, unknown> | undefined)
},
worktree: WORKTREE,
agent: 'codex'
}
}
let attach: ReturnType<typeof vi.fn>
function hostStub(): StructuredAgentSessionHost {
attach = vi.fn(async () => ({
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-a', sequence: 0 },
value: { sessionId: SESSION, fence: 1, page: {}, unconfirmedClientMessageIds: [] }
}))
return { attach } as unknown as StructuredAgentSessionHost
}
const resolvedIntent = {
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
provider: 'codex',
agent: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' },
runtimeKind: 'native'
}
async function create(
runtimeOverrides: Record<string, unknown> = {},
params: unknown = createParams()
): Promise<RpcResponse> {
const runtime = {
getRuntimeId: () => 'runtime-1',
// The structured surface is settings-gated for every caller; these fixtures probe the
// pre-commit boundary, which only runs once the gate admits the call.
getClientSettings: () => ({ experimentalStructuredNativeChat: true }),
registerSubscriptionCleanup: vi.fn(),
cleanupSubscription: vi.fn(),
cleanupSubscriptionsByPrefix: vi.fn(),
ensureStructuredAgentSessionHost: vi.fn(async () => undefined),
resolveStructuredAgentSessionCreateIntent: vi.fn(async (input: { envelope: unknown }) => ({
envelope: input.envelope,
...resolvedIntent
})),
publishStructuredAgentSessionTab: vi.fn(async () => undefined),
...runtimeOverrides
}
const replies: RpcResponse[] = []
await new RpcDispatcher({
runtime: runtime as unknown as OrcaRuntimeService,
methods: STRUCTURED_AGENT_SESSION_METHODS
}).dispatchStreaming(
{ id: 'request-1', authToken: 'token', method: 'agentSession.create', params },
(raw) => replies.push(JSON.parse(raw) as RpcResponse),
STRUCTURED_CLIENT
)
const first = replies[0]
if (!first) {
throw new Error('no reply for agentSession.create')
}
return first
}
/** The refusal a client can act on, or null when the reply was not one. */
function refusalOf(response: RpcResponse): { code: string; message: string } | null {
if (!response.ok) {
return null
}
const result = response.result as { ok: boolean; refusal?: { code: string; message: string } }
return result.ok ? null : (result.refusal ?? null)
}
beforeEach(() => {
setStructuredAgentSessionHost(hostStub())
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
})
afterEach(() => {
setStructuredAgentSessionHost(null)
vi.restoreAllMocks()
})
describe('a create refused before it commits', () => {
it('answers a code-carrying refusal as a definitive envelope', async () => {
const response = await create({
resolveStructuredAgentSessionCreateIntent: vi.fn(async () => {
throw new Error('structured_agent_session_unsupported')
})
})
const refusal = refusalOf(response)
expect(refusal?.code).toBe('structured_agent_session_unsupported')
expect(refusal?.message).toContain('structured agent chat')
expect(refusal?.message).not.toContain('Codex')
expect(isDefinitiveAgentSessionCreateRefusal(refusal?.code)).toBe(true)
expect(attach).not.toHaveBeenCalled()
})
it('answers a code-less failure as a definitive envelope too, keeping the cause in the message', async () => {
// The class no per-site conversion catches: an unresolvable worktree throws prose, not a code.
const response = await create({
resolveStructuredAgentSessionCreateIntent: vi.fn(async () => {
throw new Error('No worktree matches id:workspace-1')
})
})
const refusal = refusalOf(response)
expect(isDefinitiveAgentSessionCreateRefusal(refusal?.code)).toBe(true)
expect(refusal?.message).toContain('structured agent chat')
expect(refusal?.message).not.toContain('Codex')
expect(refusal?.message).toContain('No worktree matches id:workspace-1')
expect(attach).not.toHaveBeenCalled()
})
it('answers a host that will not install as a definitive envelope', async () => {
setStructuredAgentSessionHost(null)
const response = await create({
ensureStructuredAgentSessionHost: vi.fn(async () => {
throw new Error('EACCES: could not open the session store')
})
})
const refusal = refusalOf(response)
expect(isDefinitiveAgentSessionCreateRefusal(refusal?.code)).toBe(true)
expect(refusal?.message).toContain('could not open the session store')
})
it('answers a missing host as a definitive envelope rather than a thrown code', async () => {
setStructuredAgentSessionHost(null)
const response = await create()
const refusal = refusalOf(response)
expect(refusal?.code).toBe('structured_agent_session_unsupported')
expect(isDefinitiveAgentSessionCreateRefusal(refusal?.code)).toBe(true)
})
it('still refuses a fingerprint conflict with its own code, not a pre-commit one', async () => {
const response = await create(
{},
createParams({ envelope: { payloadFingerprint: 'a'.repeat(64) } })
)
expect(refusalOf(response)?.code).toBe('agent_session_operation_conflict')
expect(attach).not.toHaveBeenCalled()
})
})
describe('the boundary the envelope stops at', () => {
it('leaves a failure at attach unknown, because it may have committed', async () => {
attach.mockRejectedValueOnce(new Error('attach exploded'))
const response = await create()
expect(response).toMatchObject({ ok: false, error: { code: 'runtime_error' } })
expect(refusalOf(response)).toBeNull()
})
it('leaves a committed create whose tab could not be published unknown', async () => {
const response = await create({
publishStructuredAgentSessionTab: vi.fn(async () => {
throw new Error('publish failed')
})
})
const refusal = refusalOf(response)
expect(refusal?.code).toBe('agent_session_operation_unknown')
expect(isDefinitiveAgentSessionCreateRefusal(refusal?.code)).toBe(false)
})
it('keeps hiding the surface from a client that never advertised it', async () => {
const replies: RpcResponse[] = []
await new RpcDispatcher({
runtime: { getRuntimeId: () => 'runtime-1' } as unknown as OrcaRuntimeService,
methods: STRUCTURED_AGENT_SESSION_METHODS
}).dispatchStreaming(
{
id: 'request-1',
authToken: 'token',
method: 'agentSession.create',
params: createParams()
},
(raw) => replies.push(JSON.parse(raw) as RpcResponse),
{ clientKind: 'runtime', clientCapabilities: [] }
)
expect(replies[0]).toMatchObject({
ok: false,
error: { message: expect.stringContaining('structured_agent_session_unsupported') }
})
})
it('keeps a client-declared fence a programming error, not a refusal', async () => {
const response = await create({}, createParams({ envelope: { expectedRuntimeFence: 1 } }))
expect(response).toMatchObject({
ok: false,
error: { code: 'agent_session_operation_invalid' }
})
})
})