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:
Tianyi Cui
2026-07-22 23:50:41 +08:00
159 changed files with 3918 additions and 362 deletions
+188 -12
View File
@@ -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) => {