Merge origin/master into feat/tui-master-port

This commit is contained in:
Tianyi Cui
2026-07-27 23:34:56 +08:00
713 changed files with 16615 additions and 13373 deletions

View File

@@ -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 ToolRegistry, { type ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -28,12 +28,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() {

View File

@@ -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.' }
@@ -115,22 +118,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) {

View File

@@ -35,16 +35,23 @@ buffer
17| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-59 fg=bright-black
18| <blank>
19| "Prompt blocked: Unsafe policy \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-74 fg=yellow
20| <blank>
21| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
19| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-62 fg=red
22-23| <blank>
24| "Plan"
20-21| <blank>
22| "Plan"
style 0-3 fg=bright-blue bold
25| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
23| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
style 2-2 fg=yellow
24| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
25| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
26| " "
27| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 2-90 fg=bright-black

View File

@@ -514,11 +514,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,

View File

@@ -10,6 +10,7 @@ import {
type LlmCallConfig,
type LlmModelReasoningInfo,
} from '@deepseek-ai/dsh-llm'
import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
@@ -436,7 +437,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) => {
@@ -1076,6 +1076,45 @@ describe('resume command and /resume', () => {
})
describe('pi-tui chat lifecycle and transcript', () => {
it('restores durable goal phase without implying automatic continuation', async () => {
const change: GoalSnapshotChangeMeta = {
kind: 'goal/change',
version: GOAL_CHANGE_VERSION,
operation: 'create',
goal: {
id: GoalId('restored-goal'),
revision: 1,
objective: 'Resume only with human confirmation',
phase: 'active',
maxGoalRounds: 4,
},
roundsStarted: 0,
createdAt: 10,
updatedAt: 10,
}
const result = await setup({
beforeMount(session) {
session.append('user/message', {
content: renderGoalChange(change),
source: {
kind: 'goal',
goalId: change.goal.id,
revision: change.goal.revision,
round: 0,
change,
},
}, { surfaceOp: 'append' })
},
})
expect(result.terminal.output).toContain('Goal restored (active) with automatic continuation disarmed')
expect(result.terminal.output).toContain('/goal resume')
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('goal active')
await dispose(result)
})
it('uses the latest log-backed title for the header subtitle and terminal window', async () => {
const result = await setup({
// A fixed short cwd keeps the footer's token counters inside the 88-column
@@ -1171,8 +1210,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
content: [{ type: 'text', text: '<system-reminder>&#155;</system-reminder>' }],
source: { kind: 'plugin', plugin: 'workspace-control-context' },
}, { surfaceOp: 'append' })
result.session.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
result.session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked' }], source: { kind: 'user' }, reason: 'test policy' })
result.session.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' })
// 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' })
appendAssistant(result.session, [])
result.session.append('step/end', { turn: 1, step: 1 })
result.session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
@@ -1257,7 +1298,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('Additional instructions from: nested/AGENTS.md')
expect(result.terminal.output).not.toContain('<system-reminder>')
expect(result.terminal.output).toContain('\\x9b')
expect(result.terminal.output).toContain('Prompt blocked')
expect(result.terminal.output).toContain('Context · goal') // goal-sourced injected context labels by kind
expect(result.terminal.output).toContain('Turn cancelled')
expect(result.terminal.progress).toContain(true)
@@ -1365,30 +1406,38 @@ describe('pi-tui chat lifecycle and transcript', () => {
await tick()
expect(result.terminal.output).toContain('press enter to steer and esc to cancel')
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 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')
// 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')
@@ -1405,12 +1454,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 loop-authored steering event (plugin source, no matching agent/queued)
// 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,
@@ -1434,6 +1483,31 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).not.toContain('│')
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).not.toContain('queued')
await dispose(result)
})
@@ -1746,7 +1820,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
it('tracks steering drains without a running status line', async () => {
const result = await setup()
const source = { kind: 'user' as const }
result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'early' }], source, contexts: [], steering: true, wakeup: true })
result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'early' }], source }, 'steering')
result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'early' }], source }, { surfaceOp: 'append' })
await tick()
expect(result.terminal.output).not.toContain('queued')
@@ -2152,6 +2226,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()
@@ -2162,13 +2248,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 () => {
@@ -2203,20 +2282,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)
})
@@ -2256,7 +2567,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(() => {
@@ -2273,7 +2583,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()
@@ -2369,9 +2678,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)
})
@@ -2457,84 +2769,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')
@@ -2574,7 +2867,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)
@@ -2622,7 +2915,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)
@@ -2751,7 +3044,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' })
@@ -2788,7 +3080,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' })
@@ -2837,7 +3128,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',
@@ -2922,7 +3213,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)
@@ -3149,12 +3440,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' } },
})
events.session.append('turn/start', { turn: 8, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -3169,13 +3458,31 @@ 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('Turn stopped: the agent was disposed')
expect(events.terminal.output).toContain('Turn ended: plugin-policy')
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', () => {
@@ -3928,8 +4235,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, { theme: { color: false } }, { terminal, exit: vi.fn() })
@@ -3953,8 +4260,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).
@@ -3988,15 +4295,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()
@@ -4026,8 +4333,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)
@@ -4070,8 +4377,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') }