mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
* Fix structured native chat completion sorting and timestamps * Preserve native chat activity across settled updates and host upgrades --------- Co-authored-by: Merge Sim <sim@local>
828 lines
28 KiB
TypeScript
828 lines
28 KiB
TypeScript
// The wire boundary: who may see `agentSession.*` at all, and what shapes it
|
|
// accepts once they can.
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
|
|
import type { AgentSessionJournal } from '../../../native-chat/agent-session-journal/journal-store'
|
|
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 {
|
|
StructuredAgentSessionStatusFeed,
|
|
type StructuredAgentSessionStatusSubscriber
|
|
} from '../../../native-chat/agent-session-wire/structured-agent-session-status-feed'
|
|
import {
|
|
RUNTIME_CAPABILITIES,
|
|
RUNTIME_PROTOCOL_VERSION,
|
|
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,
|
|
STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY,
|
|
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
|
} from '../../../../shared/protocol-version'
|
|
import type { OrcaRuntimeService } from '../../orca-runtime'
|
|
import type { RpcRequest, RpcResponse } from '../core'
|
|
import { RpcDispatcher } from '../dispatcher'
|
|
import { ALL_RPC_METHODS } from './index'
|
|
import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session'
|
|
import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope'
|
|
|
|
const SESSION = 'session-alpha'
|
|
const FINGERPRINT = 'f'.repeat(64)
|
|
const OPERATION = '1800000000000-00000000000000000000000000000001'
|
|
|
|
function envelope(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
sessionId: SESSION,
|
|
clientOperationId: OPERATION,
|
|
expectedRuntimeFence: 1,
|
|
payloadFingerprint: FINGERPRINT,
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
function sendParams(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
envelope: envelope(),
|
|
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] },
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
function attachParams(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
envelope: envelope({ expectedRuntimeFence: null }),
|
|
location: {
|
|
executionHostId: 'local',
|
|
wslDistro: null,
|
|
workspaceId: 'workspace-1',
|
|
workspaceKind: 'git-worktree'
|
|
},
|
|
provider: 'codex',
|
|
agent: 'codex',
|
|
accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' },
|
|
runtimeKind: 'native',
|
|
providerHandle: { kind: 'codex', threadId: 'thread-1' },
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
function request(method: string, params: unknown): RpcRequest {
|
|
return { id: 'request-1', authToken: 'token', method, params }
|
|
}
|
|
|
|
let hostCalls: Record<string, ReturnType<typeof vi.fn>>
|
|
let runtimeCalls: Record<string, ReturnType<typeof vi.fn>>
|
|
|
|
const STATUS_SESSION = 'session-status'
|
|
const STATUS_ITEMS: AgentJournalRenderItem[] = [
|
|
{
|
|
itemId: 'user-1',
|
|
sequence: 1,
|
|
revision: 1,
|
|
observedAt: 1,
|
|
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] }
|
|
},
|
|
{
|
|
itemId: 'turn-1',
|
|
sequence: 2,
|
|
revision: 1,
|
|
observedAt: 2,
|
|
body: { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }
|
|
}
|
|
]
|
|
|
|
/** One indexed session over a journal that reads back fixed items; the projection is real. */
|
|
function statusFeed(): StructuredAgentSessionStatusFeed {
|
|
return new StructuredAgentSessionStatusFeed({
|
|
sessions: new Map([
|
|
[
|
|
STATUS_SESSION,
|
|
{
|
|
journal: {
|
|
isReadOnly: false,
|
|
lastActivityAt: () => 2,
|
|
snapshot: () => ({ items: STATUS_ITEMS })
|
|
} as unknown as AgentSessionJournal,
|
|
params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' as const }
|
|
}
|
|
]
|
|
]),
|
|
getRecord: () => null,
|
|
now: () => 1_000
|
|
})
|
|
}
|
|
|
|
function hostStub(): StructuredAgentSessionHost {
|
|
hostCalls = {
|
|
attach: vi.fn(async () => ({
|
|
ok: true,
|
|
replayed: false,
|
|
fence: 1,
|
|
cursor: { epoch: 'epoch-a', sequence: 0 },
|
|
value: {
|
|
sessionId: SESSION,
|
|
fence: 1,
|
|
page: {
|
|
sessionId: SESSION,
|
|
epoch: 'epoch-a',
|
|
direction: 'tail',
|
|
items: [],
|
|
removedItemIds: [],
|
|
submissions: [],
|
|
window: {
|
|
oldest: null,
|
|
newest: null,
|
|
nextCursor: { epoch: 'epoch-a', sequence: 0 }
|
|
},
|
|
liveCursor: { epoch: 'epoch-a', sequence: 0 },
|
|
hasOlder: false,
|
|
hasNewer: false
|
|
},
|
|
unconfirmedClientMessageIds: []
|
|
}
|
|
})),
|
|
send: vi.fn(async () => ({ ok: true, replayed: false })),
|
|
cancel: vi.fn(async () => ({ ok: true, replayed: false })),
|
|
close: vi.fn(async () => undefined),
|
|
revealSession: vi.fn(async () => ({
|
|
sessionId: SESSION,
|
|
workspaceId: 'workspace-1',
|
|
agent: 'codex' as const,
|
|
readable: true
|
|
})),
|
|
setSessionTabVisibility: vi.fn(async () => undefined),
|
|
respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })),
|
|
setOption: vi.fn(async () => ({ ok: true, replayed: false })),
|
|
requestHandoff: vi.fn(async () => ({
|
|
ok: true,
|
|
replayed: false,
|
|
fence: 1,
|
|
cursor: { epoch: 'epoch-a', sequence: 0 },
|
|
value: {
|
|
status: {
|
|
owner: 'native',
|
|
direction: null,
|
|
phase: 'idle',
|
|
stage: null,
|
|
operationId: null
|
|
}
|
|
}
|
|
})),
|
|
supportsCreate: vi.fn(() => true),
|
|
handoffStatus: vi.fn(async () => ({ owner: 'native' })),
|
|
readOptions: vi.fn(async () => ({
|
|
models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }],
|
|
current: { model: 'gpt-live' }
|
|
})),
|
|
history: vi.fn(() => ({ ok: true, page: { items: [] } })),
|
|
subscribe: vi.fn(() => () => undefined),
|
|
// A real feed, so the snapshot this method hands back is a genuine projection rather
|
|
// than a shape the stub restated.
|
|
subscribeStatus: vi.fn((subscriber: StructuredAgentSessionStatusSubscriber) =>
|
|
statusFeed().subscribe(subscriber)
|
|
),
|
|
unsubscribe: vi.fn()
|
|
}
|
|
return hostCalls as unknown as StructuredAgentSessionHost
|
|
}
|
|
|
|
function dispatcher(runtimeOverrides: Record<string, unknown> = {}): RpcDispatcher {
|
|
runtimeCalls = {
|
|
getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })),
|
|
resolveStructuredAgentSessionCreateIntent: vi.fn(async (params) => ({
|
|
envelope: params.envelope,
|
|
location: {
|
|
executionHostId: 'local',
|
|
wslDistro: null,
|
|
workspaceId: 'workspace-1',
|
|
workspaceKind: 'git-worktree'
|
|
},
|
|
provider: params.agent,
|
|
agent: params.agent,
|
|
accountHome: {
|
|
variable: params.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME',
|
|
path: params.agent === 'claude' ? '/host/.claude' : '/host/.codex'
|
|
},
|
|
options:
|
|
params.agent === 'claude'
|
|
? { model: 'opus', effort: 'high' }
|
|
: { model: 'gpt-5.6-sol', effort: 'medium' },
|
|
runtimeKind: 'native'
|
|
})),
|
|
publishStructuredAgentSessionTab: vi.fn()
|
|
}
|
|
const runtime = {
|
|
getRuntimeId: () => 'runtime-1',
|
|
registerSubscriptionCleanup: vi.fn(),
|
|
cleanupSubscription: vi.fn(),
|
|
cleanupSubscriptionsByPrefix: vi.fn(),
|
|
...runtimeCalls,
|
|
...runtimeOverrides
|
|
}
|
|
return new RpcDispatcher({
|
|
runtime: runtime as unknown as OrcaRuntimeService,
|
|
methods: STRUCTURED_AGENT_SESSION_METHODS
|
|
})
|
|
}
|
|
|
|
/** The reply path is the only one that carries a client's negotiated identity,
|
|
* which is exactly what the capability gate reads. */
|
|
async function call(
|
|
method: string,
|
|
params: unknown,
|
|
client?: {
|
|
clientId?: string
|
|
clientKind?: 'mobile' | 'runtime'
|
|
clientCapabilities?: string[]
|
|
},
|
|
runtimeOverrides: Record<string, unknown> = {}
|
|
): Promise<RpcResponse> {
|
|
const replies: RpcResponse[] = []
|
|
await dispatcher(runtimeOverrides).dispatchStreaming(
|
|
request(method, params),
|
|
(raw) => replies.push(JSON.parse(raw) as RpcResponse),
|
|
client
|
|
)
|
|
const first = replies[0]
|
|
if (!first) {
|
|
throw new Error(`no reply for ${method}`)
|
|
}
|
|
return first
|
|
}
|
|
|
|
const STRUCTURED_CLIENT = {
|
|
clientKind: 'runtime' as const,
|
|
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
|
|
}
|
|
const STRUCTURED_MOBILE_CLIENT = {
|
|
clientKind: 'mobile' as const,
|
|
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
|
|
}
|
|
|
|
beforeEach(() => {
|
|
setStructuredAgentSessionHost(hostStub())
|
|
})
|
|
|
|
afterEach(() => {
|
|
setStructuredAgentSessionHost(null)
|
|
})
|
|
|
|
describe('agentSession.reveal', () => {
|
|
it.each(['codex', 'claude'] as const)('republishes a persisted %s chat tab', async (agent) => {
|
|
hostCalls.revealSession.mockResolvedValueOnce({
|
|
sessionId: SESSION,
|
|
workspaceId: 'workspace-1',
|
|
agent,
|
|
readable: true
|
|
})
|
|
|
|
const response = await call('agentSession.reveal', { sessionId: SESSION }, STRUCTURED_CLIENT)
|
|
|
|
expect(hostCalls.revealSession).toHaveBeenCalledWith(SESSION)
|
|
expect(response).toMatchObject({ ok: true, result: { ok: true, agent } })
|
|
expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledWith(
|
|
expect.objectContaining({ agent, activate: true })
|
|
)
|
|
})
|
|
|
|
it('publishes the workspace the host reported, not one the client could assert', async () => {
|
|
// The client sends only a session id, so a stale or forged one cannot aim the publish at
|
|
// another workspace.
|
|
hostCalls.revealSession.mockResolvedValueOnce({
|
|
sessionId: SESSION,
|
|
workspaceId: 'workspace-from-record',
|
|
agent: 'claude',
|
|
readable: true
|
|
})
|
|
|
|
await call('agentSession.reveal', { sessionId: SESSION }, STRUCTURED_CLIENT)
|
|
|
|
expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledWith({
|
|
workspaceId: 'workspace-from-record',
|
|
sessionId: SESSION,
|
|
agent: 'claude',
|
|
activate: true
|
|
})
|
|
})
|
|
|
|
it('publishes the tab even when the journal could not be read', async () => {
|
|
// A pre-SQLite chat restores to nothing, but attach still recovers it, so the tab is worth
|
|
// publishing and the pane's hold finishes the job. Refusing here would strand it forever.
|
|
hostCalls.revealSession.mockResolvedValueOnce({
|
|
sessionId: SESSION,
|
|
workspaceId: 'workspace-1',
|
|
agent: 'codex',
|
|
readable: false
|
|
})
|
|
|
|
const response = await call('agentSession.reveal', { sessionId: SESSION }, STRUCTURED_CLIENT)
|
|
|
|
expect(response).toMatchObject({ ok: true, result: { ok: true, readable: false } })
|
|
expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('refuses rather than throws when the host holds no such record', async () => {
|
|
hostCalls.revealSession.mockRejectedValueOnce(new Error('agent_session_identity_required'))
|
|
|
|
const response = await call('agentSession.reveal', { sessionId: SESSION }, STRUCTURED_CLIENT)
|
|
|
|
expect(response).toMatchObject({
|
|
ok: true,
|
|
result: { ok: false, refusal: { code: 'agent_session_identity_required' } }
|
|
})
|
|
expect(runtimeCalls.publishStructuredAgentSessionTab).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not launder an unrelated fault into a refusal', async () => {
|
|
hostCalls.revealSession.mockRejectedValueOnce(new Error('EACCES: journal directory'))
|
|
|
|
const response = await call('agentSession.reveal', { sessionId: SESSION }, STRUCTURED_CLIENT)
|
|
|
|
expect(response).toMatchObject({ ok: false })
|
|
})
|
|
|
|
it('is refused for a client that cannot read structured sessions', async () => {
|
|
const response = await call(
|
|
'agentSession.reveal',
|
|
{ sessionId: SESSION },
|
|
{ clientKind: 'runtime', clientCapabilities: [] }
|
|
)
|
|
|
|
expect(response).toMatchObject({ ok: false })
|
|
expect(hostCalls.revealSession).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('capability gating', () => {
|
|
it('clears durable tab visibility when closing through the agent-session RPC', async () => {
|
|
const response = await call('agentSession.close', { sessionId: SESSION }, STRUCTURED_CLIENT)
|
|
|
|
expect(response).toMatchObject({ ok: true, result: { ok: true } })
|
|
expect(hostCalls.close).toHaveBeenCalledWith(SESSION)
|
|
expect(hostCalls.setSessionTabVisibility).toHaveBeenCalledWith(SESSION, false)
|
|
expect(hostCalls.setSessionTabVisibility.mock.invocationCallOrder[0]).toBeLessThan(
|
|
hostCalls.close.mock.invocationCallOrder[0]!
|
|
)
|
|
})
|
|
|
|
it('does not stop the provider when durable tab retirement fails', async () => {
|
|
hostCalls.setSessionTabVisibility.mockRejectedValueOnce(new Error('visibility write failed'))
|
|
|
|
const response = await call('agentSession.close', { sessionId: SESSION }, STRUCTURED_CLIENT)
|
|
|
|
expect(response).toMatchObject({ ok: false })
|
|
expect(hostCalls.close).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('advertises the capability without bumping the protocol version', () => {
|
|
expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)
|
|
expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY)
|
|
expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY)
|
|
// Additive methods do not break an old client; bumping would strand every
|
|
// paired device that has not updated.
|
|
expect(RUNTIME_PROTOCOL_VERSION).toBe(3)
|
|
})
|
|
|
|
it('registers every structured method on the runtime manifest', () => {
|
|
const names = new Set(ALL_RPC_METHODS.map((method) => method.name))
|
|
for (const method of STRUCTURED_AGENT_SESSION_METHODS) {
|
|
expect(names).toContain(method.name)
|
|
}
|
|
// Bump deliberately: the whole agentSession.* surface is behind the structured capability,
|
|
// so an additive method is invisible to old clients and needs no protocol bump.
|
|
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(19)
|
|
})
|
|
|
|
it('hides the surface from a declared client that did not advertise it', async () => {
|
|
const response = await call('agentSession.send', sendParams(), {
|
|
clientKind: 'runtime',
|
|
clientCapabilities: ['terminal.stream.v1']
|
|
})
|
|
expect(response).toMatchObject({
|
|
ok: false,
|
|
error: { message: expect.stringContaining('structured_agent_session_unsupported') }
|
|
})
|
|
expect(hostCalls.send).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects create intent before resolving host-owned fields for an old client', async () => {
|
|
const worktree = 'id:workspace-1'
|
|
const response = await call(
|
|
'agentSession.create',
|
|
{
|
|
envelope: envelope({
|
|
expectedRuntimeFence: null,
|
|
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
|
method: 'agentSession.create',
|
|
sessionId: SESSION,
|
|
fields: { worktree, agent: 'codex' }
|
|
})
|
|
}),
|
|
worktree,
|
|
agent: 'codex'
|
|
},
|
|
{ clientKind: 'runtime', clientCapabilities: [] }
|
|
)
|
|
|
|
expect(response).toMatchObject({
|
|
ok: false,
|
|
error: { message: expect.stringContaining('structured_agent_session_unsupported') }
|
|
})
|
|
expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('serves a client that advertised it', async () => {
|
|
const response = await call('agentSession.send', sendParams(), STRUCTURED_CLIENT)
|
|
expect(response).toMatchObject({ ok: true })
|
|
expect(hostCalls.send).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('requires the host structured-chat setting for mobile clients', async () => {
|
|
const response = await call('agentSession.send', sendParams(), STRUCTURED_MOBILE_CLIENT, {
|
|
getClientSettings: () => ({ experimentalStructuredNativeChat: false })
|
|
})
|
|
expect(response).toMatchObject({
|
|
ok: false,
|
|
error: { message: expect.stringContaining('structured_agent_session_unsupported') }
|
|
})
|
|
expect(hostCalls.send).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('serves mobile clients only after capability and setting negotiation', async () => {
|
|
const response = await call('agentSession.send', sendParams(), STRUCTURED_MOBILE_CLIENT, {
|
|
getClientSettings: () => ({ experimentalStructuredNativeChat: true })
|
|
})
|
|
expect(response).toMatchObject({ ok: true })
|
|
expect(hostCalls.send).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('serves an in-process caller, which negotiates no capabilities at all', async () => {
|
|
const response = await call('agentSession.send', sendParams())
|
|
expect(response).toMatchObject({ ok: true })
|
|
})
|
|
|
|
it('reports the surface as absent when no host is installed', async () => {
|
|
setStructuredAgentSessionHost(null)
|
|
const response = await call('agentSession.send', sendParams(), STRUCTURED_CLIENT)
|
|
expect(response).toMatchObject({ ok: false })
|
|
})
|
|
})
|
|
|
|
describe('method routing', () => {
|
|
it('creates from a client intent while the host resolves paths and provider identity', async () => {
|
|
const worktree = 'id:workspace-1'
|
|
const params = {
|
|
envelope: envelope({
|
|
expectedRuntimeFence: null,
|
|
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
|
method: 'agentSession.create',
|
|
sessionId: SESSION,
|
|
fields: { worktree, agent: 'codex' }
|
|
})
|
|
}),
|
|
worktree,
|
|
agent: 'codex'
|
|
}
|
|
const created = await call('agentSession.create', params, STRUCTURED_CLIENT)
|
|
expect(created).toMatchObject({ ok: true, result: { ok: true } })
|
|
expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith(params)
|
|
expect(hostCalls.attach).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' },
|
|
options: { model: 'gpt-5.6-sol', effort: 'medium' }
|
|
})
|
|
)
|
|
expect(hostCalls.attach.mock.calls[0]?.[1]).not.toHaveProperty('providerHandle')
|
|
expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledWith(
|
|
expect.objectContaining({ sessionId: SESSION, activate: true })
|
|
)
|
|
})
|
|
|
|
it('routes Claude create support and create through the provider-aware runtime', async () => {
|
|
const worktree = 'id:workspace-1'
|
|
const support = await call(
|
|
'agentSession.createSupport',
|
|
{ worktree, agent: 'claude' },
|
|
STRUCTURED_CLIENT
|
|
)
|
|
expect(support).toMatchObject({ ok: true, result: { supported: true } })
|
|
expect(runtimeCalls.getStructuredAgentSessionCreateSupport).toHaveBeenCalledWith(
|
|
worktree,
|
|
'claude'
|
|
)
|
|
|
|
const params = {
|
|
envelope: envelope({
|
|
expectedRuntimeFence: null,
|
|
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
|
method: 'agentSession.create',
|
|
sessionId: SESSION,
|
|
fields: { worktree, agent: 'claude' }
|
|
})
|
|
}),
|
|
worktree,
|
|
agent: 'claude'
|
|
}
|
|
const created = await call('agentSession.create', params, STRUCTURED_CLIENT)
|
|
expect(created).toMatchObject({ ok: true, result: { ok: true } })
|
|
expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith(params)
|
|
expect(hostCalls.attach).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/host/.claude' }
|
|
})
|
|
)
|
|
expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sessionId: SESSION,
|
|
activate: true,
|
|
agent: 'claude'
|
|
})
|
|
)
|
|
})
|
|
|
|
it('reports an unknown create outcome when attach commits before tab publication fails', async () => {
|
|
const worktree = 'id:workspace-1'
|
|
const params = {
|
|
envelope: envelope({
|
|
expectedRuntimeFence: null,
|
|
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
|
method: 'agentSession.create',
|
|
sessionId: SESSION,
|
|
fields: { worktree, agent: 'codex' }
|
|
})
|
|
}),
|
|
worktree,
|
|
agent: 'codex'
|
|
}
|
|
|
|
const response = await call('agentSession.create', params, STRUCTURED_CLIENT, {
|
|
publishStructuredAgentSessionTab: vi.fn(async () => {
|
|
throw new Error('publish failed')
|
|
})
|
|
})
|
|
|
|
expect(hostCalls.attach).toHaveBeenCalledOnce()
|
|
expect(response).toMatchObject({
|
|
ok: true,
|
|
result: {
|
|
ok: false,
|
|
refusal: { code: 'agent_session_operation_unknown' }
|
|
}
|
|
})
|
|
})
|
|
|
|
it('separates create from ensure by the fence the client may declare', async () => {
|
|
const created = await call('agentSession.create', attachParams())
|
|
expect(created).toMatchObject({ ok: true })
|
|
|
|
const fenced = await call('agentSession.create', attachParams({ envelope: envelope() }))
|
|
expect(fenced).toMatchObject({ ok: false })
|
|
|
|
const ensured = await call('agentSession.ensure', attachParams({ envelope: envelope() }))
|
|
expect(ensured).toMatchObject({ ok: true })
|
|
})
|
|
|
|
/** A client-supplied location skips the worktree-resolving support check, so both attach-shaped
|
|
* entries must ask the executing host directly or a host that cannot fence a provider child
|
|
* would create one anyway. */
|
|
it('returns a refusal envelope when create cannot support a client-supplied location', async () => {
|
|
hostCalls.supportsCreate.mockReturnValue(false)
|
|
|
|
const refused = await call('agentSession.create', attachParams())
|
|
|
|
expect(refused).toMatchObject({
|
|
ok: true,
|
|
result: {
|
|
ok: false,
|
|
refusal: { code: 'structured_agent_session_unsupported' }
|
|
}
|
|
})
|
|
expect(hostCalls.attach).not.toHaveBeenCalled()
|
|
expect(hostCalls.supportsCreate).toHaveBeenCalledWith(attachParams().location, 'codex')
|
|
})
|
|
|
|
it('keeps ensure failures as top-level errors for an unsupported client location', async () => {
|
|
hostCalls.supportsCreate.mockReturnValue(false)
|
|
|
|
const refused = await call('agentSession.ensure', attachParams())
|
|
|
|
expect(refused).toMatchObject({
|
|
ok: false,
|
|
error: { message: expect.stringContaining('structured_agent_session_unsupported') }
|
|
})
|
|
expect(hostCalls.attach).not.toHaveBeenCalled()
|
|
expect(hostCalls.supportsCreate).toHaveBeenCalledWith(attachParams().location, 'codex')
|
|
})
|
|
|
|
it('tags the prompt kind from the method name, not from the client', async () => {
|
|
const params = {
|
|
envelope: envelope(),
|
|
itemId: 'item-1',
|
|
expectedRevision: 1,
|
|
optionId: 'allow'
|
|
}
|
|
await call('agentSession.respondToApproval', params, STRUCTURED_CLIENT)
|
|
await call('agentSession.respondToQuestion', params, STRUCTURED_CLIENT)
|
|
expect(hostCalls.respondToPrompt.mock.calls.map((invocation) => invocation[1].kind)).toEqual([
|
|
'approval',
|
|
'question'
|
|
])
|
|
})
|
|
|
|
it('routes an optional background task id through cancellation', async () => {
|
|
const params = {
|
|
envelope: envelope(),
|
|
turnId: 'background-tasks',
|
|
scope: 'background-tasks' as const,
|
|
taskId: 'task-2'
|
|
}
|
|
|
|
const response = await call('agentSession.cancel', params, STRUCTURED_CLIENT)
|
|
|
|
expect(response).toMatchObject({ ok: true })
|
|
expect(hostCalls.cancel).toHaveBeenCalledWith(expect.anything(), params)
|
|
})
|
|
|
|
it('routes the structured handoff mutation through the host', async () => {
|
|
const response = await call('agentSession.requestHandoff', {
|
|
envelope: envelope(),
|
|
direction: 'to-tui',
|
|
mode: 'now',
|
|
action: 'start'
|
|
})
|
|
|
|
expect(response).toMatchObject({ ok: true })
|
|
expect(hostCalls.requestHandoff).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ direction: 'to-tui', mode: 'now', action: 'start' })
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('parameter validation', () => {
|
|
const rejects = async (method: string, params: unknown): Promise<void> => {
|
|
const response = await call(method, params, STRUCTURED_CLIENT)
|
|
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
|
|
}
|
|
|
|
it('rejects an unknown key rather than dropping it', async () => {
|
|
await rejects('agentSession.send', { ...sendParams(), replyToItemId: 'item-1' })
|
|
await rejects('agentSession.send', {
|
|
...sendParams(),
|
|
envelope: { ...envelope(), priority: 'high' }
|
|
})
|
|
})
|
|
|
|
it('rejects invalid or unscoped background task ids', async () => {
|
|
await rejects('agentSession.cancel', {
|
|
envelope: envelope(),
|
|
turnId: 'background-tasks',
|
|
scope: 'background-tasks',
|
|
taskId: ' task-2'
|
|
})
|
|
await rejects('agentSession.cancel', {
|
|
envelope: envelope(),
|
|
turnId: 'turn-1',
|
|
taskId: 'task-2'
|
|
})
|
|
expect(hostCalls.cancel).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('refuses to let a client author anything but a user turn', async () => {
|
|
await rejects(
|
|
'agentSession.send',
|
|
sendParams({
|
|
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hi' }] }
|
|
})
|
|
)
|
|
await rejects(
|
|
'agentSession.send',
|
|
sendParams({
|
|
body: { kind: 'message', role: 'user', blocks: [{ type: 'tool-call', name: 'Bash' }] }
|
|
})
|
|
)
|
|
})
|
|
|
|
it('rejects a journal-only opaque provider handle', async () => {
|
|
await rejects(
|
|
'agentSession.create',
|
|
attachParams({ providerHandle: { kind: 'opaque', agent: 'codex', value: 'thread-1' } })
|
|
)
|
|
})
|
|
|
|
it('requires a sha256 fingerprint and a positive fence', async () => {
|
|
await rejects(
|
|
'agentSession.send',
|
|
sendParams({ envelope: envelope({ payloadFingerprint: 'f' }) })
|
|
)
|
|
await rejects(
|
|
'agentSession.send',
|
|
sendParams({ envelope: envelope({ payloadFingerprint: 'F'.repeat(64) }) })
|
|
)
|
|
await rejects(
|
|
'agentSession.send',
|
|
sendParams({ envelope: envelope({ expectedRuntimeFence: 0 }) })
|
|
)
|
|
})
|
|
|
|
it('requires the item revision on a prompt answer', async () => {
|
|
await rejects('agentSession.respondToApproval', {
|
|
envelope: envelope(),
|
|
itemId: 'item-1',
|
|
optionId: 'allow'
|
|
})
|
|
})
|
|
|
|
it('accepts the maximum fully encoded Claude choice group and retains a finite bound', async () => {
|
|
const maximumSelections = Array.from({ length: 4 }, (_, questionIndex) => ({
|
|
questionId: `q${questionIndex + 1}`,
|
|
optionIds: Array.from(
|
|
{ length: 4 },
|
|
(_, optionIndex) => `q${questionIndex + 1}:choice-${optionIndex + 1}`
|
|
)
|
|
}))
|
|
const optionId = `question-group:${encodeURIComponent(JSON.stringify(maximumSelections))}`
|
|
expect(optionId.length).toBe(610)
|
|
|
|
const response = await call(
|
|
'agentSession.respondToQuestion',
|
|
{
|
|
envelope: envelope(),
|
|
itemId: 'item-1',
|
|
expectedRevision: 1,
|
|
optionId
|
|
},
|
|
STRUCTURED_CLIENT
|
|
)
|
|
expect(response).toMatchObject({ ok: true })
|
|
expect(hostCalls.respondToPrompt).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ optionId })
|
|
)
|
|
|
|
await rejects('agentSession.respondToQuestion', {
|
|
envelope: envelope(),
|
|
itemId: 'item-1',
|
|
expectedRevision: 1,
|
|
optionId: 'x'.repeat(1025)
|
|
})
|
|
})
|
|
|
|
it('bounds a history page and validates its cursor', async () => {
|
|
await rejects('agentSession.history', {
|
|
sessionId: SESSION,
|
|
direction: 'tail',
|
|
limit: 100_000
|
|
})
|
|
await rejects('agentSession.history', { sessionId: SESSION, direction: 'sideways' })
|
|
await rejects('agentSession.history', {
|
|
sessionId: SESSION,
|
|
direction: 'after',
|
|
cursor: { epoch: 'epoch-1', sequence: -1 }
|
|
})
|
|
})
|
|
|
|
it('accepts a well-formed history request', async () => {
|
|
const response = await call(
|
|
'agentSession.history',
|
|
{
|
|
sessionId: SESSION,
|
|
direction: 'after',
|
|
cursor: { epoch: 'epoch-1', sequence: 4 },
|
|
limit: 40
|
|
},
|
|
STRUCTURED_CLIENT
|
|
)
|
|
expect(response).toMatchObject({ ok: true })
|
|
})
|
|
})
|
|
|
|
describe('agentSession.subscribeStatus', () => {
|
|
it('is invisible to a client without the structured capability', async () => {
|
|
const reply = await call('agentSession.subscribeStatus', null, { clientKind: 'runtime' })
|
|
expect(reply.ok).toBe(false)
|
|
expect(hostCalls.subscribeStatus).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('opens the host status feed with a projected snapshot as its first reply', async () => {
|
|
const reply = await call('agentSession.subscribeStatus', null, STRUCTURED_CLIENT)
|
|
expect(reply).toMatchObject({
|
|
ok: true,
|
|
result: {
|
|
type: 'snapshot',
|
|
sessions: [
|
|
{
|
|
sessionId: STATUS_SESSION,
|
|
workspaceId: 'workspace-1',
|
|
agent: 'codex',
|
|
status: 'working',
|
|
latestPrompt: 'write a poem',
|
|
updatedAt: 2
|
|
}
|
|
]
|
|
}
|
|
})
|
|
expect(hostCalls.subscribeStatus).toHaveBeenCalledOnce()
|
|
})
|
|
})
|