Merge branch 'master' into sdk/ts-client-and-subagent
This commit is contained in:
@@ -131,10 +131,16 @@ export class HarnessSdkServer {
|
||||
async prompt(params: SessionPromptParams): Promise<SessionPromptResult> {
|
||||
const rec = await this.getOrCreateSession(params.sessionId)
|
||||
if (rec.activePrompt) throw new Error(`session already has an active prompt: ${params.sessionId}`)
|
||||
// An agent-loop-only reload disposes the loop's agents while this record
|
||||
// survives; a retained agent accepts followup() silently, so validate the
|
||||
// record against the live registry before delivery (as the ACP bridge does).
|
||||
if (this.ctx.agents.get(rec.handle.agent.id) !== rec.handle.agent) {
|
||||
throw new Error(`session agent was disposed outside the server: ${params.sessionId}`)
|
||||
}
|
||||
rec.activePrompt = true
|
||||
try {
|
||||
rec.lastTurnEnd = undefined
|
||||
rec.handle.agent.followup(params.contentBlocks)
|
||||
rec.handle.agent.followup({ content: params.contentBlocks, source: { kind: 'user' } })
|
||||
await rec.handle.agent.whenIdle()
|
||||
const payload: SessionFinishedNotification = {
|
||||
sessionId: params.sessionId,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { AgentMessageId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { AgentMessageId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
@@ -153,7 +153,7 @@ describe('HarnessSdkServer', () => {
|
||||
meta: { cwd: storageDir },
|
||||
agentOptions: { provider: 'deepseek', model: 'dsagent-model' },
|
||||
})
|
||||
orphanHandle.agent.followup([{ type: 'text', text: 'outside the sdk session map' }])
|
||||
orphanHandle.agent.followup({ content: [{ type: 'text', text: 'outside the sdk session map' }], source: { kind: 'user' } })
|
||||
await orphanHandle.agent.whenIdle()
|
||||
await orphanHandle.dispose()
|
||||
expect(llmServer.requests).toHaveLength(3)
|
||||
@@ -173,21 +173,24 @@ describe('HarnessSdkServer', () => {
|
||||
.mockResolvedValue(undefined)
|
||||
const mainFollowup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('main-followup'))
|
||||
const mainAgent = ({
|
||||
id: SessionId('main'),
|
||||
followup: mainFollowup,
|
||||
whenIdle: mainWhenIdle,
|
||||
} satisfies Pick<Agent, 'followup' | 'whenIdle'>) as unknown as Agent
|
||||
} satisfies Pick<Agent, 'id' | 'followup' | 'whenIdle'>) as unknown as Agent
|
||||
const otherFollowup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('other-followup'))
|
||||
const otherAgent = ({
|
||||
id: SessionId('other'),
|
||||
followup: otherFollowup,
|
||||
whenIdle: vi.fn(() => Promise.resolve()),
|
||||
} satisfies Pick<Agent, 'followup' | 'whenIdle'>) as unknown as Agent
|
||||
} satisfies Pick<Agent, 'id' | 'followup' | 'whenIdle'>) as unknown as Agent
|
||||
const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) }
|
||||
const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) }
|
||||
const create = vi.fn(async (options: { sessionId: SessionId }) =>
|
||||
String(options.sessionId) === 'main' ? mainHandle : otherHandle)
|
||||
const liveAgents = new Map<string, Agent>([['main', mainAgent], ['other', otherAgent]])
|
||||
const ctx = {
|
||||
on: vi.fn(() => () => undefined),
|
||||
agents: { create, get: () => undefined },
|
||||
agents: { create, get: (id: SessionId) => liveAgents.get(String(id)) },
|
||||
get: () => undefined,
|
||||
} as unknown as Context
|
||||
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
||||
@@ -216,9 +219,43 @@ describe('HarnessSdkServer', () => {
|
||||
expect(otherHandle.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects a prompt for a session whose agent was disposed outside the server', async () => {
|
||||
const followup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('stub'))
|
||||
const agent = ({
|
||||
id: SessionId('zombie'),
|
||||
followup,
|
||||
whenIdle: vi.fn(() => Promise.resolve()),
|
||||
} satisfies Pick<Agent, 'id' | 'followup' | 'whenIdle'>) as unknown as Agent
|
||||
const handle = { agent, dispose: vi.fn(() => Promise.resolve()) }
|
||||
// The registry drops the agent after creation, modelling an agent-loop-only
|
||||
// reload that leaves the server's SessionRecord pointing at a detached agent.
|
||||
let live = true
|
||||
const ctx = {
|
||||
on: vi.fn(() => () => undefined),
|
||||
agents: {
|
||||
create: vi.fn(async () => handle),
|
||||
get: (id: SessionId) => (live && String(id) === 'zombie' ? agent : undefined),
|
||||
},
|
||||
get: () => undefined,
|
||||
} as unknown as Context
|
||||
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
||||
const prompt = (text: string) => server.prompt({
|
||||
sessionId: 'zombie',
|
||||
contentBlocks: [{ type: 'text', text }],
|
||||
})
|
||||
|
||||
await expect(prompt('while live')).resolves.toEqual({ accepted: true })
|
||||
live = false
|
||||
await expect(prompt('after detach')).rejects.toThrow('session agent was disposed outside the server: zombie')
|
||||
// The detached agent was never driven by the rejected prompt.
|
||||
expect(followup).toHaveBeenCalledOnce()
|
||||
await server.shutdown()
|
||||
})
|
||||
|
||||
it('reports the message-turn outcome when a later non-message turn settles before idle', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const transport = new FakeTransport()
|
||||
const server = new HarnessSdkServer(ctx, transport) as unknown as {
|
||||
prompt(params: { sessionId: string; contentBlocks: { type: 'text'; text: string }[] }): Promise<unknown>
|
||||
@@ -227,16 +264,14 @@ describe('HarnessSdkServer', () => {
|
||||
}
|
||||
const session = ctx.sessions.create(SessionId('message-outcome'))
|
||||
const agent = ({
|
||||
id: SessionId('message-outcome'),
|
||||
session,
|
||||
followup(content: { type: 'text'; text: string }[]) {
|
||||
followup(input: { content: { type: 'text'; text: string }[]; source: { kind: 'user' } }) {
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
trigger: { kind: 'message', source: input.source },
|
||||
})
|
||||
session.append('user/message', {
|
||||
content,
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
@@ -250,7 +285,8 @@ describe('HarnessSdkServer', () => {
|
||||
return AgentMessageId('message-outcome')
|
||||
},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
} satisfies Pick<Agent, 'session' | 'followup' | 'whenIdle'>) as unknown as Agent
|
||||
} satisfies Pick<Agent, 'id' | 'session' | 'followup' | 'whenIdle'>) as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
server.sessions.set('message-outcome', {
|
||||
handle: { agent, dispose: () => Promise.resolve() },
|
||||
lastTurnEnd: undefined,
|
||||
|
||||
@@ -42,10 +42,10 @@ import z from 'schemastery'
|
||||
import {
|
||||
installAgentLlmTarget,
|
||||
type Agent,
|
||||
type AgentMessageId,
|
||||
type AgentLlmTarget,
|
||||
type AgentLlmTargetRef,
|
||||
type AgentStatus,
|
||||
type HookContext,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-agent-loop'
|
||||
import type {} from '@deepseek-ai/dsh-token-meter'
|
||||
@@ -61,13 +61,13 @@ import type {
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import {
|
||||
displayPromptContent,
|
||||
SessionId,
|
||||
type JsonValue,
|
||||
type Session,
|
||||
type SessionEvent,
|
||||
type SessionHeader,
|
||||
type TodoItem,
|
||||
type UserMessageData,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal'
|
||||
import {
|
||||
@@ -1389,7 +1389,6 @@ function resumeTurnLabel(snapshot: SessionLogSnapshot): string {
|
||||
case 'error': return `turn ${event.data.turn}: error`
|
||||
case 'disposed': return `turn ${event.data.turn}: disposed`
|
||||
case 'max-tokens': return `turn ${event.data.turn}: max tokens`
|
||||
case 'rejected': return `turn ${event.data.turn}: rejected`
|
||||
case 'interrupted': return `turn ${event.data.turn}: interrupted`
|
||||
default: return `turn ${event.data.turn}: unknown result`
|
||||
}
|
||||
@@ -1904,9 +1903,9 @@ function activeSurfaceSeqs(session: Session): Set<number> {
|
||||
return new Set(session.surface.nodes)
|
||||
}
|
||||
|
||||
function sessionReferenceCard(meta: unknown): string[] | undefined {
|
||||
if (typeof meta !== 'object' || meta === null) return undefined
|
||||
const record = meta as Record<string, unknown>
|
||||
function sessionReferenceCard(source: unknown): string[] | undefined {
|
||||
if (typeof source !== 'object' || source === null) return undefined
|
||||
const record = source as Record<string, unknown>
|
||||
if (record['kind'] !== 'session-reference' || !Array.isArray(record['references'])) return undefined
|
||||
const references = record['references'] as unknown[]
|
||||
const labels: string[] = []
|
||||
@@ -1921,13 +1920,6 @@ function sessionReferenceCard(meta: unknown): string[] | undefined {
|
||||
return labels
|
||||
}
|
||||
|
||||
function promptReferenceCards(event: Extract<SessionEvent, { type: 'user/message' | 'steering/message' }>): string[][] {
|
||||
return event.data.envelope?.prefixContexts.flatMap((context) => {
|
||||
const card = sessionReferenceCard(context.meta)
|
||||
return card === undefined ? [] : [card]
|
||||
}) ?? []
|
||||
}
|
||||
|
||||
function activeToolCallIds(session: Session, active: ReadonlySet<number>): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const event of session.events) {
|
||||
@@ -1972,16 +1964,10 @@ export function createTuiChat(
|
||||
let toolsExpanded = false
|
||||
let streaming: StreamingAssistantComponent | undefined
|
||||
let runningStatus: RunningStatus | undefined
|
||||
// Steering messages queued during the running turn (`agent/inbox/enqueue`
|
||||
// with `info.steering`) that the loop has not yet drained, shown as a badge on
|
||||
// the status line. Each entry is the queued message's serialized source: a
|
||||
// drain (`steering/message`) removes one MATCHING entry, so a loop-authored
|
||||
// continuation reason (which enqueues and drains under its own source) pushes
|
||||
// and pops its own slot and cannot consume a pending user message's slot.
|
||||
// Cleared on leaving `running`, which also absorbs a cancellation that
|
||||
// discards the queue without logging drains; the status line exists only
|
||||
// while running, so idle carries no badge to keep current.
|
||||
const pendingSteering: string[] = []
|
||||
// TUI steering submissions that the inbox has not yet claimed or discarded.
|
||||
// Correlation ids avoid guessing whether a running-state submission actually
|
||||
// joined steering or fell back to the queued-turn FIFO during turn close.
|
||||
const pendingSteering = new Set<AgentMessageId>()
|
||||
let disposed = false
|
||||
let shuttingDown: Promise<void> | undefined
|
||||
// Optional: skills mount conditionally, so read the global service store
|
||||
@@ -2243,7 +2229,7 @@ export function createTuiChat(
|
||||
const renderStatus = (running: RunningStatus): void => {
|
||||
const at = now()
|
||||
running.loader.setMessage(
|
||||
formatTurnStatus(running.phase, at - running.phaseStartedAt, at - running.stepStartedAt, pendingSteering.length),
|
||||
formatTurnStatus(running.phase, at - running.phaseStartedAt, at - running.stepStartedAt, pendingSteering.size),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2271,7 +2257,7 @@ export function createTuiChat(
|
||||
const phase = prior?.phase ?? 'waiting'
|
||||
const phaseStartedAt = prior?.phaseStartedAt ?? at
|
||||
const stepStartedAt = prior?.stepStartedAt ?? at
|
||||
const message = formatTurnStatus(phase, at - phaseStartedAt, at - stepStartedAt, pendingSteering.length)
|
||||
const message = formatTurnStatus(phase, at - phaseStartedAt, at - stepStartedAt, pendingSteering.size)
|
||||
const loader = new Loader(ui, text => palette.accent(text), text => palette.muted(text), message)
|
||||
statusContainer.addChild(loader)
|
||||
const running: RunningStatus = {
|
||||
@@ -2349,7 +2335,7 @@ export function createTuiChat(
|
||||
// boolean avoids narrowing `source`, so the label keeps its full union.
|
||||
const source = event.data.source
|
||||
if (source.kind !== 'user') {
|
||||
const references = sessionReferenceCard(event.data.meta)
|
||||
const references = sessionReferenceCard(event.data.source)
|
||||
if (references !== undefined) {
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0))
|
||||
@@ -2367,33 +2353,22 @@ export function createTuiChat(
|
||||
}
|
||||
break
|
||||
}
|
||||
const text = displayText(contentText(displayPromptContent(event.data)).trim())
|
||||
const text = displayText(contentText(event.data.content).trim())
|
||||
if (text) {
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new UserMessageComponent(text, palette, mdTheme))
|
||||
if (options.addHistory) editor.addToHistory(text)
|
||||
}
|
||||
for (const references of promptReferenceCards(event)) {
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'steering/message': {
|
||||
const text = displayText(contentText(displayPromptContent(event.data)).trim())
|
||||
const text = displayText(contentText(event.data.content).trim())
|
||||
if (text) {
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new UserMessageComponent(text, palette, mdTheme, 'Steering'))
|
||||
}
|
||||
for (const references of promptReferenceCards(event)) {
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'prompt/blocked':
|
||||
appendNotice(`Prompt blocked: ${event.data.reason}`, 'warning')
|
||||
break
|
||||
case 'assistant/chunk':
|
||||
if (options.renderChunks) {
|
||||
if (streaming === undefined) {
|
||||
@@ -2453,8 +2428,6 @@ export function createTuiChat(
|
||||
appendNotice('Turn cancelled.', 'warning')
|
||||
} else if (event.data.reason.kind === 'max-tokens') {
|
||||
appendNotice('The model reached its output-token limit.', 'warning')
|
||||
} else if (event.data.reason.kind === 'rejected') {
|
||||
appendNotice(`Turn rejected: ${event.data.reason.reason}`, 'warning')
|
||||
} else if (event.data.reason.kind === 'interrupted') {
|
||||
appendNotice('The previous process ended during this turn.', 'warning')
|
||||
}
|
||||
@@ -2915,19 +2888,82 @@ export function createTuiChat(
|
||||
).finally(() => { commandControllers.delete(controller) })
|
||||
}
|
||||
|
||||
const dispatchMessage = (content: ContentBlock[], contexts: HookContext[]): void => {
|
||||
if (agent.status === 'disposed') {
|
||||
const dispatchMessage = (content: ContentBlock[], attachedContext?: UserMessageData): void => {
|
||||
if (disposed) {
|
||||
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
|
||||
} else if (agent.status === 'running') {
|
||||
agent.steer(content, { contexts })
|
||||
} else {
|
||||
agent.followup(content, { contexts })
|
||||
return
|
||||
}
|
||||
if (agent.acceptsNextStep) {
|
||||
// Steering is never subject to prompt admission; an attached snapshot
|
||||
// drains beside it at the same step boundary through the outbox.
|
||||
if (attachedContext !== undefined) {
|
||||
agent.inject({ content: attachedContext.content, source: attachedContext.source })
|
||||
}
|
||||
pendingSteering.add(agent.steer({ content, source: { kind: 'user' } }))
|
||||
refreshStatus()
|
||||
return
|
||||
}
|
||||
if (attachedContext === undefined) {
|
||||
agent.followup({ content, source: { kind: 'user' } })
|
||||
return
|
||||
}
|
||||
// Idle: the snapshot rides the prompt's admission transaction so a
|
||||
// blocking hook discards both together.
|
||||
let cleanedUp = false
|
||||
let acceptedId: AgentMessageId | undefined
|
||||
let acceptedContent: ContentBlock[] | undefined
|
||||
const enqueued = new Map<AgentMessageId, ContentBlock[]>()
|
||||
const discarded = new Set<AgentMessageId>()
|
||||
const cleanup = (): void => {
|
||||
// Every completion path detaches all three listeners. Keep this
|
||||
// idempotent so later cleanup paths cannot double-release them.
|
||||
/* v8 ignore next -- unreachable idempotence guard, see above */
|
||||
if (cleanedUp) return
|
||||
cleanedUp = true
|
||||
detachEnqueue()
|
||||
detachSubmit()
|
||||
detachDiscard()
|
||||
}
|
||||
// send() snapshots input before publishing it, and publishes enqueue
|
||||
// before returning its id. Capture that snapshot by id so admission can
|
||||
// use exact reference identity without depending on caller-owned input.
|
||||
const detachEnqueue = ctx.on('agent/inbox/enqueue', (subject, message) => {
|
||||
if (subject === agent) enqueued.set(message.id, message.content)
|
||||
})
|
||||
// Prepended so this wrapper is outermost: it observes the admission
|
||||
// whether a downstream hook allows or blocks, and detaches either way.
|
||||
const detachSubmit = ctx.on('agent/prompt-submit', async (subject, submitted, _source, _signal, next) => {
|
||||
if (subject !== agent || submitted !== acceptedContent) return next()
|
||||
cleanup()
|
||||
const decision = await next()
|
||||
if (decision.kind !== 'allow') return decision
|
||||
return { ...decision, additionalContexts: [...decision.additionalContexts ?? [], attachedContext] }
|
||||
}, { prepend: true })
|
||||
// Installed before followup(): an enqueue listener can synchronously
|
||||
// cancel and discard before followup() returns its id.
|
||||
const detachDiscard = ctx.on('agent/inbox/discard', (subject, messages) => {
|
||||
if (subject !== agent) return
|
||||
for (const message of messages) discarded.add(message.id)
|
||||
if (acceptedId !== undefined && discarded.has(acceptedId)) cleanup()
|
||||
})
|
||||
// followup() accepts any typed input and contains listener failures;
|
||||
// this guards a future synchronous throw so the wrapper cannot leak.
|
||||
/* v8 ignore start -- future-proofing guard, see above */
|
||||
try {
|
||||
acceptedId = agent.followup({ content, source: { kind: 'user' } })
|
||||
acceptedContent = enqueued.get(acceptedId) ?? content
|
||||
detachEnqueue()
|
||||
if (discarded.has(acceptedId)) cleanup()
|
||||
} catch (error: unknown) {
|
||||
cleanup()
|
||||
throw error
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
/** Deliver a user turn to the agent: steer while running, send while idle, or report a disposed agent. */
|
||||
const deliver = (payload: string): void => {
|
||||
dispatchMessage([{ type: 'text', text: payload }], [])
|
||||
dispatchMessage([{ type: 'text', text: payload }])
|
||||
}
|
||||
|
||||
/** Load a manually invoked skill and deliver its rendered body as a user turn, reporting lookup outcomes as notices. */
|
||||
@@ -3187,7 +3223,7 @@ export function createTuiChat(
|
||||
if (parsed.references.length === 0) {
|
||||
editor.addToHistory(text)
|
||||
editor.setText('')
|
||||
dispatchMessage([{ type: 'text', text: parsed.text }], [])
|
||||
dispatchMessage([{ type: 'text', text: parsed.text }])
|
||||
return
|
||||
}
|
||||
const sessionReferences = ctx.get('sessionReferences')
|
||||
@@ -3208,7 +3244,9 @@ export function createTuiChat(
|
||||
if (disposed) return
|
||||
editor.addToHistory(text)
|
||||
if (editor.getText() === value) editor.setText('')
|
||||
dispatchMessage(prepared.content, prepared.contexts)
|
||||
// The snapshot travels with the prompt so a blocking admission hook
|
||||
// discards them together — see dispatchMessage's attached-context path.
|
||||
dispatchMessage(prepared.content, prepared.additionalContext)
|
||||
}, (error: unknown) => {
|
||||
if (!disposed && !controller.signal.aborted) {
|
||||
restoreSubmittedInput()
|
||||
@@ -3263,17 +3301,6 @@ export function createTuiChat(
|
||||
if (event.type === 'tool/result') fileSearch.invalidate()
|
||||
recordEventUsage(tokens, event)
|
||||
advanceTurnPhase(event)
|
||||
if (event.type === 'steering/message') {
|
||||
// A queued steering message reached the model as it drained; drop its
|
||||
// entry from the badge. Matching by source keeps a loop-authored
|
||||
// continuation reason popping its own enqueued slot rather than a pending
|
||||
// user message's slot.
|
||||
const drained = pendingSteering.indexOf(JSON.stringify(event.data.source))
|
||||
if (drained >= 0) {
|
||||
pendingSteering.splice(drained, 1)
|
||||
refreshStatus()
|
||||
}
|
||||
}
|
||||
if ('surfaceOp' in event && typeof event.surfaceOp === 'object') {
|
||||
rebuildTranscript(false)
|
||||
return
|
||||
@@ -3281,17 +3308,24 @@ export function createTuiChat(
|
||||
renderEvent(event, { addHistory: false, renderChunks: true })
|
||||
requestRender()
|
||||
})
|
||||
const disposeQueued = ctx.on('agent/inbox/enqueue', (subject, info) => {
|
||||
if (subject !== agent || !info.steering) return
|
||||
pendingSteering.push(JSON.stringify(info.source))
|
||||
refreshStatus()
|
||||
const settlePendingSteering = (id: AgentMessageId): void => {
|
||||
if (pendingSteering.delete(id)) refreshStatus()
|
||||
}
|
||||
const disposeDequeued = ctx.on('agent/inbox/dequeue', (subject, message) => {
|
||||
if (subject === agent) settlePendingSteering(message.id)
|
||||
})
|
||||
const disposeDiscarded = ctx.on('agent/inbox/discard', (subject, messages) => {
|
||||
if (subject !== agent) return
|
||||
let changed = false
|
||||
for (const message of messages) changed = pendingSteering.delete(message.id) || changed
|
||||
if (changed) refreshStatus()
|
||||
})
|
||||
const disposeStatus = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject !== agent) return
|
||||
// Leaving 'running' ends the turn's status line; clear any badge so the
|
||||
// next running turn starts from zero (and a cancellation, which discards
|
||||
// the queue without logging drains, cannot strand a stale count).
|
||||
if (status !== 'running') pendingSteering.length = 0
|
||||
if (status !== 'running') pendingSteering.clear()
|
||||
setStatus(status)
|
||||
})
|
||||
const disposeError = ctx.on('agent/error', (subject, turn, step, error) => {
|
||||
@@ -3303,6 +3337,11 @@ export function createTuiChat(
|
||||
})
|
||||
const disposeAgent = ctx.on('agent/disposed', (subject) => {
|
||||
if (subject !== agent) return
|
||||
// The agent left the registry (e.g. an agent-loop-only reload) while the
|
||||
// TUI stays mounted. Retained agents accept deliveries after detachment, so
|
||||
// without this a later send would drive a zombie agent/session; mark
|
||||
// disposed so dispatchMessage reports it instead.
|
||||
disposed = true
|
||||
clearStatus()
|
||||
appendNotice(`Agent "${agent.id}" was disposed.`, 'warning')
|
||||
})
|
||||
@@ -3314,7 +3353,8 @@ export function createTuiChat(
|
||||
disposeCommandChanges()
|
||||
stopBannerReveal()
|
||||
disposeSessionEvents()
|
||||
disposeQueued()
|
||||
disposeDequeued()
|
||||
disposeDiscarded()
|
||||
disposeStatus()
|
||||
disposeError()
|
||||
disposeAgent()
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
LlmResolvedModelInfo,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import SessionStore, { SessionId, type Session, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId, type Session, type SessionHeader, type UserMessageData } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
@@ -27,12 +27,17 @@ interface FakeAgent extends Agent {
|
||||
sent: ContentBlock[][]
|
||||
sentOptions: (SendOptions | undefined)[]
|
||||
steered: ContentBlock[][]
|
||||
steeredOptions: (SendOptions | undefined)[]
|
||||
steeredIds: AgentMessageId[]
|
||||
steeredOptions: UserMessageData[]
|
||||
injected: ContentBlock[][]
|
||||
injectedOptions: UserMessageData[]
|
||||
cancelled: AgentCancelCause[]
|
||||
}
|
||||
|
||||
export interface TuiHarnessOptions {
|
||||
status?: AgentStatus
|
||||
/** Override the fake agent's next-step capability independently of status. */
|
||||
acceptsNextStep?: boolean
|
||||
config?: Config
|
||||
/** Leave the session event log empty instead of seeding one turn and step. */
|
||||
omitInitialLifecycle?: boolean
|
||||
@@ -177,38 +182,52 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
options.beforeMount?.(session)
|
||||
const sent: ContentBlock[][] = []
|
||||
const steered: ContentBlock[][] = []
|
||||
const steeredIds: AgentMessageId[] = []
|
||||
const sentOptions: (SendOptions | undefined)[] = []
|
||||
const steeredOptions: (SendOptions | undefined)[] = []
|
||||
const steeredOptions: UserMessageData[] = []
|
||||
const injected: ContentBlock[][] = []
|
||||
const injectedOptions: UserMessageData[] = []
|
||||
const cancelled: AgentCancelCause[] = []
|
||||
const agent: FakeAgent = {
|
||||
id: sessionId,
|
||||
options: options.agentOptions ?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
session,
|
||||
status: options.status ?? 'idle',
|
||||
get acceptsNextStep() {
|
||||
return options.acceptsNextStep ?? this.status === 'running'
|
||||
},
|
||||
ctx,
|
||||
sent,
|
||||
sentOptions,
|
||||
steered,
|
||||
steeredIds,
|
||||
steeredOptions,
|
||||
injected,
|
||||
injectedOptions,
|
||||
cancelled,
|
||||
followup(content, options) {
|
||||
sent.push(content)
|
||||
send(input, options) {
|
||||
sent.push(input.content)
|
||||
sentOptions.push(options)
|
||||
return AgentMessageId('stub')
|
||||
},
|
||||
queue(content, options) {
|
||||
sent.push(content)
|
||||
sentOptions.push(options)
|
||||
followup(input) {
|
||||
sent.push(input.content)
|
||||
sentOptions.push(undefined)
|
||||
return AgentMessageId('stub')
|
||||
},
|
||||
steer(content, options) {
|
||||
steered.push(content)
|
||||
steeredOptions.push(options)
|
||||
steer(input) {
|
||||
steered.push(input.content)
|
||||
steeredOptions.push(input)
|
||||
const id = AgentMessageId(`steering-${steeredIds.length + 1}`)
|
||||
steeredIds.push(id)
|
||||
return id
|
||||
},
|
||||
inject(input) {
|
||||
injected.push(input.content)
|
||||
injectedOptions.push(input)
|
||||
return AgentMessageId('stub')
|
||||
},
|
||||
inject: () => AgentMessageId('stub'),
|
||||
send: () => AgentMessageId('stub'),
|
||||
cancel(cause = { kind: 'user' }) {
|
||||
cancel(cause) {
|
||||
cancelled.push(cause)
|
||||
},
|
||||
whenIdle() {
|
||||
|
||||
@@ -24,10 +24,13 @@ class SnapshotAdapter extends LlmAdapter {
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
const prompt = options.messages.at(-1)
|
||||
if (prompt?.role !== 'user' || prompt.content.length !== 3
|
||||
|| prompt.content[1]?.type !== 'text' || prompt.content[1].text !== '\n\n## My request:\n') {
|
||||
throw new Error('session reference did not reach the model as one prefixed user message')
|
||||
// The snapshot rides the prompt's admission: the loop appends the
|
||||
// prompt first, then its additional contexts (the branch-wide ordering
|
||||
// for plugin-sourced context).
|
||||
const [prompt, context] = options.messages.slice(-2)
|
||||
if (context?.role !== 'user' || prompt?.role !== 'user'
|
||||
|| prompt.content[0]?.type !== 'text' || prompt.content[0].text !== 'Use @Source session') {
|
||||
throw new Error('session reference context did not follow the direct user message')
|
||||
}
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 0, text: 'Combined reference request accepted.' }
|
||||
@@ -113,22 +116,17 @@ describe('TUI session-reference snapshot', () => {
|
||||
expect(request).toContain('Recent retained question.')
|
||||
expect(request).not.toContain('SHADOWED OLD USER')
|
||||
expect(request).not.toContain('SHADOWED OLD ASSISTANT')
|
||||
const user = target.session.events.find(event => event.type === 'user/message')
|
||||
expect(user?.type === 'user/message' && user.data.envelope).toMatchObject({
|
||||
displayContent: [{ type: 'text', text: 'Use @Source session' }],
|
||||
prefixContexts: [{
|
||||
source: { kind: 'plugin', plugin: 'session-reference' },
|
||||
meta: {
|
||||
kind: 'session-reference',
|
||||
references: [{ sessionId: 'source-session', compacted: true }],
|
||||
},
|
||||
}],
|
||||
const context = target.session.events.find(event =>
|
||||
event.type === 'user/message' && event.data.source.kind === 'session-reference')
|
||||
expect(context?.type === 'user/message' && context.data.source).toMatchObject({
|
||||
kind: 'session-reference',
|
||||
references: [{ sessionId: 'source-session', compacted: true }],
|
||||
})
|
||||
expect(user?.type === 'user/message' && user.data.content[1]).toEqual({
|
||||
type: 'text',
|
||||
text: '\n\n## My request:\n',
|
||||
})
|
||||
expect(target.session.events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false)
|
||||
const user = target.session.events.find(event =>
|
||||
event.type === 'user/message' && event.data.source.kind === 'user')
|
||||
expect(user?.type === 'user/message' && user.data.content).toEqual([
|
||||
{ type: 'text', text: 'Use @Source session' },
|
||||
])
|
||||
|
||||
const snapshot = await terminal.snapshot({ includeScrollback: true })
|
||||
if (REFRESHING) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
terminal 100x34 buffer=normal length=38 base=4 viewport=4
|
||||
terminal 100x34 buffer=normal length=36 base=2 viewport=2
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "Unsafe terminal title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
|
||||
cursor hidden column=100 viewportRow=33 bufferRow=37
|
||||
cursor hidden column=100 viewportRow=33 bufferRow=35
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
@@ -55,23 +55,20 @@ buffer
|
||||
24| " Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 1-60 fg=bright-black
|
||||
25| <blank>
|
||||
26| " Prompt blocked: Unsafe policy \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 1-75 fg=yellow
|
||||
27| <blank>
|
||||
28| " Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
26| " Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 1-63 fg=red
|
||||
29| <blank>
|
||||
30| " "
|
||||
31| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
27| <blank>
|
||||
28| " "
|
||||
29| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 2-90 fg=bright-black
|
||||
32| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
33| " "
|
||||
34| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c "
|
||||
30| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
31| " "
|
||||
32| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c "
|
||||
style 2-65 fg=bright-blue bold
|
||||
style 67-97 fg=bright-black
|
||||
35| " Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt "
|
||||
33| " Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt "
|
||||
style 2-64 dim
|
||||
36| " "
|
||||
37| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
|
||||
34| " "
|
||||
35| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
|
||||
style 0-43 dim
|
||||
style 73-99 dim
|
||||
|
||||
@@ -489,11 +489,6 @@ describe('TUI terminal-state snapshots', () => {
|
||||
content: [{ type: 'text', text: `Unsafe context ${CONTROL_PROBE}` }],
|
||||
source: { kind: 'plugin', plugin: `unsafe-${CONTROL_PROBE}` },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('prompt/blocked', {
|
||||
content: [{ type: 'text', text: 'blocked' }],
|
||||
source: { kind: 'user' },
|
||||
reason: `Unsafe policy ${CONTROL_PROBE}`,
|
||||
})
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', {
|
||||
turn: 1,
|
||||
|
||||
@@ -425,7 +425,6 @@ describe('resume command and /resume', () => {
|
||||
[{ kind: 'error', step: 1, message: 'failed' }, 'error'],
|
||||
[{ kind: 'disposed' }, 'disposed'],
|
||||
[{ kind: 'max-tokens' }, 'max tokens'],
|
||||
[{ kind: 'rejected', reason: 'policy' }, 'rejected'],
|
||||
[{ kind: 'interrupted' }, 'interrupted'],
|
||||
[{ kind: 'future-result' } as unknown as TurnEndReason, 'unknown result'],
|
||||
] as const)('renders the last turn result %s', async (reason, label) => {
|
||||
@@ -1085,8 +1084,13 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
beforeMount(session) {
|
||||
session.append('user/message', {
|
||||
content: renderGoalChange(change),
|
||||
source: { kind: 'goal', goalId: change.goal.id, revision: change.goal.revision, round: 0 },
|
||||
meta: change as unknown as JsonValue,
|
||||
source: {
|
||||
kind: 'goal',
|
||||
goalId: change.goal.id,
|
||||
revision: change.goal.revision,
|
||||
round: 0,
|
||||
change,
|
||||
},
|
||||
}, { surfaceOp: 'append' })
|
||||
},
|
||||
})
|
||||
@@ -1187,7 +1191,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
// A non-plugin injected source (goal) has no `plugin` field, so its context
|
||||
// card label falls back to the source kind.
|
||||
result.session.append('user/message', { content: [{ type: 'text', text: 'goal context' }], source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0 } as never }, { surfaceOp: 'append' })
|
||||
result.session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked' }], source: { kind: 'user' }, reason: 'test policy' })
|
||||
appendAssistant(result.session, [])
|
||||
result.session.append('step/end', { turn: 1, step: 1 })
|
||||
result.session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
|
||||
@@ -1268,7 +1271,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).toContain('Steering')
|
||||
expect(result.terminal.output).toContain('user context')
|
||||
expect(result.terminal.output).toContain('Context · goal') // goal-sourced injected context labels by kind
|
||||
expect(result.terminal.output).toContain('Prompt blocked')
|
||||
expect(result.terminal.output).toContain('Turn cancelled')
|
||||
expect(result.terminal.progress).toContain(true)
|
||||
|
||||
@@ -1363,30 +1365,38 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels')
|
||||
expect(result.terminal.output).not.toContain('queued')
|
||||
|
||||
const queueSteering = (text: string): void => {
|
||||
result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true })
|
||||
const submitSteering = (text: string): void => {
|
||||
result.terminal.send(text)
|
||||
result.terminal.send('\r')
|
||||
}
|
||||
const drainSteering = (text: string): void => {
|
||||
const id = result.agent.steeredIds.shift()
|
||||
if (id !== undefined) {
|
||||
result.ctx.emit('agent/inbox/dequeue', result.agent, {
|
||||
id,
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
}
|
||||
result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
// A steering queue for a different agent never touches this status line.
|
||||
const other = { ...result.agent, id: SessionId('other') } as unknown as Agent
|
||||
result.terminal.output = ''
|
||||
result.ctx.emit('agent/inbox/enqueue', other, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true })
|
||||
result.ctx.emit('agent/inbox/enqueue', other, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' } }, 'queued')
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('queued')
|
||||
|
||||
// Two steering messages queue while the turn runs.
|
||||
queueSteering('first')
|
||||
submitSteering('first')
|
||||
result.terminal.output = ''
|
||||
queueSteering('second')
|
||||
submitSteering('second')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('2 queued · Enter sends steering, Esc cancels')
|
||||
|
||||
// A non-steering queue (an idle-style send) leaves the badge untouched.
|
||||
// Draining one submitted message decrements the badge.
|
||||
result.terminal.output = ''
|
||||
result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'sent' }], source: { kind: 'user' }, contexts: [], steering: false, wakeup: true })
|
||||
drainSteering('first')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('1 queued')
|
||||
@@ -1402,13 +1412,12 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
// A drain with no matching queued entry is ignored rather than underflowing.
|
||||
result.terminal.output = ''
|
||||
drainSteering('continuation')
|
||||
queueSteering('after')
|
||||
submitSteering('after')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('1 queued')
|
||||
|
||||
// A steering/message whose source matches no pending badge entry (here a
|
||||
// plugin source with no tracked enqueue) pops nothing, so it cannot consume
|
||||
// a pending user slot even when it drains first.
|
||||
// A steering/message has no inbox identity and therefore cannot consume a
|
||||
// pending slot by itself.
|
||||
result.terminal.output = ''
|
||||
result.session.append('steering/message', {
|
||||
turn: 1,
|
||||
@@ -1432,15 +1441,40 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels')
|
||||
expect(result.terminal.output).not.toContain('queued')
|
||||
|
||||
// A cancellation discards queued steering: the badge clears without drains.
|
||||
submitSteering('third')
|
||||
submitSteering('fourth')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('2 queued')
|
||||
const discarded = result.agent.steeredIds.splice(0).map(id => ({
|
||||
id, content: [{ type: 'text' as const, text: 'discarded' }], source: { kind: 'user' as const },
|
||||
}))
|
||||
// Another agent's dequeue/discard, and ones naming no pending id, leave
|
||||
// the badge alone.
|
||||
result.ctx.emit('agent/inbox/dequeue', other, discarded[0]!)
|
||||
result.ctx.emit('agent/inbox/dequeue', result.agent, {
|
||||
id: AgentMessageId('never-queued'), content: [{ type: 'text', text: 'x' }], source: { kind: 'user' },
|
||||
})
|
||||
result.ctx.emit('agent/inbox/discard', other, discarded)
|
||||
result.ctx.emit('agent/inbox/discard', result.agent, [
|
||||
{ id: AgentMessageId('never-queued'), content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } },
|
||||
])
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('2 queued')
|
||||
result.terminal.output = ''
|
||||
result.ctx.emit('agent/inbox/discard', result.agent, discarded)
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels')
|
||||
expect(result.terminal.output).not.toContain('queued')
|
||||
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('derives the fine-grained turn phase from session lifecycle events', async () => {
|
||||
// A live event before the turn runs has no status controller to move.
|
||||
const idle = await setup()
|
||||
// A steering queue arriving while idle has no status line to badge, so the
|
||||
// refresh is a no-op beyond requesting a render.
|
||||
idle.ctx.emit('agent/inbox/enqueue', idle.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'early' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true })
|
||||
// Inbox notifications do not affect the status phase while idle.
|
||||
idle.ctx.emit('agent/inbox/enqueue', idle.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'early' }], source: { kind: 'user' } }, 'queued')
|
||||
idle.session.append('tool/call', { turn: 1, step: 0, callId: 'pre' as never, name: 'bash', arguments: '{}' })
|
||||
await tick()
|
||||
expect(idle.terminal.output).not.toContain('Executing tools')
|
||||
@@ -1814,6 +1848,18 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
// /reload without a Loader in the context degrades to a warning.
|
||||
expect(result.terminal.output).toContain('/reload needs the cordis Loader')
|
||||
expect(result.exit).toHaveBeenCalledWith(0)
|
||||
|
||||
// The exit above left the TUI disposed (the mocked runtime.exit returns):
|
||||
// a message submitted now is refused instead of reaching the agent. The
|
||||
// refusal notice lands in the transcript, but the stopped UI no longer
|
||||
// paints, so assert the refusal through the agent surface.
|
||||
const sentBefore = result.agent.sent.length
|
||||
const steeredBefore = result.agent.steered.length
|
||||
result.terminal.send('after shutdown')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.agent.sent).toHaveLength(sentBefore)
|
||||
expect(result.agent.steered).toHaveLength(steeredBefore)
|
||||
await result.controller.dispose()
|
||||
await result.ctx.fiber.dispose()
|
||||
|
||||
@@ -1824,13 +1870,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
await ctrlCExit.controller.dispose()
|
||||
await ctrlCExit.ctx.fiber.dispose()
|
||||
|
||||
const disposedAgent = await setup()
|
||||
disposedAgent.agent.status = 'disposed'
|
||||
disposedAgent.terminal.send('late input')
|
||||
disposedAgent.terminal.send('\r')
|
||||
await tick()
|
||||
expect(disposedAgent.terminal.output).toContain('is disposed')
|
||||
await dispose(disposedAgent)
|
||||
})
|
||||
|
||||
it('combines session autocomplete with files and prepares send/steer references asynchronously', async () => {
|
||||
@@ -1865,20 +1904,252 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
|
||||
expect(result.agent.sent).toEqual([[{ type: 'text', text: '@Source chat' }]])
|
||||
expect(result.agent.sentOptions[0]?.contexts).toHaveLength(1)
|
||||
// Idle: the snapshot rides the prompt's admission (additionalContexts on
|
||||
// the allow decision), not a separate pre-admission inject.
|
||||
expect(result.agent.injected).toHaveLength(0)
|
||||
const decision = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', result.agent.sent[0]!, { kind: 'user' },
|
||||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||||
)
|
||||
expect(decision.kind).toBe('allow')
|
||||
expect(decision.kind === 'allow' && decision.additionalContexts?.[0]?.source)
|
||||
.toMatchObject({ kind: 'session-reference', references: [{ sessionId: 'source-session' }] })
|
||||
|
||||
// The one-shot wrapper detached itself at admission: replaying the
|
||||
// waterfall attaches nothing a second time.
|
||||
const replay = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', result.agent.sent[0]!, { kind: 'user' },
|
||||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||||
)
|
||||
expect(replay.kind === 'allow' && replay.additionalContexts).toBeUndefined()
|
||||
|
||||
const mention = formatSessionReferenceMention({ sessionId: sourceId, label: 'Source chat' })
|
||||
expect(result.agent.sentOptions[0]?.contexts).toMatchObject([{
|
||||
source: { kind: 'plugin', plugin: 'session-reference' },
|
||||
meta: { kind: 'session-reference', references: [{ sessionId: 'source-session' }] },
|
||||
}])
|
||||
|
||||
result.agent.status = 'running'
|
||||
result.terminal.send(`steer ${mention}`)
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(result.agent.steered).toHaveLength(1) })
|
||||
expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer @Source chat' }]])
|
||||
expect(result.agent.steeredOptions[0]?.contexts).toHaveLength(1)
|
||||
// Steering bypasses admission, so its snapshot still arrives via inject.
|
||||
expect(result.agent.injected).toHaveLength(1)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps a referenced prompt on admission when running no longer accepts next-step input', async () => {
|
||||
const result = await setup({
|
||||
status: 'running',
|
||||
acceptsNextStep: false,
|
||||
omitInitialLifecycle: true,
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
const source = ctx.sessions.create(SessionId('admission-src'), {
|
||||
meta: { cwd: process.cwd(), createdAt: 1 },
|
||||
})
|
||||
appendUser(source, 'source background')
|
||||
source.append('session/title', {
|
||||
title: 'Admission source',
|
||||
messageSeqs: [0],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
result.terminal.send(formatSessionReferenceMention({
|
||||
sessionId: SessionId('admission-src'),
|
||||
label: 'Admission source',
|
||||
}))
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
|
||||
|
||||
expect(result.agent.steered).toHaveLength(0)
|
||||
expect(result.agent.injected).toHaveLength(0)
|
||||
const decision = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', result.agent.sent[0]!, { kind: 'user' },
|
||||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||||
)
|
||||
expect(decision.kind === 'allow' && decision.additionalContexts?.[0]?.source)
|
||||
.toMatchObject({ kind: 'session-reference', references: [{ sessionId: 'admission-src' }] })
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('releases the reference-admission wrapper on the ordinary allowed path', async () => {
|
||||
const result = await setup({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
const source = ctx.sessions.create(SessionId('leak-source'), { meta: { cwd: process.cwd(), createdAt: 1 } })
|
||||
appendUser(source, 'source background')
|
||||
},
|
||||
})
|
||||
const send = async (): Promise<void> => {
|
||||
result.terminal.send('@leak-source')
|
||||
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · leak-source') })
|
||||
result.terminal.send('\t')
|
||||
await tick()
|
||||
result.terminal.send('\r')
|
||||
}
|
||||
await send()
|
||||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
|
||||
await send()
|
||||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) })
|
||||
|
||||
// Each wrapper releases on its own allowed admission — matched by the
|
||||
// message content it carries, not the returned id, which real send()
|
||||
// assigns as a random UUID only after followup() returns. Running each
|
||||
// prompt's admission waterfall detaches its wrapper.
|
||||
for (const sent of result.agent.sent) {
|
||||
await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', sent, { kind: 'user' },
|
||||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||||
)
|
||||
}
|
||||
// Both wrappers now gone: a discard naming either prompt's content finds
|
||||
// no armed listener, and an unrelated admission is untouched. The leak
|
||||
// regression: a listener installed after its cleanup already ran would
|
||||
// survive every future cleanup.
|
||||
result.ctx.emit('agent/inbox/discard', result.agent, [{
|
||||
id: AgentMessageId('stub'), content: result.agent.sent[0]!, source: { kind: 'user' },
|
||||
}])
|
||||
const unrelated = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', [{ type: 'text', text: 'unrelated' }], { kind: 'user' },
|
||||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||||
)
|
||||
expect(unrelated.kind === 'allow' && unrelated.additionalContexts).toBeUndefined()
|
||||
// Replaying either sent prompt attaches nothing: the one-shot wrappers
|
||||
// are gone, not merely spent.
|
||||
for (const sent of result.agent.sent) {
|
||||
const replay = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', sent, { kind: 'user' },
|
||||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||||
)
|
||||
expect(replay.kind === 'allow' && replay.additionalContexts).toBeUndefined()
|
||||
}
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('releases the reference wrapper when enqueue synchronously discards before followup returns', async () => {
|
||||
const result = await setup({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
const source = ctx.sessions.create(SessionId('sync-source'), { meta: { cwd: process.cwd(), createdAt: 1 } })
|
||||
appendUser(source, 'source background')
|
||||
},
|
||||
})
|
||||
// Real send() publishes its snapshotted message, then an enqueue listener
|
||||
// may synchronously cancel and discard it before followup() returns the
|
||||
// already-assigned id. This stub reproduces that ordering.
|
||||
const foreign = { ...result.agent, id: SessionId('foreign') } as unknown as Agent
|
||||
result.agent.followup = (input) => {
|
||||
result.agent.sent.push(input.content)
|
||||
const message = {
|
||||
id: AgentMessageId('stub'),
|
||||
content: structuredClone(input.content),
|
||||
source: structuredClone(input.source),
|
||||
}
|
||||
result.ctx.emit('agent/inbox/enqueue', foreign, message, 'queued')
|
||||
result.ctx.emit('agent/inbox/enqueue', result.agent, message, 'queued')
|
||||
result.ctx.emit('agent/inbox/discard', result.agent, [message])
|
||||
return message.id
|
||||
}
|
||||
|
||||
result.terminal.send('@sync-source')
|
||||
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · sync-source') })
|
||||
result.terminal.send('\t')
|
||||
await tick()
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
|
||||
|
||||
// The synchronous discard released the listeners even though followup()
|
||||
// had not returned the id yet: replaying the prompt's admission attaches
|
||||
// no stranded snapshot, and nothing leaks for the TUI lifetime.
|
||||
const replay = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', result.agent.sent[0]!, { kind: 'user' },
|
||||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||||
)
|
||||
expect(replay.kind === 'allow' && replay.additionalContexts).toBeUndefined()
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('discards the reference snapshot with its blocked or cancelled prompt', async () => {
|
||||
const result = await setup({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
const source = ctx.sessions.create(SessionId('blocked-source'), { meta: { cwd: process.cwd(), createdAt: 1 } })
|
||||
appendUser(source, 'source background')
|
||||
},
|
||||
})
|
||||
// A downstream admission hook blocks the prompt: the attached snapshot
|
||||
// must be discarded with it, not stranded for the next prompt.
|
||||
let blockPrompts = true
|
||||
result.ctx.on('agent/prompt-submit', async (_agent, _content, _source, _signal, next) =>
|
||||
blockPrompts ? { kind: 'block' as const, reason: 'policy' } : next())
|
||||
|
||||
result.terminal.send('@blocked-source')
|
||||
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · blocked-source') })
|
||||
result.terminal.send('\t')
|
||||
await tick()
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
|
||||
|
||||
const blocked = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', result.agent.sent[0]!, { kind: 'user' },
|
||||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||||
)
|
||||
expect(blocked.kind).toBe('block')
|
||||
// Nothing entered history and nothing waits for a later prompt: a fresh
|
||||
// unrelated admission sees no leftover contexts.
|
||||
expect(result.agent.injected).toHaveLength(0)
|
||||
blockPrompts = false
|
||||
const unrelated = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', [{ type: 'text', text: 'unrelated' }], { kind: 'user' },
|
||||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||||
)
|
||||
expect(unrelated.kind === 'allow' && unrelated.additionalContexts).toBeUndefined()
|
||||
|
||||
// Second referenced prompt, this time dropped by a broad cancel before
|
||||
// any admission runs: the discard listener releases the wrapper.
|
||||
result.terminal.send('@blocked-source')
|
||||
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · blocked-source') })
|
||||
result.terminal.send('\t')
|
||||
await tick()
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) })
|
||||
// A different prompt passing the still-armed wrapper delegates untouched.
|
||||
const passthrough = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', [{ type: 'text', text: 'different prompt' }], { kind: 'user' },
|
||||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||||
)
|
||||
expect(passthrough.kind === 'allow' && passthrough.additionalContexts).toBeUndefined()
|
||||
// A foreign agent's discard leaves the wrapper armed.
|
||||
const foreign = { ...result.agent, id: SessionId('foreign') } as unknown as Agent
|
||||
result.ctx.emit('agent/inbox/discard', foreign, [{
|
||||
id: AgentMessageId('stub'),
|
||||
content: result.agent.sent.at(-1)!,
|
||||
source: { kind: 'user' },
|
||||
}])
|
||||
result.ctx.emit('agent/inbox/discard', result.agent, [{
|
||||
id: AgentMessageId('stub'),
|
||||
content: result.agent.sent.at(-1)!,
|
||||
source: { kind: 'user' },
|
||||
}])
|
||||
await tick()
|
||||
// Idempotent: a repeat discard after cleanup is a no-op.
|
||||
result.ctx.emit('agent/inbox/discard', result.agent, [{
|
||||
id: AgentMessageId('stub'),
|
||||
content: result.agent.sent.at(-1)!,
|
||||
source: { kind: 'user' },
|
||||
}])
|
||||
const afterDiscard = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', result.agent.sent.at(-1)!, { kind: 'user' },
|
||||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||||
)
|
||||
expect(afterDiscard.kind === 'allow' && afterDiscard.additionalContexts).toBeUndefined()
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
@@ -1918,7 +2189,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
|
||||
expect(result.agent.sent[0]).toEqual([{ type: 'text', text: '@src/source-file.ts' }])
|
||||
expect(result.agent.sentOptions[0]?.contexts).toEqual([])
|
||||
|
||||
result.terminal.send('@do')
|
||||
await vi.waitFor(() => {
|
||||
@@ -1934,7 +2204,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) })
|
||||
expect(result.agent.sent[1]).toEqual([{ type: 'text', text: '@"docs/design notes.md"' }])
|
||||
expect(result.agent.sentOptions[1]?.contexts).toEqual([])
|
||||
|
||||
result.terminal.send('@unsafe')
|
||||
await tick()
|
||||
@@ -2030,9 +2299,12 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.agent.sent).toEqual([[
|
||||
{ type: 'text', text: '@evil\\x1b\\x07\\x9b\\x0as' },
|
||||
]])
|
||||
expect(result.agent.sentOptions[0]?.contexts).toMatchObject([{
|
||||
meta: { references: [{ sessionId: unsafeId }] },
|
||||
}])
|
||||
const decision = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/prompt-submit', result.agent.sent[0]!, { kind: 'user' },
|
||||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||||
)
|
||||
expect(decision.kind === 'allow' && decision.additionalContexts?.[0]?.source)
|
||||
.toMatchObject({ references: [{ sessionId: unsafeId }] })
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
@@ -2118,84 +2390,65 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).toContain('keep @[')
|
||||
|
||||
result.session.append('user/message', {
|
||||
content: [
|
||||
{ type: 'text', text: 'hidden baked snapshot payload' },
|
||||
{ type: 'text', text: '\n\n## My request:\n' },
|
||||
{ type: 'text', text: 'visible referenced question' },
|
||||
],
|
||||
content: [{ type: 'text', text: 'hidden snapshot payload' }],
|
||||
source: {
|
||||
kind: 'session-reference',
|
||||
references: [{ sessionId: 'prefixed', label: 'Prefixed source' }],
|
||||
} as never,
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'visible referenced question' }],
|
||||
source: { kind: 'user' },
|
||||
envelope: {
|
||||
displayContent: [{ type: 'text', text: 'visible referenced question' }],
|
||||
prefixContexts: [{
|
||||
source: { kind: 'plugin', plugin: 'session-reference' },
|
||||
meta: {
|
||||
kind: 'session-reference',
|
||||
references: [{ sessionId: 'prefixed', label: 'Prefixed source' }],
|
||||
},
|
||||
}],
|
||||
},
|
||||
}, { surfaceOp: 'append' })
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('visible referenced question')
|
||||
expect(result.terminal.output).toContain('Referenced sessions · Prefixed source (prefixed)')
|
||||
expect(result.terminal.output).not.toContain('hidden baked snapshot payload')
|
||||
expect(result.terminal.output).not.toContain('hidden snapshot payload')
|
||||
|
||||
result.session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'hidden steering context' }],
|
||||
source: {
|
||||
kind: 'session-reference',
|
||||
references: [{ sessionId: 'steering-source', label: 'Steering source' }],
|
||||
} as never,
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('steering/message', {
|
||||
turn: 1,
|
||||
content: [
|
||||
{ type: 'text', text: 'hidden non-reference prefix' },
|
||||
{ type: 'text', text: '\n\n## My request:\n' },
|
||||
{ type: 'text', text: 'visible steering prompt' },
|
||||
],
|
||||
content: [{ type: 'text', text: 'visible steering prompt' }],
|
||||
source: { kind: 'user' },
|
||||
envelope: {
|
||||
displayContent: [{ type: 'text', text: 'visible steering prompt' }],
|
||||
prefixContexts: [
|
||||
{ source: { kind: 'plugin', plugin: 'other' }, meta: { kind: 'other' } },
|
||||
{
|
||||
source: { kind: 'plugin', plugin: 'session-reference' },
|
||||
meta: {
|
||||
kind: 'session-reference',
|
||||
references: [{ sessionId: 'steering-source', label: 'Steering source' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}, { surfaceOp: 'append' })
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('visible steering prompt')
|
||||
expect(result.terminal.output).toContain('Referenced sessions · Steering source (steering-source)')
|
||||
expect(result.terminal.output).not.toContain('hidden non-reference prefix')
|
||||
expect(result.terminal.output).not.toContain('hidden steering context')
|
||||
|
||||
result.session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'secret full snapshot payload' }],
|
||||
source: { kind: 'plugin', plugin: 'session-reference' },
|
||||
meta: {
|
||||
source: {
|
||||
kind: 'session-reference',
|
||||
version: 1,
|
||||
references: [{ sessionId: 'source', label: 'Source', capturedThroughSeq: 2 }],
|
||||
},
|
||||
} as never,
|
||||
}, { surfaceOp: 'append' })
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Referenced sessions · Source (source)')
|
||||
expect(result.terminal.output).not.toContain('secret full snapshot payload')
|
||||
|
||||
const invalidCards: [JsonValue, string][] = [
|
||||
['plain-string-source', 'invalid-shape'],
|
||||
[{ kind: 'other' }, 'invalid-kind'],
|
||||
[{ kind: 'session-reference', references: [null] }, 'invalid-entry'],
|
||||
[{ kind: 'session-reference', references: [{}] }, 'invalid-fields'],
|
||||
]
|
||||
for (const [meta, text] of invalidCards) {
|
||||
for (const [source, text] of invalidCards) {
|
||||
result.session.append('user/message', {
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'plugin', plugin: 'session-reference' },
|
||||
meta,
|
||||
source: source as never,
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
result.session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'same-label snapshot' }],
|
||||
source: { kind: 'plugin', plugin: 'session-reference' },
|
||||
meta: { kind: 'session-reference', references: [{ sessionId: 'same', label: 'same' }] },
|
||||
source: { kind: 'session-reference', references: [{ sessionId: 'same', label: 'same' }] } as never,
|
||||
}, { surfaceOp: 'append' })
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Referenced sessions · same')
|
||||
@@ -2235,7 +2488,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
let release: (() => void) | undefined
|
||||
const prepare = vi.spyOn(result.ctx.sessionReferences, 'prepare').mockImplementation(
|
||||
(_agent, content) => new Promise((resolve) => {
|
||||
release = () => { resolve({ content, contexts: [] }) }
|
||||
release = () => { resolve({ content }) }
|
||||
}),
|
||||
)
|
||||
result.terminal.send(value)
|
||||
@@ -2283,7 +2536,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
let resolveAfterDispose: (() => void) | undefined
|
||||
const latePrepare = vi.spyOn(lateSuccess.ctx.sessionReferences, 'prepare').mockImplementation(
|
||||
(_agent, content) => new Promise((resolve) => {
|
||||
resolveAfterDispose = () => { resolve({ content, contexts: [] }) }
|
||||
resolveAfterDispose = () => { resolve({ content }) }
|
||||
}),
|
||||
)
|
||||
lateSuccess.terminal.send(value)
|
||||
@@ -2414,7 +2667,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
'agent/request',
|
||||
0,
|
||||
0,
|
||||
explicitResetSeed,
|
||||
new AbortController().signal,
|
||||
() => Promise.resolve(explicitResetSeed),
|
||||
)).resolves.toEqual({ provider: 'alpha', model: 'shared' })
|
||||
@@ -2451,7 +2703,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
'agent/request',
|
||||
0,
|
||||
0,
|
||||
inheritedEffort,
|
||||
new AbortController().signal,
|
||||
() => Promise.resolve(inheritedEffort),
|
||||
)).resolves.toEqual({ provider: 'beta', model: 'shared' })
|
||||
@@ -2505,7 +2756,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' })
|
||||
const seed: LlmCallConfig = { provider: 'alpha', model: 'a1', temperature: 0.2 }
|
||||
const request = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/request', 1, 0, seed, new AbortController().signal, () => Promise.resolve(seed),
|
||||
'agent/request', 1, 0, new AbortController().signal, () => Promise.resolve(seed),
|
||||
)
|
||||
expect(request).toEqual({
|
||||
provider: 'beta',
|
||||
@@ -2590,7 +2841,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(assembly.variables).toEqual({})
|
||||
const seed: LlmCallConfig = { provider: 'fallback', model: 'fallback' }
|
||||
await expect(agentEvents(empty.ctx, empty.agent).waterfall(
|
||||
'agent/request', 1, 0, seed, new AbortController().signal, () => Promise.resolve(seed),
|
||||
'agent/request', 1, 0, new AbortController().signal, () => Promise.resolve(seed),
|
||||
)).resolves.toBe(seed)
|
||||
await dispose(empty)
|
||||
|
||||
@@ -2817,12 +3068,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
events.session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
events.session.append('turn/end', { turn: 4, reason: { kind: 'max-tokens' } })
|
||||
events.session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
events.session.append('turn/end', { turn: 5, reason: { kind: 'rejected', reason: 'policy' } })
|
||||
events.session.append('turn/end', { turn: 5, reason: { kind: 'interrupted' } })
|
||||
events.session.append('turn/start', { turn: 6, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
events.session.append('turn/end', { turn: 6, reason: { kind: 'interrupted' } })
|
||||
events.session.append('turn/start', { turn: 7, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
events.session.append('turn/end', {
|
||||
turn: 7,
|
||||
turn: 6,
|
||||
reason: { kind: 'error', step: 1, failure: { message: 'structured provider failure', code: 'SERVER' } },
|
||||
})
|
||||
agentEvents(events.ctx, events.agent).emit('agent/disposed')
|
||||
@@ -2832,11 +3081,29 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(events.terminal.output).toContain('Turn cancelled')
|
||||
expect(events.terminal.output).toContain('structured provider failure')
|
||||
expect(events.terminal.output).toContain('output-token limit')
|
||||
expect(events.terminal.output).toContain('Turn rejected')
|
||||
expect(events.terminal.output).toContain('previous process ended')
|
||||
expect(events.terminal.output).toContain('was disposed')
|
||||
await dispose(events)
|
||||
})
|
||||
|
||||
it('rejects input after the agent is disposed out from under the TUI', async () => {
|
||||
const result = await setup()
|
||||
|
||||
// The agent leaves the registry (e.g. an agent-loop-only reload) while the
|
||||
// TUI stays mounted. A later send must report disposal, not drive the
|
||||
// detached zombie agent.
|
||||
agentEvents(result.ctx, result.agent).emit('agent/disposed')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('was disposed')
|
||||
|
||||
result.terminal.send('drive the zombie')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.agent.sent).toHaveLength(0)
|
||||
expect(result.agent.steered).toHaveLength(0)
|
||||
expect(result.terminal.output).toContain('is disposed')
|
||||
await dispose(result)
|
||||
})
|
||||
})
|
||||
|
||||
describe('skill slash command', () => {
|
||||
@@ -3484,8 +3751,8 @@ describe('terminal mounting', () => {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const session = ctx.sessions.create(SessionId('main'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', ctx,
|
||||
followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
mountTui(ctx, { color: false }, { terminal, exit: vi.fn() })
|
||||
@@ -3508,8 +3775,8 @@ describe('terminal mounting', () => {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const session = ctx.sessions.create(SessionId('main'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', ctx,
|
||||
followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
// Mirror dsh-tui's own inject (minus loader, the absence under test).
|
||||
@@ -3542,15 +3809,15 @@ describe('terminal mounting', () => {
|
||||
|
||||
const otherSession = ctx.sessions.create(SessionId('other-session'))
|
||||
ctx.agents.register({
|
||||
id: otherSession.id, options: {}, session: otherSession, status: 'idle', ctx,
|
||||
followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
id: otherSession.id, options: {}, session: otherSession, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
expect(terminal.started).toBe(0)
|
||||
|
||||
const session = ctx.sessions.create(SessionId('late-session'))
|
||||
const agent = {
|
||||
id: session.id, options: {}, session, status: 'idle', ctx,
|
||||
followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
} as Agent
|
||||
ctx.agents.register(agent)
|
||||
await tick()
|
||||
@@ -3579,8 +3846,8 @@ describe('terminal mounting', () => {
|
||||
|
||||
const session = ctx.sessions.create(SessionId('main-session'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', ctx,
|
||||
followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
await tick()
|
||||
expect(terminal.started).toBe(0)
|
||||
@@ -3621,8 +3888,8 @@ describe('terminal mounting', () => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'running', ctx,
|
||||
followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
id: session.id, options: {}, session, status: 'running', acceptsNextStep: true, ctx,
|
||||
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
terminal.start = () => { throw new Error('terminal startup failed') }
|
||||
|
||||
@@ -235,8 +235,8 @@ export class ApprovalService extends Service {
|
||||
})
|
||||
})
|
||||
|
||||
// Visibility layer 2: the boundary narrator. pre-step runs after prompt
|
||||
// assembly but before the request history is derived, so the notice is
|
||||
// Visibility layer 2: the boundary narrator. agent/step runs before the
|
||||
// request history is derived, so the notice is
|
||||
// seen by THIS step's request: idle-time flip-flops coalesce at the
|
||||
// turn's first step (net-zero → nothing), and a mid-turn switch is
|
||||
// narrated no later than the next step. What each session was last told
|
||||
@@ -246,7 +246,7 @@ export class ApprovalService extends Service {
|
||||
// switch by the user; otherwise the configured default moved under the
|
||||
// session (operator/config).
|
||||
const narrated = new WeakMap<Agent['session'], ApprovalPolicy>()
|
||||
ctx.on('agent/pre-step', (agent) => {
|
||||
ctx.on('agent/step', (agent) => {
|
||||
const session = agent.session
|
||||
const events = session.events
|
||||
let overrideIndex = -1
|
||||
@@ -269,10 +269,10 @@ export class ApprovalService extends Service {
|
||||
// to go out states the truth, and there is no delta to explain.
|
||||
if (told === undefined || told === current) return
|
||||
const cause = overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config'
|
||||
agent.inject(
|
||||
[{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }],
|
||||
{ source: { kind: 'plugin', plugin: 'user-approval' } },
|
||||
)
|
||||
agent.inject({
|
||||
content: [{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }],
|
||||
source: { kind: 'plugin', plugin: 'user-approval' },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -365,13 +365,15 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
const agent = {
|
||||
id,
|
||||
session,
|
||||
inject: (content: { type: string; text: string }[]) => { injected.push(content[0]?.text ?? '') },
|
||||
inject: (input: { content: Array<{ type: string; text: string }> }) => {
|
||||
injected.push(input.content[0]?.text ?? '')
|
||||
},
|
||||
} as unknown as Agent
|
||||
return { agent, session, injected }
|
||||
}
|
||||
|
||||
const preStep = (ctx: Context, agent: Agent): Promise<void> =>
|
||||
agentEvents(ctx, agent).serial('agent/pre-step', 1, 1, new AbortController().signal)
|
||||
agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
|
||||
|
||||
/** Append a `request/header` snapshot whose system text is exactly `system`. */
|
||||
function appendHeader(session: Session, system: string): void {
|
||||
|
||||
Reference in New Issue
Block a user