Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml # .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md # .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md # .agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml # .agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md # .agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md # .agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml # .agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md # .agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md # docs/architecture.i18n.yaml # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/defensive-patterns.i18n.yaml # packages/client/runtime/src/client/sessions/session.ts # packages/client/runtime/tests/queue-store.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/agent.ts # packages/core/agent/README.i18n.yaml # packages/core/agent/README.md # packages/core/agent/README.zh.md # packages/core/agent/src/types.ts # packages/core/agent/tests/agent.spec.ts # packages/core/scope/src/scoped-events.generated.ts # packages/goal/command-goal/tests/command-goal.spec.ts # packages/goal/goal-session/src/index.ts # packages/goal/goal-session/tests/goal-session.spec.ts # packages/goal/goal/tests/goal.spec.ts # packages/goal/goal/tests/projection.spec.ts # packages/goal/tool-goal/tests/tool-goal.spec.ts # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/events.schema.ts # packages/host/apiproxy/src/api/events.ts # packages/host/apiproxy/tests/api-proxy-workspace.spec.ts # packages/llm/llm/README.i18n.yaml # packages/llm/llm/README.zh.md # packages/llm/llm/src/index.ts # packages/pty/pty-local/tests/index.spec.ts # packages/pty/pty-local/tests/local.spec.ts # packages/pty/pty/tests/service.spec.ts # packages/pty/tool-pty/tests/loader-composition.spec.ts # packages/pty/tool-pty/tests/tools.spec.ts # packages/skill/tool-skill/tests/tool-skill.spec.ts # packages/tasks/tasks-local/tests/tasks.spec.ts # packages/ui/tui/src/index.ts # packages/ui/tui/tests/harness.ts # packages/ui/tui/tests/tui.spec.ts # scripts/gen-cordis-catalog.ts # scripts/type-equiv.manifest.json
This commit is contained in:
330
packages/host/apiproxy/tests/api-proxy-approval.spec.ts
Normal file
330
packages/host/apiproxy/tests/api-proxy-approval.spec.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* Approval pending registry over the proxy: an ask through `ctx.approval`
|
||||
* becomes an answerable `approval/requested` mux frame (stable rpcId, replayed
|
||||
* verbatim on a later mux open), `respond` routes by the echoed rpcId and
|
||||
* validates the audit correlation, and the ask's abort signal withdraws the
|
||||
* question with a broadcast `cancelled`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { RpcId as mintRpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
|
||||
async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(ApprovalService)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
return { ctx, api }
|
||||
}
|
||||
|
||||
/** A minimal agent stand-in inside an open turn (the service only reaches `.session`). */
|
||||
function agentOf(ctx: Context): Agent {
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
return { session } as unknown as Agent
|
||||
}
|
||||
|
||||
/** Open a mux stream and capture frames into an array (returns an on-demand waiter). */
|
||||
function openMux(api: ApiProxy, abort: AbortController): { frames: MuxFrame[]; envelopes: RpcRequest<MuxFrame>[]; waitFor(type: MuxFrame['type']): Promise<MuxFrame> } {
|
||||
const frames: MuxFrame[] = []
|
||||
const envelopes: RpcRequest<MuxFrame>[] = []
|
||||
const waiters: { type: MuxFrame['type']; resolve: (frame: MuxFrame) => void }[] = []
|
||||
void (async () => {
|
||||
for await (const envelope of api.events.mux({ rpcId: mintRpcId('t-mux'), payload: {} }, abort.signal)) {
|
||||
frames.push(envelope.payload)
|
||||
envelopes.push(envelope)
|
||||
for (let i = waiters.length - 1; i >= 0; i -= 1) {
|
||||
const waiter = waiters[i] as (typeof waiters)[number]
|
||||
if (waiter.type === envelope.payload.type) {
|
||||
waiters.splice(i, 1)
|
||||
waiter.resolve(envelope.payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
})()
|
||||
return {
|
||||
frames,
|
||||
envelopes,
|
||||
waitFor: (type) => {
|
||||
const found = frames.find(frame => frame.type === type)
|
||||
if (found !== undefined) return Promise.resolve(found)
|
||||
return new Promise((resolve) => { waiters.push({ type, resolve }) })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function requestedOf(frame: MuxFrame): Extract<MuxFrame, { type: 'approval/requested' }> {
|
||||
if (frame.type !== 'approval/requested') throw new Error(`expected approval/requested, got ${frame.type}`)
|
||||
return frame
|
||||
}
|
||||
|
||||
/** Wait until the stream delivered `count` frames of `type` (bounded poll; waitFor only covers the first). */
|
||||
async function waitForCount(mux: { frames: MuxFrame[] }, type: MuxFrame['type'], count: number): Promise<void> {
|
||||
for (let i = 0; i < 200 && mux.frames.filter(frame => frame.type === type).length < count; i += 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
}
|
||||
expect(mux.frames.filter(frame => frame.type === type).length).toBeGreaterThanOrEqual(count)
|
||||
}
|
||||
|
||||
function answer(rpcId: RpcId, sessionId: unknown, approvalId: ApprovalRequestId, outcome: 'allowed-once' | 'rejected'): Parameters<ApiProxy['respond']>[0] {
|
||||
return { type: 'client-response', rpcId, result: { ok: true, value: { sessionId, approvalId, outcome } } }
|
||||
}
|
||||
|
||||
describe('approval pending registry', () => {
|
||||
it('round-trips ask → requested frame → respond → outcome + resolved broadcast', async () => {
|
||||
const { ctx, api } = await harness()
|
||||
const abort = new AbortController()
|
||||
const mux = openMux(api, abort)
|
||||
const agent = agentOf(ctx)
|
||||
|
||||
const asked = ctx.approval.request({ agent, toolName: 'bash', reason: 'sandbox escalation' })
|
||||
const requested = requestedOf(await mux.waitFor('approval/requested'))
|
||||
expect(requested).toMatchObject({ toolName: 'bash', reason: 'sandbox escalation', sessionId: agent.session.id })
|
||||
|
||||
const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
|
||||
const receipt = await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once'))
|
||||
expect(receipt).toEqual({ accepted: true })
|
||||
await expect(asked).resolves.toBe('allowed-once')
|
||||
|
||||
const resolved = await mux.waitFor('approval/resolved')
|
||||
expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'allowed-once' })
|
||||
|
||||
// The question settled: a duplicate answer is late, not re-decidable.
|
||||
const dup = await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'rejected'))
|
||||
expect(dup).toEqual({ accepted: false, reason: 'not-pending' })
|
||||
abort.abort()
|
||||
})
|
||||
|
||||
it('replays a still-pending requested frame (same rpcId) on a later mux open', async () => {
|
||||
const { ctx, api } = await harness()
|
||||
const first = new AbortController()
|
||||
const firstMux = openMux(api, first)
|
||||
const agent = agentOf(ctx)
|
||||
const asked = ctx.approval.request({ agent, toolName: 'write' })
|
||||
const requested = requestedOf(await firstMux.waitFor('approval/requested'))
|
||||
const firstEnvelope = firstMux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
|
||||
first.abort()
|
||||
|
||||
// A fresh subscriber (refresh recovery) sees the same stable rpcId.
|
||||
const second = new AbortController()
|
||||
const secondMux = openMux(api, second)
|
||||
const replayed = requestedOf(await secondMux.waitFor('approval/requested'))
|
||||
const secondEnvelope = secondMux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
|
||||
expect(secondEnvelope.rpcId).toBe(firstEnvelope.rpcId)
|
||||
expect(replayed.approvalId).toBe(requested.approvalId)
|
||||
|
||||
const receipt = await api.respond(answer(secondEnvelope.rpcId, replayed.sessionId, replayed.approvalId, 'rejected'))
|
||||
expect(receipt).toEqual({ accepted: true })
|
||||
await expect(asked).resolves.toBe('rejected')
|
||||
second.abort()
|
||||
})
|
||||
|
||||
it('rejects malformed and mismatched answers as bad-response, unknown ids as not-pending', async () => {
|
||||
const { ctx, api } = await harness()
|
||||
const abort = new AbortController()
|
||||
const mux = openMux(api, abort)
|
||||
const agent = agentOf(ctx)
|
||||
void ctx.approval.request({ agent, toolName: 'bash' })
|
||||
const requested = requestedOf(await mux.waitFor('approval/requested'))
|
||||
const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
|
||||
|
||||
// Unknown rpcId: not routed to any pending entry.
|
||||
expect(await api.respond(answer(mintRpcId('ghost'), requested.sessionId, requested.approvalId, 'rejected')))
|
||||
.toEqual({ accepted: false, reason: 'not-pending' })
|
||||
// Error-branch result: the client can only answer with a value.
|
||||
expect(await api.respond({ type: 'client-response', rpcId: envelope.rpcId, result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } }))
|
||||
.toEqual({ accepted: false, reason: 'bad-response' })
|
||||
// Wrong audit correlation: the rpcId routed, but the payload disagrees.
|
||||
expect(await api.respond(answer(envelope.rpcId, requested.sessionId, 'other-approval' as ApprovalRequestId, 'rejected')))
|
||||
.toEqual({ accepted: false, reason: 'bad-response' })
|
||||
// Malformed payload shape.
|
||||
expect(await api.respond({ type: 'client-response', rpcId: envelope.rpcId, result: { ok: true, value: { nonsense: 1 } } }))
|
||||
.toEqual({ accepted: false, reason: 'bad-response' })
|
||||
abort.abort()
|
||||
})
|
||||
|
||||
it('withdraws the question on the ask signal: cancelled outcome, resolved broadcast, late answer not-pending', async () => {
|
||||
const { ctx, api } = await harness()
|
||||
const abort = new AbortController()
|
||||
const mux = openMux(api, abort)
|
||||
const agent = agentOf(ctx)
|
||||
const cancel = new AbortController()
|
||||
const asked = ctx.approval.request({ agent, toolName: 'bash', signal: cancel.signal })
|
||||
const requested = requestedOf(await mux.waitFor('approval/requested'))
|
||||
const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
|
||||
|
||||
cancel.abort()
|
||||
await expect(asked).resolves.toBe('cancelled')
|
||||
const resolved = await mux.waitFor('approval/resolved')
|
||||
expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'cancelled' })
|
||||
expect(await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once')))
|
||||
.toEqual({ accepted: false, reason: 'not-pending' })
|
||||
abort.abort()
|
||||
})
|
||||
|
||||
it('an ask whose signal aborted before dispatch settles cancelled without publishing', async () => {
|
||||
// The service checks the signal, then dispatch rides a microtask: an
|
||||
// abort in that window must not register a dead listener and strand the
|
||||
// entry (zombie frame on every replay). Drive the waterfall directly
|
||||
// with a pre-aborted signal to hit the answerer's register-path guard.
|
||||
const { ctx, api } = await harness()
|
||||
const abort = new AbortController()
|
||||
const mux = openMux(api, abort)
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('approval/asked', { id: 'pre-aborted' as ApprovalRequestId, toolName: 'bash' })
|
||||
const agent = { session } as unknown as Agent
|
||||
const cancelled = new AbortController()
|
||||
cancelled.abort()
|
||||
const outcome = await ctx.waterfall(
|
||||
'approval/request',
|
||||
{ agent, toolName: 'bash', signal: cancelled.signal },
|
||||
() => Promise.resolve('unavailable' as const),
|
||||
)
|
||||
expect(outcome).toBe('cancelled')
|
||||
// Nothing was published: a fresh mux open replays no approval frame.
|
||||
const abort2 = new AbortController()
|
||||
const mux2 = openMux(api, abort2)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
expect(mux2.envelopes.some(e => e.payload.type === 'approval/requested')).toBe(false)
|
||||
abort2.abort()
|
||||
abort.abort()
|
||||
void mux
|
||||
})
|
||||
|
||||
it('gateway teardown settles pending approvals as cancelled (question-provider parity)', async () => {
|
||||
// Mount the proxy on its own fiber so disposal exercises the teardown
|
||||
// effect while an ask is still pending.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(ApprovalService)
|
||||
let api!: ApiProxy
|
||||
const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => {
|
||||
api = createApiProxy(fiberCtx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
}, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] }))
|
||||
await fiber.await()
|
||||
const abort = new AbortController()
|
||||
const mux = openMux(api, abort)
|
||||
const asked = ctx.approval.request({ agent: agentOf(ctx), toolName: 'bash' })
|
||||
const requested = requestedOf(await mux.waitFor('approval/requested'))
|
||||
await fiber.dispose()
|
||||
await expect(asked).resolves.toBe('cancelled')
|
||||
const resolved = await mux.waitFor('approval/resolved')
|
||||
expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'cancelled' })
|
||||
abort.abort()
|
||||
})
|
||||
|
||||
it('carries callId on the frame and ignores a late abort after the answer settled', async () => {
|
||||
const { ctx, api } = await harness()
|
||||
const abort = new AbortController()
|
||||
const mux = openMux(api, abort)
|
||||
const agent = agentOf(ctx)
|
||||
const cancel = new AbortController()
|
||||
const asked = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-9' as never, signal: cancel.signal })
|
||||
const requested = requestedOf(await mux.waitFor('approval/requested'))
|
||||
expect(requested.callId).toBe('call-9')
|
||||
const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
|
||||
expect(await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once')))
|
||||
.toEqual({ accepted: true })
|
||||
await expect(asked).resolves.toBe('allowed-once')
|
||||
// Late abort: the pending entry is gone; settle's delete-guard returns.
|
||||
cancel.abort()
|
||||
expect(mux.frames.filter(f => f.type === 'approval/resolved')).toHaveLength(1)
|
||||
abort.abort()
|
||||
})
|
||||
|
||||
it('pairs parallel asks by callId: each requested frame carries its own audit id', async () => {
|
||||
const { ctx, api } = await harness()
|
||||
const abort = new AbortController()
|
||||
const mux = openMux(api, abort)
|
||||
const agent = agentOf(ctx)
|
||||
// Both asks append their approval/asked audit events before either
|
||||
// answerer's microtask dispatch runs — the parallel tool-call window.
|
||||
const askA = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-a' as never })
|
||||
const askB = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-b' as never })
|
||||
await waitForCount(mux, 'approval/requested', 2)
|
||||
const frames = mux.envelopes.filter(e => e.payload.type === 'approval/requested')
|
||||
const frameA = frames.find(e => requestedOf(e.payload).callId === 'call-a') as RpcRequest<MuxFrame>
|
||||
const frameB = frames.find(e => requestedOf(e.payload).callId === 'call-b') as RpcRequest<MuxFrame>
|
||||
// Each frame claimed the asked event with its own callId, not merely the newest.
|
||||
const askedIdByCall = new Map(agent.session.events
|
||||
.filter(event => event.type === 'approval/asked')
|
||||
.map(event => [String(event.data.callId), event.data.id]))
|
||||
expect(requestedOf(frameA.payload).approvalId).toBe(askedIdByCall.get('call-a'))
|
||||
expect(requestedOf(frameB.payload).approvalId).toBe(askedIdByCall.get('call-b'))
|
||||
// Answers route back to the right ask through the pairing.
|
||||
expect(await api.respond(answer(frameB.rpcId, agent.session.id, requestedOf(frameB.payload).approvalId, 'rejected')))
|
||||
.toEqual({ accepted: true })
|
||||
expect(await api.respond(answer(frameA.rpcId, agent.session.id, requestedOf(frameA.payload).approvalId, 'allowed-once')))
|
||||
.toEqual({ accepted: true })
|
||||
await expect(askA).resolves.toBe('allowed-once')
|
||||
await expect(askB).resolves.toBe('rejected')
|
||||
abort.abort()
|
||||
})
|
||||
|
||||
it('gives parallel callId-less asks distinct audit ids (claimed-entry skip); both stay answerable', async () => {
|
||||
const { ctx, api } = await harness()
|
||||
const abort = new AbortController()
|
||||
const mux = openMux(api, abort)
|
||||
const agent = agentOf(ctx)
|
||||
const askA = ctx.approval.request({ agent, toolName: 'alpha' })
|
||||
const askB = ctx.approval.request({ agent, toolName: 'beta' })
|
||||
await waitForCount(mux, 'approval/requested', 2)
|
||||
const frames = mux.envelopes.filter(e => e.payload.type === 'approval/requested')
|
||||
const frameA = frames.find(e => requestedOf(e.payload).toolName === 'alpha') as RpcRequest<MuxFrame>
|
||||
const frameB = frames.find(e => requestedOf(e.payload).toolName === 'beta') as RpcRequest<MuxFrame>
|
||||
// Without a callId the pairing is heuristic, but never shared: the second
|
||||
// dispatch skips the id the first pending entry already claimed.
|
||||
expect(requestedOf(frameA.payload).approvalId).not.toBe(requestedOf(frameB.payload).approvalId)
|
||||
expect(await api.respond(answer(frameA.rpcId, agent.session.id, requestedOf(frameA.payload).approvalId, 'allowed-once')))
|
||||
.toEqual({ accepted: true })
|
||||
expect(await api.respond(answer(frameB.rpcId, agent.session.id, requestedOf(frameB.payload).approvalId, 'rejected')))
|
||||
.toEqual({ accepted: true })
|
||||
await expect(askA).resolves.toBe('allowed-once')
|
||||
await expect(askB).resolves.toBe('rejected')
|
||||
abort.abort()
|
||||
})
|
||||
|
||||
it('delegates a dispatch whose only asked candidate is already decided (stale re-dispatch)', async () => {
|
||||
const { ctx, api } = await harness()
|
||||
void api // the answerer is registered; the fake below bypasses the service
|
||||
// Bypass ApprovalService: a log whose sole asked event already has its
|
||||
// decided partner must not be re-claimed — the answerer delegates.
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('approval/asked', { id: 'stale-ask' as ApprovalRequestId, toolName: 'bash' })
|
||||
session.append('approval/decided', { id: 'stale-ask' as ApprovalRequestId, outcome: 'rejected' })
|
||||
const agent = { session } as unknown as Agent
|
||||
const outcome = await ctx.waterfall('approval/request', { agent, toolName: 'bash' }, () => Promise.resolve('unavailable' as const))
|
||||
expect(outcome).toBe('unavailable')
|
||||
})
|
||||
|
||||
it('delegates an ask whose session log carries no asked audit event (foreign channel)', async () => {
|
||||
const { ctx, api } = await harness()
|
||||
void api // the answerer is registered; the fake below bypasses the audit path
|
||||
// Bypass ApprovalService: dispatch the waterfall directly with a session
|
||||
// that has no approval/asked event — the proxy answerer must call next().
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const agent = { session } as unknown as Agent
|
||||
const outcome = await ctx.waterfall('approval/request', { agent, toolName: 'x' }, () => Promise.resolve('unavailable' as const))
|
||||
expect(outcome).toBe('unavailable')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The summary blank bit means "conversation not started" (no turn has run),
|
||||
* not "log empty": standalone plugin events — command lifecycle records,
|
||||
* plan/mode, session titles — never flip it, so running /plan or /goal on a
|
||||
* plan/mode, permission knob events, session titles — never flip it, so running /plan or /goal on a
|
||||
* fresh session keeps it list-hidden and reusable, while the first accepted
|
||||
* prompt's turn/start clears it. The host/session-added frame shares the
|
||||
* same predicate function (covered by the workspace spec's frame assertion).
|
||||
@@ -15,6 +15,10 @@ import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
// Side-effect type imports: the knob-event SessionEventMap merges.
|
||||
import type {} from '@deepseek-ai/dsh-permission'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ApiProxy, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
@@ -48,6 +52,10 @@ function appendStandalone(session: Session): void {
|
||||
session.append('session/title', {
|
||||
title: 'standalone title', messageSeqs: [], source: { kind: 'fallback' },
|
||||
})
|
||||
// The three permission knob events (a /permission switch on a fresh session).
|
||||
session.append('permission/preset', { preset: 'danger-full-access' })
|
||||
session.append('sandbox/mode', { mode: 'danger-full-access' })
|
||||
session.append('approval/policy', { policy: 'never' })
|
||||
}
|
||||
|
||||
async function listBlank(api: ApiProxy, id: string): Promise<boolean | undefined> {
|
||||
|
||||
@@ -9,10 +9,10 @@ import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm'
|
||||
* open-time queue snapshot.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, {} from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { InboxItemId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, InboxItem, InboxPlacement } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -180,17 +180,35 @@ describe('skill.list', () => {
|
||||
it('lists skills for the session cwd taken from the header', async () => {
|
||||
const ctx = await harness()
|
||||
const seenCwds: (string | undefined)[] = []
|
||||
ctx.skills.registerProvider({
|
||||
ctx.skills.registerProvider(() => ({
|
||||
name: 'probe',
|
||||
list: (options) => {
|
||||
seenCwds.push(options.cwd)
|
||||
return Promise.resolve([{
|
||||
name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing',
|
||||
source: 'custom', provider: 'probe', rank: 0, locator: null,
|
||||
}])
|
||||
return Promise.resolve([
|
||||
{
|
||||
name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
source: 'custom', provider: 'probe', rank: 0, locator: null,
|
||||
},
|
||||
{
|
||||
name: 'user-only', description: 'User-only',
|
||||
invocation: { modelInvocable: false, userInvocable: true },
|
||||
source: 'custom', provider: 'probe', rank: 0, locator: null,
|
||||
},
|
||||
{
|
||||
name: 'model-only', description: 'Model-only',
|
||||
invocation: { modelInvocable: true, userInvocable: false },
|
||||
source: 'custom', provider: 'probe', rank: 0, locator: null,
|
||||
},
|
||||
{
|
||||
name: 'trusted-only', description: 'Trusted-only',
|
||||
invocation: { modelInvocable: false, userInvocable: false },
|
||||
source: 'custom', provider: 'probe', rank: 0, locator: null,
|
||||
},
|
||||
])
|
||||
},
|
||||
get: () => Promise.resolve(undefined),
|
||||
})
|
||||
}))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
// No agent is registered for this session: header resolution must not
|
||||
// touch (or resume through) the Agent registry.
|
||||
@@ -219,11 +237,11 @@ describe('skill.list', () => {
|
||||
|
||||
it('folds a provider failure into internal', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.skills.registerProvider({
|
||||
ctx.skills.registerProvider(() => ({
|
||||
name: 'broken',
|
||||
list: () => Promise.reject(new Error('directory exploded')),
|
||||
get: () => Promise.resolve(undefined),
|
||||
})
|
||||
}))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
|
||||
const response = await api.skills.list(request({ sessionId: session.id }))
|
||||
@@ -256,84 +274,159 @@ function inboxMessage(id: string, text: string, rpcId?: string): UserMessage {
|
||||
})
|
||||
}
|
||||
|
||||
describe('session/queued frames', () => {
|
||||
it('forwards live enqueue events and replays the snapshot on a later mux open', async () => {
|
||||
/** Build one addressable inbox occurrence around a frozen message. */
|
||||
function inboxItem(id: string, message: UserMessage, placement: InboxPlacement): InboxItem {
|
||||
return { id: InboxItemId(id), message, placement }
|
||||
}
|
||||
|
||||
describe('session.updateQueue', () => {
|
||||
it('routes an addressable action and reports a lost claim race', async () => {
|
||||
const ctx = await harness()
|
||||
const agent = stubAgent(ctx)
|
||||
const seen: unknown[] = []
|
||||
agent.updateInbox = (id, action) => {
|
||||
seen.push({ id, action })
|
||||
return id === InboxItemId('present') ? 'applied' : 'not-found'
|
||||
}
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
|
||||
const applied = await api.sessions.updateQueue({
|
||||
rpcId: RpcId('q-apply'),
|
||||
payload: {
|
||||
sessionId: agent.id,
|
||||
itemId: InboxItemId('present'),
|
||||
action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] },
|
||||
},
|
||||
})
|
||||
expect(expectOk(applied)).toEqual({ accepted: true })
|
||||
const missing = await api.sessions.updateQueue({
|
||||
rpcId: RpcId('q-missing'),
|
||||
payload: {
|
||||
sessionId: agent.id,
|
||||
itemId: InboxItemId('claimed'),
|
||||
action: { kind: 'remove' },
|
||||
},
|
||||
})
|
||||
expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' })
|
||||
expect(seen).toEqual([
|
||||
{ id: 'present', action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] } },
|
||||
{ id: 'claimed', action: { kind: 'remove' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a stale occurrence without resuming a cold agent', async () => {
|
||||
const ctx = await harness()
|
||||
const resume = vi.spyOn(ctx.agents, 'resume')
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const response = await api.sessions.updateQueue({
|
||||
rpcId: RpcId('q-cold'),
|
||||
payload: {
|
||||
sessionId: 'cold-session' as SessionId,
|
||||
itemId: InboxItemId('stale-item'),
|
||||
action: { kind: 'remove' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(expectErr(response)).toMatchObject({ code: 'queue-item-not-found' })
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('session/queue frames', () => {
|
||||
it('folds nested mutations observed before their outer enqueue', async () => {
|
||||
const ctx = await harness()
|
||||
const agent = stubAgent(ctx)
|
||||
const original = inboxItem('i-edit', inboxMessage('m-edit', 'before'), 'queued')
|
||||
const edited = inboxItem('i-edit', inboxMessage('m-edit', 'after'), 'queued')
|
||||
const removed = inboxItem('i-remove', inboxMessage('m-remove', 'remove me'), 'queued')
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject !== agent) return
|
||||
if (item.id === original.id) ctx.emit('agent/inbox/update', agent, edited)
|
||||
if (item.id === removed.id) ctx.emit('agent/inbox/discard', agent, [removed])
|
||||
})
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const live = new AbortController()
|
||||
const collected = collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-reentrant'), payload: {} }, live.signal), 2, live)
|
||||
|
||||
ctx.emit('agent/inbox/enqueue', agent, original)
|
||||
ctx.emit('agent/inbox/enqueue', agent, removed)
|
||||
|
||||
const liveFrames = (await collected).filter(frame => frame.type === 'session/queue')
|
||||
expect(liveFrames.map(frame => frame.items)).toEqual([
|
||||
[{ id: edited.id, message: edited.message }],
|
||||
])
|
||||
const replay = new AbortController()
|
||||
const replayFrames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-reentrant-replay'), payload: {} }, replay.signal), 2, replay)
|
||||
expect(replayFrames.filter(frame => frame.type === 'session/queue')).toEqual(liveFrames)
|
||||
})
|
||||
|
||||
it('publishes complete live snapshots and replays the latest snapshot on reconnect', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const live = new AbortController()
|
||||
const liveStream = api.events.mux({ rpcId: RpcId('t-mux-live'), payload: {} }, live.signal)
|
||||
// subscribed baseline + 2 queued frames
|
||||
const liveCollected = collect<MuxFrame>(liveStream, 3, live)
|
||||
// subscribed baseline + one queued snapshot; pending steering stays off this wire.
|
||||
const liveCollected = collect<MuxFrame>(liveStream, 2, live)
|
||||
|
||||
const queued = inboxMessage('m-1', 'queued prompt')
|
||||
const steering = inboxMessage('m-2', 'queued prompt')
|
||||
ctx.emit('agent/inbox/enqueue', agent, queued, 'queued')
|
||||
ctx.emit('agent/inbox/enqueue', agent, steering, 'steering')
|
||||
const queued = inboxItem('i-1', inboxMessage('m-1', 'queued prompt'), 'queued')
|
||||
const steering = inboxItem('i-2', inboxMessage('m-2', 'steering prompt'), 'steering')
|
||||
ctx.emit('agent/inbox/enqueue', agent, queued)
|
||||
ctx.emit('agent/inbox/enqueue', agent, steering)
|
||||
|
||||
const liveFrames = (await liveCollected).filter(f => f.type === 'session/queued')
|
||||
const liveFrames = (await liveCollected).filter(f => f.type === 'session/queue')
|
||||
expect(liveFrames).toEqual([
|
||||
{ type: 'session/queued', sessionId: agent.id, message: queued, steering: false },
|
||||
{ type: 'session/queued', sessionId: agent.id, message: steering, steering: true },
|
||||
{
|
||||
type: 'session/queue',
|
||||
sessionId: agent.id,
|
||||
items: [{ id: queued.id, message: queued.message }],
|
||||
},
|
||||
])
|
||||
|
||||
// A fresh mux connection replays the still-pending entries as its baseline.
|
||||
// A fresh mux connection replays only the current authoritative snapshot.
|
||||
const replay = new AbortController()
|
||||
const replayFrames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 3, replay)
|
||||
expect(replayFrames.filter(f => f.type === 'session/queued')).toEqual(liveFrames)
|
||||
api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 2, replay)
|
||||
expect(replayFrames.filter(f => f.type === 'session/queue')).toEqual([liveFrames[0]])
|
||||
})
|
||||
|
||||
it('retires mirror entries on their terminal dequeue', async () => {
|
||||
it('publishes edits in place in the authoritative order', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const queued = inboxMessage('m-3', 'x')
|
||||
const steering = inboxMessage('m-4', 'x', 'r-1')
|
||||
ctx.emit('agent/inbox/enqueue', agent, queued, 'queued')
|
||||
ctx.emit('agent/inbox/enqueue', agent, steering, 'steering')
|
||||
ctx.emit('agent/inbox/dequeue', agent, queued, 'queued')
|
||||
ctx.emit('agent/inbox/dequeue', agent, steering, 'steering')
|
||||
|
||||
const abort = new AbortController()
|
||||
const frames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-after'), payload: {} }, abort.signal), 1, abort)
|
||||
expect(frames.filter(f => f.type === 'session/queued')).toHaveLength(0)
|
||||
})
|
||||
const collected = collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-updates'), payload: {} }, abort.signal), 5, abort)
|
||||
const first = inboxItem('i-a', inboxMessage('m-a', 'a'), 'queued')
|
||||
const second = inboxItem('i-b', inboxMessage('m-b', 'b'), 'queued')
|
||||
const edited = inboxItem('i-b', inboxMessage('m-b', 'b edited'), 'queued')
|
||||
ctx.emit('agent/inbox/enqueue', agent, first)
|
||||
ctx.emit('agent/inbox/enqueue', agent, second)
|
||||
ctx.emit('agent/inbox/update', agent, edited)
|
||||
ctx.emit('agent/inbox/dequeue', agent, edited)
|
||||
|
||||
it('retires the matching placement when one message identity is queued and steering', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const repeated = inboxMessage('m-repeat', 'same prompt')
|
||||
ctx.emit('agent/inbox/enqueue', agent, repeated, 'queued')
|
||||
ctx.emit('agent/inbox/enqueue', agent, repeated, 'steering')
|
||||
ctx.emit('agent/inbox/dequeue', agent, inboxMessage('unknown', 'not queued'), 'queued')
|
||||
ctx.emit('agent/inbox/dequeue', agent, repeated, 'steering')
|
||||
|
||||
const abort = new AbortController()
|
||||
const frames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-repeat'), payload: {} }, abort.signal), 2, abort)
|
||||
expect(frames.filter(f => f.type === 'session/queued')).toEqual([
|
||||
{ type: 'session/queued', sessionId: agent.id, message: repeated, steering: false },
|
||||
const frames = (await collected).filter(frame => frame.type === 'session/queue')
|
||||
expect(frames.map(frame => frame.items)).toEqual([
|
||||
[{ id: first.id, message: first.message }],
|
||||
[{ id: first.id, message: first.message }, { id: second.id, message: second.message }],
|
||||
[{ id: first.id, message: first.message }, { id: edited.id, message: edited.message }],
|
||||
[{ id: first.id, message: first.message }],
|
||||
])
|
||||
})
|
||||
|
||||
it('retires mirror entries on a batch discard (cancel path)', async () => {
|
||||
it('publishes an empty snapshot after terminal discard', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const agent = stubAgent(ctx)
|
||||
const doomed = inboxMessage('m-5', 'doomed')
|
||||
const survivor = inboxMessage('m-6', 'survivor')
|
||||
ctx.emit('agent/inbox/enqueue', agent, doomed, 'queued')
|
||||
ctx.emit('agent/inbox/enqueue', agent, survivor, 'queued')
|
||||
const doomed = inboxItem('i-doomed', inboxMessage('m-5', 'doomed'), 'queued')
|
||||
ctx.emit('agent/inbox/enqueue', agent, doomed)
|
||||
ctx.emit('agent/inbox/discard', agent, [doomed])
|
||||
|
||||
const abort = new AbortController()
|
||||
const frames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-swept'), payload: {} }, abort.signal), 2, abort)
|
||||
const remaining = frames.filter(f => f.type === 'session/queued')
|
||||
expect(remaining).toHaveLength(1)
|
||||
expect(remaining[0]).toMatchObject({ message: survivor })
|
||||
api.events.mux({ rpcId: RpcId('t-mux-swept'), payload: {} }, abort.signal), 1, abort)
|
||||
expect(frames.filter(frame => frame.type === 'session/queue')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
129
packages/host/apiproxy/tests/api-proxy-rename.spec.ts
Normal file
129
packages/host/apiproxy/tests/api-proxy-rename.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* sessions.rename delegation through the composed SessionTitleService. The
|
||||
* agent factory is a structural stub whose createAgent forwards seed/meta into
|
||||
* the real SessionStore, and whose resume never runs (every source here is
|
||||
* already attached). Cold-session resolution is the shared `agentFor` path —
|
||||
* api-proxy-cold.spec.ts owns the resume evidence for every unary that rides
|
||||
* it, rename included.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionTitleService from '@deepseek-ai/dsh-session-title'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
|
||||
let nextRpc = 1
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`fr-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
|
||||
async function composed(withTitles = true): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
if (withTitles) {
|
||||
await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 40 })
|
||||
}
|
||||
// Store-backed structural factory: create builds the session with the
|
||||
// forwarded seed/meta (the store validates the balanced prefix) and
|
||||
// registers an idle agent stub over it.
|
||||
ctx.agents.setFactory({
|
||||
createAgent: (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
|
||||
const session = ctx.sessions.create(options.sessionId, {
|
||||
...options.seed === undefined ? {} : { seed: [...options.seed] },
|
||||
...options.meta === undefined ? {} : { meta: options.meta },
|
||||
})
|
||||
const agent = { id: session.id, session, status: 'idle', ctx: ownerCtx } as Agent
|
||||
ctx.agents.register(agent)
|
||||
return Promise.resolve({ agent, dispose: () => Promise.resolve() })
|
||||
},
|
||||
resume: () => Promise.reject(new Error('resume must not run: every source is attached')),
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Register one live agent whose log holds `turns` completed turns. */
|
||||
function liveAgent(ctx: Context, id: string, turns: number): Session {
|
||||
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } })
|
||||
for (let turn = 1; turn <= turns; turn++) {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `prompt ${String(turn)}` }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
return session
|
||||
}
|
||||
|
||||
const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
describe('sessions.rename', () => {
|
||||
it('accepts through the composed title service: normalized user-source event, echoed seq', async () => {
|
||||
const ctx = await composed()
|
||||
const source = liveAgent(ctx, 'session-rename', 1)
|
||||
|
||||
const renamed = await api(ctx).sessions.rename(request({ sessionId: source.id, title: ' new name ' }))
|
||||
expect(renamed.result.ok).toBe(true)
|
||||
if (!renamed.result.ok) return
|
||||
expect(renamed.result.value.title).toBe('new name')
|
||||
const event = source.events.findLast(item => item.type === 'session/title')
|
||||
expect(event?.seq).toBe(renamed.result.value.seq)
|
||||
expect(event?.data).toMatchObject({ title: 'new name', source: { kind: 'user' } })
|
||||
})
|
||||
|
||||
it('maps only an empty-normalizing title to title-invalid, with a presentable message', async () => {
|
||||
const ctx = await composed()
|
||||
const source = liveAgent(ctx, 'session-rename-bad', 1)
|
||||
|
||||
// U+200B passes a client-side trim gate but normalizes to empty host-side.
|
||||
const response = await api(ctx).sessions.rename(request({ sessionId: source.id, title: ' ' }))
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (!response.result.ok) {
|
||||
expect(response.result.error).toMatchObject({
|
||||
code: 'title-invalid',
|
||||
details: { sessionId: source.id },
|
||||
})
|
||||
// The message renders verbatim in the rename dialog's alert.
|
||||
expect(response.result.error.message).toBe('session title must contain visible characters')
|
||||
}
|
||||
})
|
||||
|
||||
it('maps a non-validation rename failure (stale session object) to internal, not title-invalid', async () => {
|
||||
const ctx = await composed()
|
||||
// The registered agent holds a session object from another store: the
|
||||
// title service's liveness check throws a plain Error, which must not
|
||||
// read as the user's fault.
|
||||
const foreign = await composed(false)
|
||||
const stale = liveAgent(foreign, 'session-rename-stale', 1)
|
||||
ctx.agents.register({ id: stale.id, session: stale, status: 'idle', ctx } as Agent)
|
||||
|
||||
const response = await api(ctx).sessions.rename(request({ sessionId: stale.id, title: 'name' }))
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (!response.result.ok) expect(response.result.error.code).toBe('internal')
|
||||
})
|
||||
|
||||
it('answers internal when the composition mounts no session-title service', async () => {
|
||||
const ctx = await composed(false)
|
||||
const source = liveAgent(ctx, 'session-no-titles', 1)
|
||||
|
||||
const response = await api(ctx).sessions.rename(request({ sessionId: source.id, title: 'name' }))
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (!response.result.ok) {
|
||||
expect(response.result.error.code).toBe('internal')
|
||||
expect(response.result.error.message).toMatch(/mounts no session-title service/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -47,6 +47,8 @@ function stubAgent(session: Session): Agent {
|
||||
status: 'idle',
|
||||
acceptsNextStep: false,
|
||||
ctx: new Context(),
|
||||
send: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject: () => {},
|
||||
|
||||
@@ -46,7 +46,9 @@ function scriptedApi(overrides: {
|
||||
selectModel: r => ok(r, {
|
||||
selected: { provider: r.payload.provider, model: r.payload.model },
|
||||
}),
|
||||
rename: r => ok(r, { title: 'renamed', seq: 0 }),
|
||||
prompt: r => ok(r, { accepted: true as const }),
|
||||
updateQueue: r => ok(r, { accepted: true as const }),
|
||||
cancel: r => ok(r, { accepted: true as const }),
|
||||
...overrides.sessions,
|
||||
},
|
||||
|
||||
@@ -67,9 +67,15 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
},
|
||||
}
|
||||
},
|
||||
async rename(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { title: request.payload.title, seq: 0 } } }
|
||||
},
|
||||
async prompt(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
|
||||
},
|
||||
async updateQueue(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
|
||||
},
|
||||
async cancel(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
|
||||
},
|
||||
@@ -204,7 +210,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
|
||||
})
|
||||
|
||||
it('covers create/prompt/cancel/describe passthrough', async () => {
|
||||
it('covers create/prompt/updateQueue/cancel/describe passthrough', async () => {
|
||||
const c = client()
|
||||
expect((await c.sessions.create({})).result.ok).toBe(true)
|
||||
expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true)
|
||||
@@ -224,7 +230,14 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
const renamed = await c.sessions.rename({ sessionId: 's' as never, title: 'named' })
|
||||
expect(renamed.result).toMatchObject({ ok: true, value: { title: 'named', seq: 0 } })
|
||||
expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
|
||||
expect((await c.sessions.updateQueue({
|
||||
sessionId: 's' as never,
|
||||
itemId: 'item-1' as never,
|
||||
action: { kind: 'remove' },
|
||||
})).result.ok).toBe(true)
|
||||
expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true)
|
||||
expect((await c.host.describe({})).result.ok).toBe(true)
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionModelsRequestSchema,
|
||||
sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema,
|
||||
sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema,
|
||||
sessionUpdateQueueRequestSchema, sessionUpdateQueueValueSchema,
|
||||
} from '../src/api/sessions.schema.ts'
|
||||
import {
|
||||
hostCreateDirectoryRequestSchema, hostCreateDirectoryValueSchema,
|
||||
@@ -68,13 +69,16 @@ describe('rpcErrorSchema', () => {
|
||||
details: { provider: 'p', model: 'm' },
|
||||
}).code).toBe('model-unavailable')
|
||||
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
|
||||
expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found')
|
||||
expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error')
|
||||
expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command')
|
||||
expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid')
|
||||
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
|
||||
})
|
||||
|
||||
it('rejects a known code with missing details', () => {
|
||||
expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow()
|
||||
expect(() => rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: {} })).toThrow()
|
||||
expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow()
|
||||
expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow()
|
||||
})
|
||||
@@ -219,7 +223,19 @@ describe('sessions domain schemas', () => {
|
||||
expect(sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'success' } }).command).toEqual({ kind: 'success' })
|
||||
expect(() => sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'failure' } })).toThrow()
|
||||
expect(sessionCancelRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
expect(sessionUpdateQueueRequestSchema.parse({
|
||||
sessionId: 's1',
|
||||
itemId: 'i1',
|
||||
action: { kind: 'edit', content: [{ type: 'text', text: 'next' }] },
|
||||
}).action.kind).toBe('edit')
|
||||
expect(sessionUpdateQueueRequestSchema.parse({
|
||||
sessionId: 's1', itemId: 'i1', action: { kind: 'remove' },
|
||||
}).action.kind).toBe('remove')
|
||||
expect(() => sessionUpdateQueueRequestSchema.parse({
|
||||
sessionId: 's1', itemId: 'i1', action: { kind: 'promote' },
|
||||
})).toThrow()
|
||||
expect(sessionCancelValueSchema.parse({ accepted: true }).accepted).toBe(true)
|
||||
expect(sessionUpdateQueueValueSchema.parse({ accepted: true }).accepted).toBe(true)
|
||||
expect(contentBlockSchema.parse({ type: 'text', text: 'x', extra: 1 })).toMatchObject({ extra: 1 })
|
||||
})
|
||||
})
|
||||
@@ -363,8 +379,9 @@ describe('events frame schemas', () => {
|
||||
{ type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' },
|
||||
{ type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
|
||||
{ type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' },
|
||||
{ type: 'session/queued', sessionId: 's', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } }, steering: false },
|
||||
{ type: 'session/queued', sessionId: 's', message: { id: 'm2', role: 'user', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' } }, steering: true },
|
||||
{ type: 'session/queue', sessionId: 's', items: [
|
||||
{ id: 'i1', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } } },
|
||||
] },
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 },
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
]
|
||||
@@ -382,10 +399,10 @@ describe('events frame schemas', () => {
|
||||
expect(() => muxFrameSchema.parse({ type: 'question/requested', sessionId: 's', questions: [] })).toThrow()
|
||||
})
|
||||
|
||||
it('rejects a queued frame missing its members', () => {
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: 'x', steering: false })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: { kind: 'user' } } })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: {} }, steering: false })).toThrow()
|
||||
it('rejects a queue snapshot with malformed items', () => {
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: 'x' })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', message: {} }] })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: 'i', message: { id: 'm', role: 'user', content: [], source: {} } }] })).toThrow()
|
||||
})
|
||||
|
||||
it('accepts every host frame branch', () => {
|
||||
|
||||
Reference in New Issue
Block a user