Merge branch 'codex/tool-json-schema-dsl' into codex/canonical-tool-output
# Conflicts: # docs/config-catalog.md # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/ui/acp/src/index.ts # packages/ui/tui/src/index.ts
This commit is contained in:
@@ -14,6 +14,8 @@ An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label
|
||||
|
||||
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.
|
||||
|
||||
When optional `ctx.sessionReferences` is mounted, the existing `@` file menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares the selected snapshots before dispatch. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.send()` from the status after that asynchronous preparation, so idle sends still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook.
|
||||
|
||||
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
|
||||
|
||||
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
|
||||
@@ -67,7 +69,7 @@ The palette uses the standard 16-color ANSI foregrounds and SGR attributes, whic
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Each non-empty ordinary editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only; command results remain terminal notices. A command producer may schedule a separate agent input, such as the optional message accepted by `/plan [message]`.
|
||||
Each non-empty ordinary editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. A session mention becomes readable `@label` text plus the durable untrusted context defined by [`dsh-session-reference`](../../context/session-reference/README.md); its full JSON is hidden behind a compact reference card. Slash commands and keybindings are TUI-only; command results remain terminal notices. A command producer may schedule a separate agent input, such as the optional message accepted by `/plan [message]`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-reference": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-title": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill": "^0.0.1",
|
||||
@@ -64,6 +65,8 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
visibleWidth,
|
||||
wrapTextWithAnsi,
|
||||
type Component,
|
||||
type AutocompleteItem,
|
||||
type AutocompleteProvider,
|
||||
type AutocompleteSuggestions,
|
||||
type EditorTheme,
|
||||
type Focusable,
|
||||
type MarkdownTheme,
|
||||
@@ -42,6 +45,7 @@ import {
|
||||
type AgentLlmTarget,
|
||||
type AgentLlmTargetRef,
|
||||
type AgentStatus,
|
||||
type HookContext,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-agent-loop'
|
||||
import type {} from '@deepseek-ai/dsh-token-meter'
|
||||
@@ -54,7 +58,20 @@ import type {
|
||||
TokenUsage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import { SessionId, type JsonValue, type Session, type SessionEvent, type SessionHeader, type TodoItem } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
displayPromptContent,
|
||||
SessionId,
|
||||
type JsonValue,
|
||||
type Session,
|
||||
type SessionEvent,
|
||||
type SessionHeader,
|
||||
type TodoItem,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
formatSessionReferenceMention,
|
||||
parseSessionReferenceText,
|
||||
type SessionReferenceService,
|
||||
} from '@deepseek-ai/dsh-session-reference'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
// Side-effect type import: declaration-merges the optional `sessionPersistence`
|
||||
// service onto `Context` so `ctx.get('sessionPersistence')` is typed.
|
||||
@@ -265,6 +282,11 @@ function displayText(text: string): string {
|
||||
`\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
||||
}
|
||||
|
||||
/** Escape external controls for terminal fields that must remain on one line. */
|
||||
function displayInlineText(text: string): string {
|
||||
return displayText(text).replaceAll('\n', '\\x0a')
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-agnostic palette built from the standard 16-color ANSI set plus SGR
|
||||
* attributes, which every terminal remaps to its active color scheme. Body
|
||||
@@ -1272,6 +1294,64 @@ interface PendingQuestion {
|
||||
overlay: OverlayHandle | undefined
|
||||
}
|
||||
|
||||
/** Add session candidates to pi-tui's existing command/file provider. */
|
||||
class SessionAutocompleteProvider implements AutocompleteProvider {
|
||||
constructor(
|
||||
private readonly base: CombinedAutocompleteProvider,
|
||||
private readonly sessions: SessionReferenceService,
|
||||
private readonly agent: Agent,
|
||||
) {}
|
||||
|
||||
async getSuggestions(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
options: { signal: AbortSignal; force?: boolean },
|
||||
): Promise<AutocompleteSuggestions | null> {
|
||||
const basePromise = this.base.getSuggestions(lines, cursorLine, cursorCol, options)
|
||||
const currentLine = lines[cursorLine]
|
||||
/* v8 ignore next -- Editor always supplies its current state line. */
|
||||
if (currentLine === undefined) return basePromise
|
||||
const token = /(?:^|\s)(@[^\s]*)$/u.exec(currentLine.slice(0, cursorCol))?.[1]
|
||||
if (token === undefined) return basePromise
|
||||
let candidates
|
||||
try {
|
||||
candidates = await this.sessions.listCandidates(this.agent, token.slice(1), undefined, options.signal)
|
||||
} catch {
|
||||
return basePromise
|
||||
}
|
||||
const base = await basePromise
|
||||
if (options.signal.aborted) return base
|
||||
const items: AutocompleteItem[] = candidates.map((candidate) => {
|
||||
const mentionLabel = displayInlineText(candidate.label)
|
||||
const sessionId = displayInlineText(candidate.sessionId)
|
||||
const location = candidate.cwd === undefined ? '(no cwd)' : displayInlineText(candidate.cwd)
|
||||
const description = `${candidate.label === candidate.sessionId ? '' : `${sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}`
|
||||
return {
|
||||
value: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: mentionLabel }),
|
||||
label: `Session · ${mentionLabel}`,
|
||||
description,
|
||||
}
|
||||
})
|
||||
if (items.length === 0) return base
|
||||
return { items: [...items, ...(base?.items ?? [])], prefix: token }
|
||||
}
|
||||
|
||||
applyCompletion(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
item: AutocompleteItem,
|
||||
prefix: string,
|
||||
): { lines: string[]; cursorLine: number; cursorCol: number } {
|
||||
return this.base.applyCompletion(lines, cursorLine, cursorCol, item, prefix)
|
||||
}
|
||||
|
||||
shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {
|
||||
return this.base.shouldTriggerFileCompletion(lines, cursorLine, cursorCol)
|
||||
}
|
||||
}
|
||||
|
||||
/** Lifecycle handle for a mounted interactive terminal channel. */
|
||||
export interface TuiController {
|
||||
/** Stop rendering, restore the terminal, and reject pending questions. */
|
||||
@@ -1341,6 +1421,30 @@ function activeSurfaceSeqs(session: Session): Set<number> {
|
||||
return new Set(session.surface.nodes)
|
||||
}
|
||||
|
||||
function sessionReferenceCard(meta: unknown): string[] | undefined {
|
||||
if (typeof meta !== 'object' || meta === null) return undefined
|
||||
const record = meta as Record<string, unknown>
|
||||
if (record['kind'] !== 'session-reference' || !Array.isArray(record['references'])) return undefined
|
||||
const references = record['references'] as unknown[]
|
||||
const labels: string[] = []
|
||||
for (const reference of references) {
|
||||
if (typeof reference !== 'object' || reference === null) return undefined
|
||||
const entry = reference as Record<string, unknown>
|
||||
const sessionId = entry['sessionId']
|
||||
const label = entry['label']
|
||||
if (typeof sessionId !== 'string' || typeof label !== 'string') return undefined
|
||||
labels.push(label === sessionId ? sessionId : `${label} (${sessionId})`)
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
function promptReferenceCards(event: Extract<SessionEvent, { type: 'user/message' | 'steering/message' }>): string[][] {
|
||||
return event.data.envelope?.prefixContexts.flatMap((context) => {
|
||||
const card = sessionReferenceCard(context.meta)
|
||||
return card === undefined ? [] : [card]
|
||||
}) ?? []
|
||||
}
|
||||
|
||||
function activeToolCallIds(session: Session, active: ReadonlySet<number>): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const event of session.events) {
|
||||
@@ -1406,6 +1510,7 @@ export function createTuiChat(
|
||||
const liveErrors = new Set<string>()
|
||||
const questionQueue: PendingQuestion[] = []
|
||||
const commandControllers = new Set<AbortController>()
|
||||
const referenceControllers = new Set<AbortController>()
|
||||
let activeQuestion: PendingQuestion | undefined
|
||||
let modelOverlay: OverlayHandle | undefined
|
||||
const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined }
|
||||
@@ -1691,23 +1796,37 @@ export function createTuiChat(
|
||||
const renderEvent = (event: SessionEvent, options: { addHistory: boolean; renderChunks: boolean }): void => {
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
const text = displayText(contentText(event.data.content).trim())
|
||||
const text = displayText(contentText(displayPromptContent(event.data)).trim())
|
||||
if (text) {
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new UserMessageComponent(text, palette, mdTheme))
|
||||
if (options.addHistory) editor.addToHistory(text)
|
||||
}
|
||||
for (const references of promptReferenceCards(event)) {
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'steering/message': {
|
||||
const text = displayText(contentText(event.data.content).trim())
|
||||
const text = displayText(contentText(displayPromptContent(event.data)).trim())
|
||||
if (text) {
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new UserMessageComponent(text, palette, mdTheme, 'Steering'))
|
||||
}
|
||||
for (const references of promptReferenceCards(event)) {
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'context/message': {
|
||||
const references = sessionReferenceCard(event.data.meta)
|
||||
if (references !== undefined) {
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0))
|
||||
break
|
||||
}
|
||||
const text = displayText(contentText(event.data.content).trim())
|
||||
if (text) {
|
||||
const source = event.data.source.kind === 'plugin' ? event.data.source.plugin : event.data.source.kind
|
||||
@@ -1939,6 +2058,8 @@ export function createTuiChat(
|
||||
modelOverlay = undefined
|
||||
for (const controller of commandControllers) controller.abort(new Error('TUI disposed'))
|
||||
commandControllers.clear()
|
||||
for (const controller of referenceControllers) controller.abort(new Error('TUI disposed'))
|
||||
referenceControllers.clear()
|
||||
if (activeQuestion !== undefined) {
|
||||
const pending = activeQuestion
|
||||
activeQuestion = undefined
|
||||
@@ -2084,7 +2205,7 @@ export function createTuiChat(
|
||||
// still invoke one by typing its exact name.
|
||||
let skillCommands: SlashCommand[] = []
|
||||
const refreshCommandAutocomplete = (): void => {
|
||||
editor.setAutocompleteProvider(new CombinedAutocompleteProvider(
|
||||
const base = new CombinedAutocompleteProvider(
|
||||
[
|
||||
...ctx.commands.list(agent).map(command => ({
|
||||
name: command.name,
|
||||
@@ -2093,7 +2214,11 @@ export function createTuiChat(
|
||||
...skillCommands,
|
||||
],
|
||||
agent.session.header.cwd ?? process.cwd(),
|
||||
))
|
||||
)
|
||||
const sessionReferences = ctx.get('sessionReferences')
|
||||
editor.setAutocompleteProvider(sessionReferences === undefined
|
||||
? base
|
||||
: new SessionAutocompleteProvider(base, sessionReferences, agent))
|
||||
}
|
||||
const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete)
|
||||
refreshCommandAutocomplete()
|
||||
@@ -2198,17 +2323,21 @@ export function createTuiChat(
|
||||
).finally(() => { commandControllers.delete(controller) })
|
||||
}
|
||||
|
||||
/** Deliver a user turn to the agent: steer while running, send while idle, or report a disposed agent. */
|
||||
const deliver = (payload: string): void => {
|
||||
const dispatchMessage = (content: ContentBlock[], contexts: HookContext[]): void => {
|
||||
if (agent.status === 'disposed') {
|
||||
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
|
||||
} else if (agent.status === 'running') {
|
||||
agent.steer([{ type: 'text', text: payload }])
|
||||
agent.steer(content, { contexts })
|
||||
} else {
|
||||
agent.send([{ type: 'text', text: payload }])
|
||||
agent.send(content, { contexts })
|
||||
}
|
||||
}
|
||||
|
||||
/** Deliver a user turn to the agent: steer while running, send while idle, or report a disposed agent. */
|
||||
const deliver = (payload: string): void => {
|
||||
dispatchMessage([{ type: 'text', text: payload }], [])
|
||||
}
|
||||
|
||||
/** Load a manually invoked skill and deliver its rendered body as a user turn, reporting lookup outcomes as notices. */
|
||||
const invokeSkill = (name: string, instructions: string): void => {
|
||||
if (skills === undefined) {
|
||||
@@ -2317,21 +2446,68 @@ export function createTuiChat(
|
||||
editor.onSubmit = (value: string) => {
|
||||
const text = value.trim()
|
||||
if (text === '') return
|
||||
editor.addToHistory(text)
|
||||
editor.setText('')
|
||||
const restoreSubmittedInput = (): void => {
|
||||
if (editor.getText() === '') editor.setText(value)
|
||||
}
|
||||
// `/skill:<name>` carries a colon, which the command registry's name
|
||||
// grammar rejects, so it is intercepted before generic command routing.
|
||||
if (text.startsWith(SKILL_COMMAND_PREFIX)) {
|
||||
editor.addToHistory(text)
|
||||
editor.setText('')
|
||||
const { name, instructions } = parseSkillCommand(text)
|
||||
if (name === '') appendNotice('Usage: /skill:<name> [instructions]', 'warning')
|
||||
else invokeSkill(name, instructions)
|
||||
return
|
||||
}
|
||||
if (value.startsWith('/')) {
|
||||
editor.addToHistory(text)
|
||||
editor.setText('')
|
||||
runCommand(value)
|
||||
return
|
||||
}
|
||||
deliver(text)
|
||||
let parsed: ReturnType<typeof parseSessionReferenceText>
|
||||
try {
|
||||
parsed = parseSessionReferenceText(text)
|
||||
} catch (error: unknown) {
|
||||
restoreSubmittedInput()
|
||||
appendNotice(`Invalid session reference: ${errorChain(error)}`, 'error')
|
||||
return
|
||||
}
|
||||
if (parsed.references.length === 0) {
|
||||
editor.addToHistory(text)
|
||||
editor.setText('')
|
||||
dispatchMessage([{ type: 'text', text: parsed.text }], [])
|
||||
return
|
||||
}
|
||||
const sessionReferences = ctx.get('sessionReferences')
|
||||
if (sessionReferences === undefined) {
|
||||
restoreSubmittedInput()
|
||||
appendNotice('Session reference capability unavailable.', 'error')
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
referenceControllers.add(controller)
|
||||
editor.disableSubmit = true
|
||||
void sessionReferences.prepare(
|
||||
agent,
|
||||
[{ type: 'text', text: parsed.text }],
|
||||
parsed.references,
|
||||
controller.signal,
|
||||
).then((prepared) => {
|
||||
if (disposed) return
|
||||
editor.addToHistory(text)
|
||||
if (editor.getText() === value) editor.setText('')
|
||||
dispatchMessage(prepared.content, prepared.contexts)
|
||||
}, (error: unknown) => {
|
||||
if (!disposed && !controller.signal.aborted) {
|
||||
restoreSubmittedInput()
|
||||
appendNotice(`Session reference failed: ${errorChain(error)}`, 'error')
|
||||
}
|
||||
}).finally(() => {
|
||||
referenceControllers.delete(controller)
|
||||
editor.disableSubmit = false
|
||||
requestRender()
|
||||
})
|
||||
}
|
||||
|
||||
const removeInputListener = ui.addInputListener((data) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import AgentRegistry, {
|
||||
type AgentCancelCause,
|
||||
type AgentOptions,
|
||||
type AgentStatus,
|
||||
type SendOptions,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
@@ -17,7 +18,9 @@ import { createTuiChat, type Config, type TuiRuntime } from '../src/index.ts'
|
||||
interface FakeAgent extends Agent {
|
||||
status: AgentStatus
|
||||
sent: ContentBlock[][]
|
||||
sentOptions: (SendOptions | undefined)[]
|
||||
steered: ContentBlock[][]
|
||||
steeredOptions: (SendOptions | undefined)[]
|
||||
cancelled: AgentCancelCause[]
|
||||
}
|
||||
|
||||
@@ -132,6 +135,8 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
options.beforeMount?.(session)
|
||||
const sent: ContentBlock[][] = []
|
||||
const steered: ContentBlock[][] = []
|
||||
const sentOptions: (SendOptions | undefined)[] = []
|
||||
const steeredOptions: (SendOptions | undefined)[] = []
|
||||
const cancelled: AgentCancelCause[] = []
|
||||
const agent: FakeAgent = {
|
||||
id: sessionId,
|
||||
@@ -140,13 +145,17 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
status: options.status ?? 'idle',
|
||||
ctx,
|
||||
sent,
|
||||
sentOptions,
|
||||
steered,
|
||||
steeredOptions,
|
||||
cancelled,
|
||||
send(content) {
|
||||
send(content, options) {
|
||||
sent.push(content)
|
||||
sentOptions.push(options)
|
||||
},
|
||||
steer(content) {
|
||||
steer(content, options) {
|
||||
steered.push(content)
|
||||
steeredOptions.push(options)
|
||||
},
|
||||
inject() {},
|
||||
cancel(cause = { kind: 'user' }) {
|
||||
|
||||
144
packages/ui/tui/tests/session-reference.snapshot.ts
Normal file
144
packages/ui/tui/tests/session-reference.snapshot.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { LlmAdapter, type GenerateOptions, type StreamChunk } 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'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import SessionQueryService from '@deepseek-ai/dsh-session-query'
|
||||
import SessionReferenceService, { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
|
||||
import { createTuiChat } from '../src/index.ts'
|
||||
import { HeadlessTerminal } from './headless-terminal.ts'
|
||||
|
||||
const EXPECTED = join(dirname(fileURLToPath(import.meta.url)), 'snapshots/session-reference.expected.txt')
|
||||
const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh'
|
||||
|
||||
class SnapshotAdapter extends LlmAdapter {
|
||||
readonly requests: GenerateOptions[] = []
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
const prompt = options.messages.at(-1)
|
||||
if (prompt?.role !== 'user' || prompt.content.length !== 3
|
||||
|| prompt.content[1]?.type !== 'text' || prompt.content[1].text !== '\n\n## My request:\n') {
|
||||
throw new Error('session reference did not reach the model as one prefixed user message')
|
||||
}
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 0, text: 'Combined reference request accepted.' }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Combined reference request accepted.' } }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
|
||||
function nextIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject !== agent || status !== 'idle') return
|
||||
dispose()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('TUI session-reference snapshot', () => {
|
||||
it('snapshots compacted current-surface context on send and displays only its reference card', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
|
||||
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', {
|
||||
content: [{ type: 'text', text: 'SHADOWED OLD USER' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const oldAssistant = source.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
content: [{ type: 'text', text: 'SHADOWED OLD ASSISTANT' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
source.append('user/message', {
|
||||
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', {
|
||||
content: [{ type: 'text', text: 'Recent retained question.' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
const target = ctx.agentLoop.create(
|
||||
SessionId('target-session'),
|
||||
{ provider: 'mock', model: 'mock' },
|
||||
{ cwd: '/workspace/project' },
|
||||
)
|
||||
const terminal = new HeadlessTerminal(96, 24)
|
||||
const controller = createTuiChat(ctx, {
|
||||
sessionId: target.id,
|
||||
welcome: 'Session reference snapshot.',
|
||||
color: true,
|
||||
title: 'DSH session reference',
|
||||
}, { terminal, exit: () => {} })
|
||||
await terminal.waitForFrame(0)
|
||||
|
||||
const mention = formatSessionReferenceMention({ sessionId: source.id, label: 'Source session' })
|
||||
const idle = nextIdle(ctx, target)
|
||||
const frame = terminal.frames
|
||||
terminal.send(`Use ${mention}`)
|
||||
terminal.send('\r')
|
||||
await idle
|
||||
await terminal.waitForFrame(frame)
|
||||
|
||||
const request = JSON.stringify(adapter.requests[0]?.messages)
|
||||
expect(request).toContain('untrusted, read-only snapshot')
|
||||
expect(request).toContain('Retained checkpoint.')
|
||||
expect(request).toContain('Recent retained question.')
|
||||
expect(request).not.toContain('SHADOWED OLD USER')
|
||||
expect(request).not.toContain('SHADOWED OLD ASSISTANT')
|
||||
const user = target.session.events.find(event => event.type === 'user/message')
|
||||
expect(user?.type === 'user/message' && user.data.envelope).toMatchObject({
|
||||
displayContent: [{ type: 'text', text: 'Use @Source session' }],
|
||||
prefixContexts: [{
|
||||
source: { kind: 'plugin', plugin: 'session-reference' },
|
||||
meta: {
|
||||
kind: 'session-reference',
|
||||
references: [{ sessionId: 'source-session', compacted: true }],
|
||||
},
|
||||
}],
|
||||
})
|
||||
expect(user?.type === 'user/message' && user.data.content[1]).toEqual({
|
||||
type: 'text',
|
||||
text: '\n\n## My request:\n',
|
||||
})
|
||||
expect(target.session.events.some(event => event.type === 'context/message')).toBe(false)
|
||||
|
||||
const snapshot = await terminal.snapshot({ includeScrollback: true })
|
||||
if (REFRESHING) {
|
||||
await mkdir(dirname(EXPECTED), { recursive: true })
|
||||
await writeFile(EXPECTED, snapshot)
|
||||
}
|
||||
await expect(snapshot).toMatchFileSnapshot(EXPECTED)
|
||||
|
||||
await controller.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
await terminal.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
terminal 96x24 buffer=normal length=24 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH session reference"
|
||||
cursor hidden column=1 viewportRow=14 bufferRow=14
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 10-16 bold
|
||||
1| " Session reference snapshot."
|
||||
style 1-27 fg=bright-black
|
||||
2| " mock • target-session"
|
||||
style 1-23 dim
|
||||
3| <blank>
|
||||
4| "▌ "
|
||||
style 0-0 fg=bright-blue
|
||||
5| "▌ You "
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-4 fg=bright-blue bold
|
||||
6| "▌ Use @Source session "
|
||||
style 0-0 fg=bright-blue
|
||||
7| "▌ "
|
||||
style 0-0 fg=bright-blue
|
||||
8| <blank>
|
||||
9| " Referenced sessions · Source session (source-session) "
|
||||
style 1-53 dim
|
||||
10| <blank>
|
||||
11| " Assistant "
|
||||
style 1-9 fg=bright-magenta bold
|
||||
12| " Combined reference request accepted. "
|
||||
13| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
14| " "
|
||||
style 1-1 inverse
|
||||
15| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
16| "mock /workspace/project ↑0 ↓0 tools:collapsed"
|
||||
style 0-30 dim
|
||||
style 81-95 dim
|
||||
17-23| <blank>
|
||||
@@ -51,6 +51,10 @@ const CHECKPOINTS = [
|
||||
'status-diagnostics-narrow',
|
||||
] as const
|
||||
|
||||
// Real-loop scenarios own their assertions in separate snapshot suites but
|
||||
// share this directory, whose inventory remains exact.
|
||||
const STANDALONE_CHECKPOINTS = ['session-reference'] as const
|
||||
|
||||
type Checkpoint = typeof CHECKPOINTS[number]
|
||||
type SnapshotHarness = TuiHarness<HeadlessTerminal, (code: number) => void>
|
||||
|
||||
@@ -685,5 +689,5 @@ afterAll(async () => {
|
||||
const files = (await readdir(SNAPSHOTS_DIR))
|
||||
.filter(file => file.endsWith('.expected.txt'))
|
||||
.sort()
|
||||
expect(files).toEqual(CHECKPOINTS.map(name => `${name}.expected.txt`).sort())
|
||||
expect(files).toEqual([...CHECKPOINTS, ...STANDALONE_CHECKPOINTS].map(name => `${name}.expected.txt`).sort())
|
||||
})
|
||||
|
||||
@@ -2,15 +2,17 @@ import { homedir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Terminal } from '@earendil-works/pi-tui'
|
||||
import { CombinedAutocompleteProvider, type Terminal } from '@earendil-works/pi-tui'
|
||||
import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { type LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
|
||||
import SessionStore, { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId, type JsonValue, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import SessionQueryService from '@deepseek-ai/dsh-session-query'
|
||||
import SessionReferenceService, { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import {
|
||||
createTuiChat,
|
||||
@@ -555,7 +557,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).not.toContain('queued')
|
||||
|
||||
const queueSteering = (text: string): void => {
|
||||
result.ctx.emit('agent/queued', result.agent, [{ type: 'text', text }], { source: { kind: 'user' }, steering: true })
|
||||
result.ctx.emit('agent/queued', result.agent, [{ type: 'text', text }], { source: { kind: 'user' }, contexts: [], steering: true })
|
||||
}
|
||||
const drainSteering = (text: string): void => {
|
||||
result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
@@ -564,7 +566,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
// 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/queued', other, [{ type: 'text', text: 'elsewhere' }], { source: { kind: 'user' }, steering: true })
|
||||
result.ctx.emit('agent/queued', other, [{ type: 'text', text: 'elsewhere' }], { source: { kind: 'user' }, contexts: [], steering: true })
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('queued')
|
||||
|
||||
@@ -577,7 +579,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
|
||||
// A non-steering queue (an idle-style send) leaves the badge untouched.
|
||||
result.terminal.output = ''
|
||||
result.ctx.emit('agent/queued', result.agent, [{ type: 'text', text: 'sent' }], { source: { kind: 'user' }, steering: false })
|
||||
result.ctx.emit('agent/queued', result.agent, [{ type: 'text', text: 'sent' }], { source: { kind: 'user' }, contexts: [], steering: false })
|
||||
drainSteering('first')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('1 queued')
|
||||
@@ -630,7 +632,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
const idle = await setup()
|
||||
// A steering queue arriving while idle has no status line to badge, so the
|
||||
// refresh is a no-op beyond requesting a render.
|
||||
idle.ctx.emit('agent/queued', idle.agent, [{ type: 'text', text: 'early' }], { source: { kind: 'user' }, steering: true })
|
||||
idle.ctx.emit('agent/queued', idle.agent, [{ type: 'text', text: 'early' }], { source: { kind: 'user' }, contexts: [], steering: true })
|
||||
idle.session.append('tool/call', { turn: 1, step: 0, callId: 'pre' as never, name: 'bash', arguments: '{}' })
|
||||
await tick()
|
||||
expect(idle.terminal.output).not.toContain('Executing tools')
|
||||
@@ -1010,6 +1012,349 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
await dispose(disposedAgent)
|
||||
})
|
||||
|
||||
it('combines session autocomplete with files and prepares send/steer references asynchronously', async () => {
|
||||
let sourceId = SessionId('uninitialized')
|
||||
const result = await setup({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
await ctx.plugin(SessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
const source = ctx.sessions.create(SessionId('source-session'), { meta: { cwd: process.cwd(), createdAt: 1 } })
|
||||
sourceId = source.id
|
||||
appendUser(source, 'source background')
|
||||
source.append('session/title', {
|
||||
title: 'Source chat',
|
||||
messageSeqs: [0],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
ctx.sessions.create(SessionId('no-cwd'), { meta: { createdAt: 2 } })
|
||||
},
|
||||
})
|
||||
|
||||
result.terminal.send('@no-cwd')
|
||||
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · no-cwd') })
|
||||
expect(result.terminal.output).toContain('(no cwd)')
|
||||
result.terminal.send('\x03')
|
||||
|
||||
result.terminal.send('@source-session')
|
||||
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · Source chat') })
|
||||
expect(result.terminal.output).toContain('source-session')
|
||||
result.terminal.send('\t')
|
||||
await tick()
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
|
||||
expect(result.agent.sent).toEqual([[{ type: 'text', text: '@Source chat' }]])
|
||||
expect(result.agent.sentOptions[0]?.contexts).toHaveLength(1)
|
||||
|
||||
const mention = formatSessionReferenceMention({ sessionId: sourceId, label: 'Source chat' })
|
||||
expect(result.agent.sentOptions[0]?.contexts).toMatchObject([{
|
||||
source: { kind: 'plugin', plugin: 'session-reference' },
|
||||
meta: { kind: 'session-reference', references: [{ sessionId: 'source-session' }] },
|
||||
}])
|
||||
|
||||
result.agent.status = 'running'
|
||||
result.terminal.send(`steer ${mention}`)
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(result.agent.steered).toHaveLength(1) })
|
||||
expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer @Source chat' }]])
|
||||
expect(result.agent.steeredOptions[0]?.contexts).toHaveLength(1)
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('escapes session autocomplete metadata while preserving the referenced session id', async () => {
|
||||
const unsafeId = SessionId('evil\x1b\x07\u009b\ns')
|
||||
const unsafeCwd = '/x/\x1b\x07\u009b\nf'
|
||||
const result = await setup({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
await ctx.plugin(SessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
const source = ctx.sessions.create(unsafeId, { meta: { cwd: unsafeCwd, createdAt: 1 } })
|
||||
appendUser(source, 'safe background')
|
||||
},
|
||||
})
|
||||
|
||||
result.terminal.send('@evil')
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('Session · evil\\x1b\\x07\\x9b\\x0a')
|
||||
})
|
||||
expect(result.terminal.output).toContain('/x/\\x1b\\x07\\x9b\\x0af')
|
||||
expect(result.terminal.output).not.toContain('evil\x1b\x07')
|
||||
expect(result.terminal.output).not.toContain('/x/\x1b\x07')
|
||||
|
||||
result.terminal.send('\t')
|
||||
await tick()
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
|
||||
expect(result.agent.sent).toEqual([[
|
||||
{ type: 'text', text: '@evil\\x1b\\x07\\x9b\\x0as' },
|
||||
]])
|
||||
expect(result.agent.sentOptions[0]?.contexts).toMatchObject([{
|
||||
meta: { references: [{ sessionId: unsafeId }] },
|
||||
}])
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('falls back cleanly for non-session, empty, failed, and superseded autocomplete requests', async () => {
|
||||
const result = await setup({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
await ctx.plugin(SessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
},
|
||||
})
|
||||
const originalListCandidates = result.ctx.sessionReferences.listCandidates.bind(result.ctx.sessionReferences)
|
||||
const listCandidates = vi.spyOn(result.ctx.sessionReferences, 'listCandidates')
|
||||
|
||||
result.terminal.send('plain')
|
||||
result.terminal.send('\t')
|
||||
await tick()
|
||||
result.terminal.send('\x03')
|
||||
|
||||
result.terminal.send('/he')
|
||||
result.terminal.send('\t')
|
||||
await tick()
|
||||
result.terminal.send('\x03')
|
||||
|
||||
listCandidates.mockRejectedValueOnce(new Error('candidate lookup failed'))
|
||||
result.terminal.send('@failed')
|
||||
await vi.waitFor(() => { expect(listCandidates).toHaveBeenCalled() })
|
||||
result.terminal.send('\x03')
|
||||
|
||||
result.terminal.send('@empty')
|
||||
await tick()
|
||||
result.terminal.send('\x03')
|
||||
|
||||
let releaseBase: (() => void) | undefined
|
||||
const baseSuggestions = vi.spyOn(CombinedAutocompleteProvider.prototype, 'getSuggestions')
|
||||
.mockImplementationOnce(async () => {
|
||||
await new Promise<void>((resolve) => { releaseBase = resolve })
|
||||
return null
|
||||
})
|
||||
listCandidates.mockResolvedValueOnce([])
|
||||
result.terminal.send('@base-slow')
|
||||
await vi.waitFor(() => { expect(releaseBase).toBeTypeOf('function') })
|
||||
const baseWaitSignal = listCandidates.mock.calls.at(-1)?.[3]
|
||||
result.terminal.send('x')
|
||||
await vi.waitFor(() => { expect(baseWaitSignal?.aborted).toBe(true) })
|
||||
releaseBase?.()
|
||||
await tick()
|
||||
baseSuggestions.mockRestore()
|
||||
|
||||
let delayedSignal: AbortSignal | undefined
|
||||
let delayed = true
|
||||
listCandidates.mockImplementation(async (...args) => {
|
||||
if (!delayed) return originalListCandidates(...args)
|
||||
delayed = false
|
||||
delayedSignal = args[3]
|
||||
if (delayedSignal === undefined) throw new Error('expected autocomplete cancellation signal')
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
delayedSignal?.addEventListener('abort', () => { reject(new Error('superseded')) }, { once: true })
|
||||
})
|
||||
return []
|
||||
})
|
||||
result.terminal.send('@slow')
|
||||
await vi.waitFor(() => { expect(delayedSignal).toBeDefined() })
|
||||
result.terminal.send('x')
|
||||
await vi.waitFor(() => { expect(delayedSignal?.aborted).toBe(true) })
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps failed mention input and renders durable reference contexts as compact cards', async () => {
|
||||
const result = await setup({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
await ctx.plugin(SessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
},
|
||||
})
|
||||
const missing = formatSessionReferenceMention({ sessionId: SessionId('missing'), label: 'Missing chat' })
|
||||
result.terminal.send(`keep ${missing}`)
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.agent.sent).toHaveLength(0)
|
||||
expect(result.terminal.output).toContain('Session reference failed')
|
||||
expect(result.terminal.output).toContain('keep @[')
|
||||
|
||||
result.session.append('user/message', {
|
||||
content: [
|
||||
{ type: 'text', text: 'hidden baked snapshot payload' },
|
||||
{ type: 'text', text: '\n\n## My request:\n' },
|
||||
{ type: 'text', text: 'visible referenced question' },
|
||||
],
|
||||
source: { kind: 'user' },
|
||||
envelope: {
|
||||
displayContent: [{ type: 'text', text: 'visible referenced question' }],
|
||||
prefixContexts: [{
|
||||
source: { kind: 'plugin', plugin: 'session-reference' },
|
||||
meta: {
|
||||
kind: 'session-reference',
|
||||
references: [{ sessionId: 'prefixed', label: 'Prefixed source' }],
|
||||
},
|
||||
}],
|
||||
},
|
||||
}, { surfaceOp: 'append' })
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('visible referenced question')
|
||||
expect(result.terminal.output).toContain('Referenced sessions · Prefixed source (prefixed)')
|
||||
expect(result.terminal.output).not.toContain('hidden baked snapshot payload')
|
||||
|
||||
result.session.append('steering/message', {
|
||||
turn: 1,
|
||||
content: [
|
||||
{ type: 'text', text: 'hidden non-reference prefix' },
|
||||
{ type: 'text', text: '\n\n## My request:\n' },
|
||||
{ type: 'text', text: 'visible steering prompt' },
|
||||
],
|
||||
source: { kind: 'user' },
|
||||
envelope: {
|
||||
displayContent: [{ type: 'text', text: 'visible steering prompt' }],
|
||||
prefixContexts: [
|
||||
{ source: { kind: 'plugin', plugin: 'other' }, meta: { kind: 'other' } },
|
||||
{
|
||||
source: { kind: 'plugin', plugin: 'session-reference' },
|
||||
meta: {
|
||||
kind: 'session-reference',
|
||||
references: [{ sessionId: 'steering-source', label: 'Steering source' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}, { surfaceOp: 'append' })
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('visible steering prompt')
|
||||
expect(result.terminal.output).toContain('Referenced sessions · Steering source (steering-source)')
|
||||
expect(result.terminal.output).not.toContain('hidden non-reference prefix')
|
||||
|
||||
result.session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'secret full snapshot payload' }],
|
||||
source: { kind: 'plugin', plugin: 'session-reference' },
|
||||
meta: {
|
||||
kind: 'session-reference',
|
||||
version: 1,
|
||||
references: [{ sessionId: 'source', label: 'Source', capturedThroughSeq: 2 }],
|
||||
},
|
||||
}, { surfaceOp: 'append' })
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Referenced sessions · Source (source)')
|
||||
expect(result.terminal.output).not.toContain('secret full snapshot payload')
|
||||
|
||||
const invalidCards: [JsonValue, string][] = [
|
||||
[{ kind: 'other' }, 'invalid-kind'],
|
||||
[{ kind: 'session-reference', references: [null] }, 'invalid-entry'],
|
||||
[{ kind: 'session-reference', references: [{}] }, 'invalid-fields'],
|
||||
]
|
||||
for (const [meta, text] of invalidCards) {
|
||||
result.session.append('context/message', {
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'plugin', plugin: 'session-reference' },
|
||||
meta,
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
result.session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'same-label snapshot' }],
|
||||
source: { kind: 'plugin', plugin: 'session-reference' },
|
||||
meta: { kind: 'session-reference', references: [{ sessionId: 'same', label: 'same' }] },
|
||||
}, { surfaceOp: 'append' })
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Referenced sessions · same')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('reports malformed and unavailable references without enqueueing', async () => {
|
||||
const malformed = await setup()
|
||||
malformed.terminal.send('use dsh-session:IiJ')
|
||||
malformed.terminal.send('\r')
|
||||
await tick()
|
||||
expect(malformed.agent.sent).toHaveLength(0)
|
||||
expect(malformed.terminal.output).toContain('Invalid session reference')
|
||||
await dispose(malformed)
|
||||
|
||||
const unavailable = await setup()
|
||||
const mention = formatSessionReferenceMention({ sessionId: SessionId('source') })
|
||||
unavailable.terminal.send(`use ${mention}`)
|
||||
unavailable.terminal.send('\r')
|
||||
await tick()
|
||||
expect(unavailable.agent.sent).toHaveLength(0)
|
||||
expect(unavailable.terminal.output).toContain('Session reference capability unavailable')
|
||||
await dispose(unavailable)
|
||||
})
|
||||
|
||||
it('clears a retyped successful mention and aborts pending preparation on disposal', async () => {
|
||||
const result = await setup({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
await ctx.plugin(SessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
ctx.sessions.create(SessionId('source'))
|
||||
},
|
||||
})
|
||||
const mention = formatSessionReferenceMention({ sessionId: SessionId('source') })
|
||||
const value = `use ${mention}`
|
||||
let release: (() => void) | undefined
|
||||
const prepare = vi.spyOn(result.ctx.sessionReferences, 'prepare').mockImplementation(
|
||||
(_agent, content) => new Promise((resolve) => {
|
||||
release = () => { resolve({ content, contexts: [] }) }
|
||||
}),
|
||||
)
|
||||
result.terminal.send(value)
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(prepare).toHaveBeenCalledOnce() })
|
||||
result.terminal.send(value)
|
||||
release?.()
|
||||
await tick()
|
||||
expect(result.agent.sent).toEqual([[{ type: 'text', text: 'use @source' }]])
|
||||
|
||||
let rejectPreparation: (() => void) | undefined
|
||||
prepare.mockImplementation(() => new Promise((_resolve, reject) => {
|
||||
rejectPreparation = () => { reject(new Error('delayed failure')) }
|
||||
}))
|
||||
result.terminal.send(value)
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(rejectPreparation).toBeTypeOf('function') })
|
||||
result.terminal.send('new draft')
|
||||
rejectPreparation?.()
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('delayed failure')
|
||||
result.terminal.send('\x03')
|
||||
|
||||
let pendingSignal: AbortSignal | undefined
|
||||
prepare.mockImplementation((_agent, _content, _references, signal) => new Promise((_resolve, reject) => {
|
||||
pendingSignal = signal
|
||||
signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
|
||||
}))
|
||||
result.terminal.send(value)
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(pendingSignal).toBeDefined() })
|
||||
await result.controller.dispose()
|
||||
expect(pendingSignal?.aborted).toBe(true)
|
||||
await tick()
|
||||
await result.ctx.fiber.dispose()
|
||||
|
||||
const lateSuccess = await setup({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
await ctx.plugin(SessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
ctx.sessions.create(SessionId('source'))
|
||||
},
|
||||
})
|
||||
let resolveAfterDispose: (() => void) | undefined
|
||||
const latePrepare = vi.spyOn(lateSuccess.ctx.sessionReferences, 'prepare').mockImplementation(
|
||||
(_agent, content) => new Promise((resolve) => {
|
||||
resolveAfterDispose = () => { resolve({ content, contexts: [] }) }
|
||||
}),
|
||||
)
|
||||
lateSuccess.terminal.send(value)
|
||||
lateSuccess.terminal.send('\r')
|
||||
await vi.waitFor(() => { expect(latePrepare).toHaveBeenCalledOnce() })
|
||||
await lateSuccess.controller.dispose()
|
||||
resolveAfterDispose?.()
|
||||
await tick()
|
||||
expect(lateSuccess.agent.sent).toHaveLength(0)
|
||||
await lateSuccess.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('opens a keyboard selector and switches the session model without sending slash text to the agent', async () => {
|
||||
const initialContext = Promise.withResolvers<{ contextWindow: number }>()
|
||||
const result = await setup({
|
||||
@@ -1140,8 +1485,9 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
})
|
||||
failed.terminal.send('/model')
|
||||
failed.terminal.send('\r')
|
||||
await tick()
|
||||
expect(failed.terminal.output).toContain('Could not read the model catalog: catalog offline')
|
||||
await vi.waitFor(() => {
|
||||
expect(failed.terminal.output).toContain('Could not read the model catalog: catalog offline')
|
||||
})
|
||||
expect(failed.terminal.output).toContain('Could not resolve model context: capacity offline')
|
||||
await dispose(failed)
|
||||
})
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../context/session-reference"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user