refactor: identify and freeze messages at creation
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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' } } },
|
||||
|
||||
@@ -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>›</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') }
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user