Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # apps/cli/README.i18n.yaml # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/client/connection/src/client/fixture.ts # packages/client/connection/src/client/index.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/input/hub.ts # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/compact/compact-basic/src/summarizer.ts # packages/compact/compact-basic/tests/compact-basic.spec.ts # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/rpc.schema.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/llm/llm-deepseek/tests/serialize.spec.ts # packages/llm/llm-pi-ai/tests/adapter.spec.ts # packages/llm/llm-pi-ai/tests/convert.spec.ts # packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts # packages/llm/token-meter/tests/token-meter.spec.ts # packages/ui/tui/README.i18n.yaml
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
terminal 96x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=15 bufferRow=15
|
||||
viewport
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Tracking the steps. "
|
||||
6| "Model wait 0.0s · Completed 2026-07-21 14:45:00 "
|
||||
style 0-46 dim
|
||||
7| <blank>
|
||||
8| "You "
|
||||
style 0-2 fg=bright-blue bold underline
|
||||
9| "Plan the work. "
|
||||
10| <blank>
|
||||
11| "You "
|
||||
style 0-2 fg=bright-blue bold underline
|
||||
12| "Next question. "
|
||||
13| <blank>
|
||||
14| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
15| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 7-7 inverse
|
||||
16-35| <blank>
|
||||
@@ -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 SessionReferenceService from '@deepseek-ai/dsh-session-reference'
|
||||
@@ -57,6 +57,7 @@ const CHECKPOINTS = [
|
||||
'resume-sessions',
|
||||
'status-diagnostics',
|
||||
'status-diagnostics-narrow',
|
||||
'todo-plan-cleared',
|
||||
] as const
|
||||
|
||||
// Real-loop scenarios own their assertions in separate snapshot suites but
|
||||
@@ -169,9 +170,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' })
|
||||
}
|
||||
@@ -299,6 +302,34 @@ describe('TUI terminal-state snapshots', () => {
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('clears the plan strip when the next turn starts', async () => {
|
||||
// Freeze Completed-at formatting: the first turn ends before the next starts,
|
||||
// so the assistant timing line still appears without a Plan strip below it.
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 14, 45, 0).getTime())
|
||||
const harness = await setupSnapshot({
|
||||
beforeMount(session) {
|
||||
appendUser(session, 'Plan the work.')
|
||||
appendAssistant(session, [{ type: 'text', text: 'Tracking the steps.' }])
|
||||
session.append('todo/write', {
|
||||
todos: [
|
||||
{ content: 'read code', status: 'completed' },
|
||||
{ content: 'write tests', status: 'in_progress' },
|
||||
],
|
||||
})
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
appendUser(session, 'Next question.')
|
||||
},
|
||||
})
|
||||
await checkpoint('todo-plan-cleared', harness.terminal)
|
||||
nowSpy.mockRestore()
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins failed-stream retraction, scheduled retry, and eventual success', async () => {
|
||||
const harness = await setupSnapshot()
|
||||
await renderAfter(harness, () => {
|
||||
@@ -325,8 +356,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 })
|
||||
@@ -542,10 +579,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,
|
||||
@@ -656,23 +693,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
|
||||
@@ -682,13 +727,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,
|
||||
})
|
||||
@@ -775,10 +820,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' } })
|
||||
@@ -1458,19 +1494,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' },
|
||||
})
|
||||
}), 'steering')
|
||||
}
|
||||
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')
|
||||
|
||||
@@ -1508,8 +1556,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')
|
||||
@@ -1533,18 +1583,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', other, discarded[0]!, 'steering')
|
||||
result.ctx.emit('agent/inbox/dequeue', result.agent, freezeMessage({
|
||||
id: MessageId('never-queued'),
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'x' }],
|
||||
source: { kind: 'user' },
|
||||
}), 'steering')
|
||||
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')
|
||||
@@ -1865,8 +1926,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)
|
||||
@@ -1921,7 +1993,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 })
|
||||
@@ -1958,7 +2035,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: '{}' },
|
||||
@@ -1977,7 +2054,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```' },
|
||||
@@ -2337,7 +2414,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { content: [{ type: 'text', text: 'source background' }], source: { kind: 'user' } },
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: 'source background' }],
|
||||
source: { kind: 'user' },
|
||||
}),
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
@@ -2384,7 +2464,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')
|
||||
@@ -2394,7 +2474,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()
|
||||
@@ -2441,7 +2521,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)
|
||||
@@ -2471,13 +2551,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 }),
|
||||
)
|
||||
}
|
||||
@@ -2485,19 +2563,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()
|
||||
@@ -2515,17 +2594,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])
|
||||
@@ -2539,11 +2620,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()
|
||||
@@ -2563,7 +2644,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')
|
||||
@@ -2574,7 +2655,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')
|
||||
@@ -2583,7 +2664,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()
|
||||
@@ -2598,31 +2682,27 @@ 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)!,
|
||||
result.ctx.emit('agent/inbox/discard', foreign, [result.agent.sentMessages.at(-1)!])
|
||||
// An unrelated discard for this agent also leaves the wrapper armed.
|
||||
result.ctx.emit('agent/inbox/discard', result.agent, [createUserMessage({
|
||||
content: [{ type: 'text', text: 'unrelated discard' }],
|
||||
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', 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()
|
||||
@@ -2777,7 +2857,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)
|
||||
@@ -2866,47 +2946,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')
|
||||
@@ -2918,15 +3000,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)
|
||||
@@ -3865,47 +3947,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()
|
||||
@@ -4002,20 +4127,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],
|
||||
})
|
||||
@@ -4336,7 +4472,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() })
|
||||
@@ -4361,7 +4497,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).
|
||||
@@ -4396,14 +4532,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()
|
||||
@@ -4434,7 +4570,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)
|
||||
@@ -4478,7 +4614,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') }
|
||||
|
||||
Reference in New Issue
Block a user