refactor: identify and freeze messages at creation

This commit is contained in:
_Kerman
2026-07-28 13:55:59 +08:00
parent c49c0ba497
commit fbf87e660c
345 changed files with 5220 additions and 2901 deletions

View File

@@ -8,6 +8,7 @@
import type { Context } from 'cordis'
import { resolve } from 'node:path'
import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope'
import { findLastMessageTurnEnd, SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type SubagentService from '@deepseek-ai/dsh-subagent'
@@ -140,7 +141,7 @@ export class HarnessSdkServer {
rec.activePrompt = true
try {
rec.lastTurnEnd = undefined
rec.handle.agent.followup({ content: params.contentBlocks, source: { kind: 'user' } })
rec.handle.agent.followup(createUserMessage({ content: params.contentBlocks, source: { kind: 'user' } }))
await rec.handle.agent.whenIdle()
const payload: SessionFinishedNotification = {
sessionId: params.sessionId,

View File

@@ -1,3 +1,4 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { mkdtemp, rm } from 'node:fs/promises'
@@ -5,9 +6,9 @@ import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { AgentMessageId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
@@ -153,7 +154,7 @@ describe('HarnessSdkServer', () => {
meta: { cwd: storageDir },
agentOptions: { provider: 'deepseek', model: 'dsagent-model' },
})
orphanHandle.agent.followup({ content: [{ type: 'text', text: 'outside the sdk session map' }], source: { kind: 'user' } })
orphanHandle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'outside the sdk session map' }], source: { kind: 'user' } }))
await orphanHandle.agent.whenIdle()
await orphanHandle.dispose()
expect(llmServer.requests).toHaveLength(3)
@@ -171,13 +172,13 @@ describe('HarnessSdkServer', () => {
const mainWhenIdle = vi.fn<() => Promise<void>>()
.mockReturnValueOnce(firstMainIdle)
.mockResolvedValue(undefined)
const mainFollowup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('main-followup'))
const mainFollowup = vi.fn<Agent['followup']>()
const mainAgent = ({
id: SessionId('main'),
followup: mainFollowup,
whenIdle: mainWhenIdle,
} satisfies Pick<Agent, 'id' | 'followup' | 'whenIdle'>) as unknown as Agent
const otherFollowup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('other-followup'))
const otherFollowup = vi.fn<Agent['followup']>()
const otherAgent = ({
id: SessionId('other'),
followup: otherFollowup,
@@ -220,7 +221,7 @@ describe('HarnessSdkServer', () => {
})
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 followup = vi.fn<Agent['followup']>()
const agent = ({
id: SessionId('zombie'),
followup,
@@ -266,7 +267,7 @@ describe('HarnessSdkServer', () => {
const agent = ({
id: SessionId('message-outcome'),
session,
followup(input: { content: { type: 'text'; text: string }[]; source: { kind: 'user' } }) {
followup(input: UserMessage) {
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: input.source },
@@ -277,12 +278,12 @@ describe('HarnessSdkServer', () => {
turn: 2,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } },
})
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'late metadata' }],
source: { kind: 'plugin', plugin: 'late-metadata' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
return AgentMessageId('message-outcome')
return input.id
},
whenIdle: () => Promise.resolve(),
} satisfies Pick<Agent, 'id' | 'session' | 'followup' | 'whenIdle'>) as unknown as Agent

View File

@@ -101,7 +101,7 @@ export function activeToolCallIds(session: Session, active: ReadonlySet<number>)
const ids = new Set<string>()
for (const event of session.events) {
if (event.type !== 'assistant/message' || !active.has(event.seq)) continue
for (const block of event.data.content) {
for (const block of event.data.message.content) {
if (block.type === 'tool-call') ids.add(block.id)
}
}

View File

@@ -423,7 +423,7 @@ function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined {
}
const assistant = snapshot.events.findLast(item => item.type === 'assistant/message')
return assistant?.type === 'assistant/message'
? { provider: assistant.data.provenance.provider, model: assistant.data.provenance.model }
? { provider: assistant.data.message.source.provider, model: assistant.data.message.source.model }
: undefined
}

View File

@@ -336,9 +336,10 @@ export class ToolCardComponent implements Component {
* @param event - The `tool/result` event payload.
*/
updateResult(event: Extract<SessionEvent, { type: 'tool/result' }>['data']): void {
const result = event.message.content[0]
this.result = {
content: [...event.content],
isError: event.isError,
content: [...result.content],
isError: result.isError === true,
...event.meta !== undefined ? { meta: event.meta } : {},
}
if (this.parsed.valid && this.definition?.presentResult) {

View File

@@ -24,22 +24,21 @@ import {
assembleContextFor,
installAgentLlmTarget,
type Agent,
type AgentMessageId,
type AgentLlmTargetRef,
type AgentStatus,
} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-loop'
import type {} from '@deepseek-ai/dsh-token-meter'
import type { CommandResult } from '@deepseek-ai/dsh-commands'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm'
import { renderUnknownXml } from './components/xml-tool-output.ts'
import type {} from '@deepseek-ai/dsh-llm-retry'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
SessionId,
type SessionEvent,
type UserMessageData,
type UserMessage,
} from '@deepseek-ai/dsh-session'
import { foldGoal } from '@deepseek-ai/dsh-goal'
import {
@@ -280,7 +279,7 @@ export function createTuiChat(
// 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>()
const pendingSteering = new Set<MessageId>()
let disposed = false
let shuttingDown: Promise<void> | undefined
// Optional: skills mount conditionally, so read the global service store
@@ -655,7 +654,7 @@ export function createTuiChat(
break
}
case 'steering/message': {
const text = displayText(contentText(event.data.content).trim())
const text = displayText(contentText(event.data.message.content).trim())
if (text) {
chat.addChild(new Spacer(1))
chat.addChild(new UserMessageComponent(text, palette, mdTheme, 'Steering'))
@@ -671,7 +670,7 @@ export function createTuiChat(
case 'assistant/message':
completedStreaming = undefined
if (streaming === undefined || !chat.children.includes(streaming)) startAssistantStep(event.data)
streaming?.settle(event.data.content)
streaming?.settle(event.data.message.content)
break
case 'llm/retry': {
retractFailedStreaming()
@@ -688,7 +687,8 @@ export function createTuiChat(
trailStreamingTiming()
break
case 'tool/result': {
let card = toolCards.get(event.data.callId)
const callId = event.data.message.source.callId
let card = toolCards.get(callId)
if (card === undefined) {
card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette, mdTheme)
chat.addChild(new Spacer(1))
@@ -696,7 +696,7 @@ export function createTuiChat(
allToolCards.add(card)
}
card.updateResult(event.data)
toolCards.delete(event.data.callId)
toolCards.delete(callId)
trailStreamingTiming()
break
}
@@ -1120,7 +1120,7 @@ export function createTuiChat(
).finally(() => { commandControllers.delete(controller) })
}
const dispatchMessage = (content: ContentBlock[], attachedContext?: UserMessageData): void => {
const dispatchMessage = (content: ContentBlock[], attachedContext?: UserMessage): void => {
if (disposed) {
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
return
@@ -1129,43 +1129,37 @@ export function createTuiChat(
// 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 })
agent.inject(attachedContext)
}
pendingSteering.add(agent.steer({ content, source: { kind: 'user' } }))
const message = createUserMessage({ content, source: { kind: 'user' } })
agent.steer(message)
pendingSteering.add(message.id)
refreshStatus()
return
}
if (attachedContext === undefined) {
agent.followup({ content, source: { kind: 'user' } })
agent.followup(createUserMessage({ 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 message: UserMessage = createUserMessage({ content, source: { kind: 'user' } })
const acceptedId = message.id
const discarded = new Set<MessageId>()
const cleanup = (): void => {
// Every completion path detaches all three listeners. Keep this
// Every completion path detaches both 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()
// Prepended so this wrapper is outermost: it observes the exact accepted
// message identity whether a downstream hook allows or blocks, then detaches.
const detachSubmit = ctx.on('agent/prompt-submit', async (subject, submitted, _signal, next) => {
if (subject !== agent || submitted.id !== message.id) return next()
cleanup()
const decision = await next()
if (decision.kind !== 'allow') return decision
@@ -1176,15 +1170,13 @@ export function createTuiChat(
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()
if (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()
agent.followup(message)
if (discarded.has(acceptedId)) cleanup()
} catch (error: unknown) {
cleanup()
@@ -1388,7 +1380,7 @@ export function createTuiChat(
renderEvent(event, { addHistory: false, renderChunks: true })
requestRender()
})
const settlePendingSteering = (id: AgentMessageId): void => {
const settlePendingSteering = (id: MessageId): void => {
if (pendingSteering.delete(id)) refreshStatus()
}
const disposeDequeued = ctx.on('agent/inbox/dequeue', (subject, message) => {

View File

@@ -1,7 +1,7 @@
import { createUserMessage, MessageId , createMessage } from '@deepseek-ai/dsh-llm'
import { Context } from 'cordis'
import type { Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, {
AgentMessageId,
type Agent,
type AgentCancelCause,
type AgentOptions,
@@ -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, type UserMessageData } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, type Session, type SessionHeader, type UserMessage } 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'
@@ -26,12 +26,13 @@ import TuiPromptService from '../src/prompt.ts'
interface FakeAgent extends Agent {
status: AgentStatus
sent: ContentBlock[][]
sentMessages: UserMessage[]
sentOptions: (SendOptions | undefined)[]
steered: ContentBlock[][]
steeredIds: AgentMessageId[]
steeredOptions: UserMessageData[]
steeredIds: MessageId[]
steeredOptions: UserMessage[]
injected: ContentBlock[][]
injectedOptions: UserMessageData[]
injectedOptions: UserMessage[]
cancelled: AgentCancelCause[]
}
@@ -181,12 +182,13 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
}
options.beforeMount?.(session)
const sent: ContentBlock[][] = []
const sentMessages: UserMessage[] = []
const steered: ContentBlock[][] = []
const steeredIds: AgentMessageId[] = []
const steeredIds: MessageId[] = []
const sentOptions: (SendOptions | undefined)[] = []
const steeredOptions: UserMessageData[] = []
const steeredOptions: UserMessage[] = []
const injected: ContentBlock[][] = []
const injectedOptions: UserMessageData[] = []
const injectedOptions: UserMessage[] = []
const cancelled: AgentCancelCause[] = []
const agent: FakeAgent = {
id: sessionId,
@@ -198,6 +200,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
},
ctx,
sent,
sentMessages,
sentOptions,
steered,
steeredIds,
@@ -207,25 +210,27 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
cancelled,
send(input, options) {
sent.push(input.content)
sentMessages.push(input)
sentOptions.push(options)
return AgentMessageId('stub')
return input.id
},
followup(input) {
sent.push(input.content)
sentMessages.push(input)
sentOptions.push(undefined)
return AgentMessageId('stub')
return input.id
},
steer(input) {
steered.push(input.content)
steeredOptions.push(input)
const id = AgentMessageId(`steering-${steeredIds.length + 1}`)
const id = input.id
steeredIds.push(id)
return id
},
inject(input) {
injected.push(input.content)
injectedOptions.push(input)
return AgentMessageId('stub')
return input.id
},
cancel(cause) {
cancelled.push(cause)
@@ -263,10 +268,10 @@ export async function disposeTuiTestHarness(
/** Append a production-shaped user message to the active session surface. */
export function appendUser(session: Session, text: string): void {
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
}
/** Append a production-shaped assistant message to the active session surface. */
@@ -278,8 +283,11 @@ export function appendAssistant(
): void {
session.append('assistant/message', {
...position,
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
content,
message: createMessage({
role: 'assistant',
content,
source: { kind: 'model', provider: 'mock', model: 'deepseek-v4-flash' },
}),
...usage === undefined ? {} : { usage },
}, { surfaceOp: 'append' })
}

View File

@@ -3,7 +3,7 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
import LlmService, { createUserMessage, LlmAdapter, type GenerateOptions, type StreamChunk , createMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
@@ -68,27 +68,33 @@ describe('TUI session-reference snapshot', () => {
const adapter = new SnapshotAdapter()
ctx.llm.registerAdapter(['mock'], adapter)
const source = ctx.sessions.create(SessionId('source-session'), { meta: { cwd: '/workspace/project', createdAt: 1 } })
const oldUser = source.append('user/message', {
const oldUser = source.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'SHADOWED OLD USER' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
const oldAssistant = source.append('assistant/message', {
turn: 1,
step: 1,
provenance: { provider: 'mock', model: 'mock' },
content: [{ type: 'text', text: 'SHADOWED OLD ASSISTANT' }],
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'SHADOWED OLD ASSISTANT' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
source.append('user/message', {
source.append('user/message', createUserMessage({
content: [{ type: 'text', text: '<compacted-summary>Retained checkpoint.</compacted-summary>' }],
source: { kind: 'plugin', plugin: 'compact' },
}, {
}), {
surfaceOp: { op: 'replace', start: oldUser.seq, end: oldAssistant.seq },
sourceEventSeqs: [oldUser.seq, oldAssistant.seq],
})
source.append('user/message', {
source.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Recent retained question.' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
const target = ctx.agentLoop.create(
SessionId('target-session'),

View File

@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it, vi } from 'vitest'
import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
import { createUserMessage, CallId, type ContentBlock , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import { SessionId, type JsonValue, type Session } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -166,9 +166,11 @@ function appendToolResult(
session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId(id),
content,
isError: options.isError ?? false,
message: createToolResultMessage({
callId: CallId(id),
content,
isError: options.isError ?? false,
}),
...options.meta === undefined ? {} : { meta: options.meta },
}, { surfaceOp: 'append' })
}
@@ -322,8 +324,14 @@ describe('TUI terminal-state snapshots', () => {
harness.session.append('assistant/message', {
turn: 1,
step: 2,
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
content: [{ type: 'text', text: 'Recovered on the next bounded attempt.' }],
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'Recovered on the next bounded attempt.' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'deepseek-v4-flash' },
},
}),
}, { surfaceOp: 'append' })
harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await checkpoint('retry-recovered', harness.terminal, { includeScrollback: true })
@@ -515,10 +523,10 @@ describe('TUI terminal-state snapshots', () => {
session.append('todo/write', {
todos: [{ content: `Unsafe todo ${CONTROL_PROBE}`, status: 'in_progress' }],
})
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `Unsafe context ${CONTROL_PROBE}` }],
source: { kind: 'plugin', plugin: `unsafe-${CONTROL_PROBE}` },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', {
turn: 1,
@@ -629,23 +637,31 @@ describe('TUI terminal-state snapshots', () => {
const harness = await setupSnapshot({
tools: ADVANCED_CARD_TOOLS,
beforeMount(session) {
const user = session.append('user/message', {
const user = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Old prompt with a long line that exercises wrapping before compaction.' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
const assistant = session.append('assistant/message', {
turn: 1,
step: 1,
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }],
message: createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'deepseek-v4-flash' },
},
}),
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('old-tool'), name: 'bash', arguments: '{}' })
const result = session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('old-tool'),
content: [{ type: 'text', text: 'obsolete output that must disappear' }],
isError: false,
message: createToolResultMessage({
callId: CallId('old-tool'),
content: [{ type: 'text', text: 'obsolete output that must disappear' }],
isError: false,
}),
}, { surfaceOp: 'append' })
replacementStart = user.seq
replacementEnd = result.seq
@@ -655,13 +671,13 @@ describe('TUI terminal-state snapshots', () => {
await checkpoint('surface-before-compaction', harness.terminal, { includeScrollback: true })
await renderAfter(harness, () => {
harness.session.append('user/message', {
harness.session.append('user/message', createUserMessage({
content: [{
type: 'text',
text: '<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nRender workspace context XML clearly.\n</system-reminder>',
}],
source: { kind: 'plugin', plugin: 'workspace-context' },
}, {
}), {
surfaceOp: { op: 'replace', start: replacementStart, end: replacementEnd },
sourceEventSeqs: replacementSources,
})
@@ -748,10 +764,22 @@ describe('TUI terminal-state snapshots', () => {
meta: earlier,
events: [
{ type: 'turn/start', seq: 0, time: Date.parse('2024-01-01T00:00:01Z'), data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'user/message', seq: 1, time: Date.parse('2024-01-01T00:00:02Z'), data: { content: [{ type: 'text', text: 'restore the selector' }], source: { kind: 'user' } }, surfaceOp: 'append' },
{ type: 'user/message', seq: 1, time: Date.parse('2024-01-01T00:00:02Z'), data: createUserMessage({
content: [{ type: 'text', text: 'restore the selector' }], source: { kind: 'user' },
}), surfaceOp: 'append' },
{ type: 'step/start', seq: 2, time: Date.parse('2024-01-01T00:00:03Z'), data: { turn: 1, step: 1 } },
{ type: 'request/header', seq: 3, time: Date.parse('2024-01-01T00:00:04Z'), data: { header: { config: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, reason: 'initial' } },
{ type: 'assistant/message', seq: 4, time: Date.parse('2024-01-01T00:00:05Z'), data: { turn: 1, step: 1, content: [{ type: 'text', text: 'ready' }], provenance: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, surfaceOp: 'append' },
{ type: 'assistant/message', seq: 4, time: Date.parse('2024-01-01T00:00:05Z'), data: {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'ready' }],
source: {
kind: 'model',
...{ provider: 'deepseek', model: 'deepseek-v4-pro' },
},
}),
}, surfaceOp: 'append' },
{ type: 'step/end', seq: 5, time: Date.parse('2024-01-01T00:00:06Z'), data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 6, time: Date.parse('2024-01-01T00:00:07Z'), data: { turn: 1, reason: { kind: 'completed' } } },
{ type: 'session/title', seq: 7, time: Date.parse('2024-01-01T00:00:08Z'), data: { title: 'Resume selector design', messageSeqs: [1], source: { kind: 'fallback' } } },

View File

@@ -4,11 +4,15 @@ import { join, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CombinedAutocompleteProvider, visibleWidth, type Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { agentEvents, AgentMessageId, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
import {
import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage,
createToolResultMessage,
ReasoningEffortId,
type LlmCallConfig,
type LlmModelReasoningInfo,
MessageId,
createMessage,
freezeMessage,
} 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'
@@ -236,10 +240,22 @@ describe('resume command and /resume', () => {
reason: TurnEndReason = { kind: 'completed' },
): SessionEvent[] => [
{ type: 'turn/start', seq: 0, time, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'user/message', seq: 1, time: time + 1, data: { content: [{ type: 'text', text: 'resume me' }], source: { kind: 'user' } }, surfaceOp: 'append' },
{ type: 'user/message', seq: 1, time: time + 1, data: createUserMessage({
content: [{ type: 'text', text: 'resume me' }], source: { kind: 'user' },
}), surfaceOp: 'append' },
{ type: 'step/start', seq: 2, time: time + 2, data: { turn: 1, step: 1 } },
{ type: 'request/header', seq: 3, time: time + 3, data: { header: { config: { provider, model: 'model-1' } }, reason: 'initial' } },
{ type: 'assistant/message', seq: 4, time: time + 4, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'done' }], provenance: { provider, model: 'model-1' } }, surfaceOp: 'append' },
{ type: 'assistant/message', seq: 4, time: time + 4, data: {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
source: {
kind: 'model',
...{ provider, model: 'model-1' },
},
}),
}, surfaceOp: 'append' },
{ type: 'step/end', seq: 5, time: time + 5, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 6, time: time + 6, data: { turn: 1, reason } },
{ type: 'session/title', seq: 7, time: time + 7, data: { title, messageSeqs: [1], source: { kind: 'fallback' } } },
@@ -1094,7 +1110,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
}
const result = await setup({
beforeMount(session) {
session.append('user/message', {
session.append('user/message', createUserMessage({
content: renderGoalChange(change),
source: {
kind: 'goal',
@@ -1103,7 +1119,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
round: 0,
change,
},
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
},
})
expect(result.terminal.output).toContain('Goal restored (active) with automatic continuation disarmed')
@@ -1198,22 +1214,42 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.agent.status = 'running'
agentEvents(result.ctx, result.agent).emit('agent/status', 'running')
now = 8_000
result.session.append('user/message', { content: [{ type: 'text', text: ' ' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: 'steering note' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
result.session.append('user/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
result.session.append('user/message', {
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: ' ' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
result.session.append('steering/message', {
turn: 2,
message: createUserMessage({
content: [{ type: 'text', text: 'steering note' }],
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })
result.session.append('steering/message', {
turn: 2,
message: createUserMessage({
content: [{ type: 'text', text: '' }],
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'user context' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nRender XML context clearly.\n</system-reminder>' }],
source: { kind: 'plugin', plugin: 'workspace-context' },
}, { surfaceOp: 'append' })
result.session.append('user/message', {
}), { surfaceOp: 'append' })
result.session.append('user/message', createUserMessage({
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: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
result.session.append('user/message', createUserMessage({
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' })
result.session.append('user/message', createUserMessage({
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' } })
@@ -1433,19 +1469,31 @@ describe('pi-tui chat lifecycle and transcript', () => {
const drainSteering = (text: string): void => {
const id = result.agent.steeredIds.shift()
if (id !== undefined) {
result.ctx.emit('agent/inbox/dequeue', result.agent, {
result.ctx.emit('agent/inbox/dequeue', result.agent, freezeMessage({
id,
role: 'user',
content: [{ type: 'text', text }],
source: { kind: 'user' },
})
}))
}
result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' })
result.session.append('steering/message', {
turn: 1,
message: createUserMessage({
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' } }, 'queued')
result.ctx.emit('agent/inbox/enqueue', other, freezeMessage({
id: MessageId('stub'),
role: 'user',
content: [{ type: 'text', text: 'elsewhere' }],
source: { kind: 'user' },
}), 'queued')
await tick()
expect(result.terminal.output).not.toContain('queued')
@@ -1483,8 +1531,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.terminal.output = ''
result.session.append('steering/message', {
turn: 1,
content: [{ type: 'text', text: 'continue: goal not reached' }],
source: { kind: 'plugin', plugin: 'hooks' },
message: createUserMessage({
content: [{ type: 'text', text: 'continue: goal not reached' }],
source: { kind: 'plugin', plugin: 'hooks' },
}),
}, { surfaceOp: 'append' })
await tick()
expect(result.terminal.output).toContain('1 queued')
@@ -1508,18 +1558,29 @@ describe('pi-tui chat lifecycle and transcript', () => {
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 },
const discarded = result.agent.steeredIds.splice(0).map(id => freezeMessage({
id,
role: 'user' as const,
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/dequeue', result.agent, freezeMessage({
id: MessageId('never-queued'),
role: 'user',
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' } },
freezeMessage({
id: MessageId('never-queued'),
role: 'user',
content: [{ type: 'text', text: 'x' }],
source: { kind: 'user' },
}),
])
await tick()
expect(result.terminal.output).toContain('2 queued')
@@ -1840,8 +1901,19 @@ 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 }, 'steering')
result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'early' }], source }, { surfaceOp: 'append' })
result.ctx.emit('agent/inbox/enqueue', result.agent, freezeMessage({
id: MessageId('stub'),
role: 'user',
content: [{ type: 'text', text: 'early' }],
source,
}), 'steering')
result.session.append('steering/message', {
turn: 1,
message: createUserMessage({
content: [{ type: 'text', text: 'early' }],
source,
}),
}, { surfaceOp: 'append' })
await tick()
expect(result.terminal.output).not.toContain('queued')
await dispose(result)
@@ -1896,7 +1968,12 @@ describe('pi-tui chat lifecycle and transcript', () => {
])
result.session.append('tool/call', { turn: 1, step: 1, callId: 'c1' as never, name: 'bash', arguments: '{}' })
result.session.append('tool/result', {
turn: 1, step: 1, callId: 'c1' as never, content: [{ type: 'text', text: 'command output' }], isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c1' as never,
content: [{ type: 'text', text: 'command output' }],
isError: false,
}),
}, { surfaceOp: 'append' })
result.terminal.output = ''
result.session.append('step/end', { turn: 1, step: 1 })
@@ -1933,7 +2010,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
cwd: '/workspace',
config: { theme: { color: true } },
beforeMount(session) {
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [
{ type: 'text', text: '# Heading\n\n[link](https://example.com) `code`\n\n```ts\nconst x = 1\n```\n\n> quote\n\n---\n\n- item\n\n**bold** *italic* ~~strike~~' },
{ type: 'tool-call', id: 'nested' as never, name: 'nested_tool', arguments: '{}' },
@@ -1942,7 +2019,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
{} as never,
],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
appendAssistant(session, [
{ type: 'reasoning', text: 'styled reasoning' },
{ type: 'text', text: 'styled answer\n\n```ts\nconst answer = 42\n```' },
@@ -2306,7 +2383,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
// 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' },
'agent/prompt-submit', result.agent.sentMessages[0]!,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(decision.kind).toBe('allow')
@@ -2316,7 +2393,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
// 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' },
'agent/prompt-submit', result.agent.sentMessages[0]!,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(replay.kind === 'allow' && replay.additionalContexts).toBeUndefined()
@@ -2363,7 +2440,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
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' },
'agent/prompt-submit', result.agent.sentMessages[0]!,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(decision.kind === 'allow' && decision.additionalContexts?.[0]?.source)
@@ -2393,13 +2470,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
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) {
// Each wrapper releases on its own identified message's allowed admission.
// Running each prompt's admission waterfall detaches its wrapper.
for (const sent of result.agent.sentMessages) {
await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', sent, { kind: 'user' },
'agent/prompt-submit', sent,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
}
@@ -2407,19 +2482,20 @@ describe('pi-tui chat lifecycle and transcript', () => {
// 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' },
}])
result.ctx.emit('agent/inbox/discard', result.agent, [result.agent.sentMessages[0]!])
const unrelated = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', [{ type: 'text', text: 'unrelated' }], { kind: 'user' },
'agent/prompt-submit', createUserMessage({
content: [{ type: 'text', text: 'unrelated' }],
source: { 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) {
for (const sent of result.agent.sentMessages) {
const replay = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', sent, { kind: 'user' },
'agent/prompt-submit', sent,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(replay.kind === 'allow' && replay.additionalContexts).toBeUndefined()
@@ -2437,17 +2513,19 @@ describe('pi-tui chat lifecycle and transcript', () => {
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.
// Real send() publishes its already identified snapshot, then an enqueue
// listener may synchronously cancel and discard it before followup()
// returns that 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'),
result.agent.sentMessages.push(input)
const message = freezeMessage({
id: input.id,
role: 'user' as const,
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])
@@ -2461,11 +2539,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
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.
// The synchronous discard released the listeners before followup()
// returned the existing id: 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' },
'agent/prompt-submit', result.agent.sentMessages[0]!,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(replay.kind === 'allow' && replay.additionalContexts).toBeUndefined()
@@ -2485,7 +2563,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
// 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) =>
result.ctx.on('agent/prompt-submit', async (_agent, _message, _signal, next) =>
blockPrompts ? { kind: 'block' as const, reason: 'policy' } : next())
result.terminal.send('@blocked-source')
@@ -2496,7 +2574,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
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' },
'agent/prompt-submit', result.agent.sentMessages[0]!,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(blocked.kind).toBe('block')
@@ -2505,7 +2583,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
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' },
'agent/prompt-submit', createUserMessage({
content: [{ type: 'text', text: 'unrelated' }],
source: { kind: 'user' },
}),
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(unrelated.kind === 'allow' && unrelated.additionalContexts).toBeUndefined()
@@ -2520,31 +2601,22 @@ describe('pi-tui chat lifecycle and transcript', () => {
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' },
'agent/prompt-submit', createUserMessage({
content: [{ type: 'text', text: 'different prompt' }],
source: { 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' },
}])
result.ctx.emit('agent/inbox/discard', foreign, [result.agent.sentMessages.at(-1)!])
result.ctx.emit('agent/inbox/discard', result.agent, [result.agent.sentMessages.at(-1)!])
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' },
}])
result.ctx.emit('agent/inbox/discard', result.agent, [result.agent.sentMessages.at(-1)!])
const afterDiscard = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', result.agent.sent.at(-1)!, { kind: 'user' },
'agent/prompt-submit', result.agent.sentMessages.at(-1)!,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(afterDiscard.kind === 'allow' && afterDiscard.additionalContexts).toBeUndefined()
@@ -2699,7 +2771,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
{ type: 'text', text: '@evil\\x1b\\x07\\x9b\\x0as' },
]])
const decision = await agentEvents(result.ctx, result.agent).waterfall(
'agent/prompt-submit', result.agent.sent[0]!, { kind: 'user' },
'agent/prompt-submit', result.agent.sentMessages[0]!,
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
)
expect(decision.kind === 'allow' && decision.additionalContexts?.[0]?.source)
@@ -2788,47 +2860,49 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('Session reference failed')
expect(result.terminal.output).toContain('keep @[')
result.session.append('user/message', {
result.session.append('user/message', createUserMessage({
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', {
}), { surfaceOp: 'append' })
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'visible referenced question' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { 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 snapshot payload')
result.session.append('user/message', {
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hidden steering context' }],
source: {
kind: 'session-reference',
references: [{ sessionId: 'steering-source', label: 'Steering source' }],
} as never,
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
result.session.append('steering/message', {
turn: 1,
content: [{ type: 'text', text: 'visible steering prompt' }],
source: { kind: 'user' },
message: createUserMessage({
content: [{ type: 'text', text: 'visible steering prompt' }],
source: { kind: 'user' },
}),
}, { 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 steering context')
result.session.append('user/message', {
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'secret full snapshot payload' }],
source: {
kind: 'session-reference',
version: 1,
references: [{ sessionId: 'source', label: 'Source', capturedThroughSeq: 2 }],
} as never,
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
await tick()
expect(result.terminal.output).toContain('Referenced sessions · Source (source)')
expect(result.terminal.output).not.toContain('secret full snapshot payload')
@@ -2840,15 +2914,15 @@ describe('pi-tui chat lifecycle and transcript', () => {
[{ kind: 'session-reference', references: [{}] }, 'invalid-fields'],
]
for (const [source, text] of invalidCards) {
result.session.append('user/message', {
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: source as never,
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
}
result.session.append('user/message', {
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'same-label snapshot' }],
source: { kind: 'session-reference', references: [{ sessionId: 'same', label: 'same' }] } as never,
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
await tick()
expect(result.terminal.output).toContain('Referenced sessions · same')
await dispose(result)
@@ -3785,47 +3859,90 @@ describe('tool cards and surface replay', () => {
expect(result.terminal.output).toContain('call presenter boom')
expect(result.terminal.output).toContain('Symbol(input)')
result.session.append('tool/result', {
turn: 1, step: 1, callId: 'c1' as never, content: [{ type: 'text', text: 'raw bash' }], isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c1' as never,
content: [{ type: 'text', text: 'raw bash' }],
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 1, callId: 'c2' as never, content: [{ type: 'text', text: 'stopped' }], isError: true,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c2' as never,
content: [{ type: 'text', text: 'stopped' }],
isError: true,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 1, callId: 'c3' as never, content: [{ type: 'text', text: 'done' }], isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c3' as never,
content: [{ type: 'text', text: 'done' }],
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 1, callId: 'c4' as never, content: [{ type: 'text', text: 'raw generic' }], isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c4' as never,
content: [{ type: 'text', text: 'raw generic' }],
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 1, callId: 'c5' as never, content: [{ type: 'text', text: 'raw throwing' }], isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c5' as never,
content: [{ type: 'text', text: 'raw throwing' }],
isError: false,
}),
meta: { value: 1 },
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 1, callId: 'c7' as never,
content: [
{ type: 'tool-call', id: 'inner' as never, name: 'inner', arguments: '{}' },
{ type: 'tool-result', toolCallId: 'inner' as never, content: [{ type: 'text', text: 'nested output' }] },
{ type: 'future-result' } as never,
],
isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c7' as never,
content: [
{ type: 'tool-call', id: 'inner' as never, name: 'inner', arguments: '{}' },
{ type: 'tool-result', toolCallId: 'inner' as never, content: [{ type: 'text', text: 'nested output' }] },
{ type: 'future-result' } as never,
],
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 1, callId: 'c8' as never, content: [{ type: 'text', text: '\nundefined presenter output\n\nkept tail\n' }], isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c8' as never,
content: [{ type: 'text', text: '\nundefined presenter output\n\nkept tail\n' }],
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 1, callId: 'c11' as never, content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }], isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c11' as never,
content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }],
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 1, callId: 'c13' as never,
content: [{ type: 'text', text: '<known><value>literal</value></known>' }],
isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c13' as never,
content: [{ type: 'text', text: '<known><value>literal</value></known>' }],
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1,
step: 1,
callId: 'orphan' as never,
content: [{ type: 'text', text: '<result><path>/tmp/a.txt</path><content><line number="1">hello</line><line number="2">world</line></content></result>' }],
isError: true,
message: createToolResultMessage({
callId: 'orphan' as never,
content: [{ type: 'text', text: '<result><path>/tmp/a.txt</path><content><line number="1">hello</line><line number="2">world</line></content></result>' }],
isError: true,
}),
error: { name: 'InterruptedError', code: 'interrupted' },
}, { surfaceOp: 'append' })
await tick()
@@ -3922,20 +4039,31 @@ describe('tool cards and surface replay', () => {
const assistant = result.session.append('assistant/message', {
turn: 1,
step: 1,
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
content: [{ type: 'tool-call', id: 'old-call' as never, name: 'bash', arguments: '{}' }],
message: createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: 'old-call' as never, name: 'bash', arguments: '{}' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'deepseek-v4-flash' },
},
}),
}, { surfaceOp: 'append' })
result.session.append('tool/call', {
turn: 1, step: 1, callId: 'old-call' as never, name: 'bash', arguments: '{}',
})
const toolResult = result.session.append('tool/result', {
turn: 1, step: 1, callId: 'old-call' as never, content: [{ type: 'text', text: 'old output' }], isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'old-call' as never,
content: [{ type: 'text', text: 'old output' }],
isError: false,
}),
}, { surfaceOp: 'append' })
const start = result.session.surface.nodes[0] as number
result.session.append('user/message', {
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'summary replacement' }],
source: { kind: 'plugin', plugin: 'compact' },
}, {
}), {
surfaceOp: { op: 'replace', start, end: toolResult.seq },
sourceEventSeqs: [start, assistant.seq, toolResult.seq],
})
@@ -4256,7 +4384,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main'))
ctx.agents.register({
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(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() })
@@ -4281,7 +4409,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main'))
ctx.agents.register({
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(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
// Mirror dsh-tui's own inject (minus loader, the absence under test).
@@ -4316,14 +4444,14 @@ describe('terminal mounting', () => {
const otherSession = ctx.sessions.create(SessionId('other-session'))
ctx.agents.register({
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(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, 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', acceptsNextStep: false, ctx,
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
} as Agent
ctx.agents.register(agent)
await tick()
@@ -4354,7 +4482,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main-session'))
ctx.agents.register({
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(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
await tick()
expect(terminal.started).toBe(0)
@@ -4398,7 +4526,7 @@ describe('terminal mounting', () => {
session.append('step/start', { turn: 1, step: 1 })
ctx.agents.register({
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(),
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
terminal.start = () => { throw new Error('terminal startup failed') }

View File

@@ -8,7 +8,7 @@ import { randomUUID } from 'node:crypto'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { CallId } from '@deepseek-ai/dsh-llm'
import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
@@ -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({
agent.inject(createUserMessage({
content: [{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }],
source: { kind: 'plugin', plugin: 'user-approval' },
})
}))
})
}