feat(session-title): integrate queries ACP and TUI

This commit is contained in:
Tianyi Cui
2026-07-21 01:53:37 +08:00
parent 58dc5f94de
commit f71f96d292
131 changed files with 6142 additions and 5771 deletions

View File

@@ -50,6 +50,8 @@ import { SessionId } from '@deepseek-ai/dsh-session'
// Side-effect type import: resolves `ctx.get('permission')` to the service.
import type {} from '@deepseek-ai/dsh-permission'
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
// Side-effect type import: adds the log-only session/title event translated below.
import type {} from '@deepseek-ai/dsh-session-title'
import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools'
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
// Context (the bridge injects it and reads `list()` for load cwd validation).
@@ -1123,6 +1125,17 @@ export function streamSessionEventUpdate(
notify({ sessionId, update: { sessionUpdate: 'plan', ...todosToPlan(event.data.todos) } })
return
}
case 'session/title': {
notify({
sessionId,
update: {
sessionUpdate: 'session_info_update',
title: event.data.title,
updatedAt: new Date(event.time).toISOString(),
},
})
return
}
case 'turn/end': {
if (event.data.reason.kind !== 'error' || !('failure' in event.data.reason)) return
const text = `\n\n[Model attempt failed; any partial output above is discarded: ${event.data.reason.failure.message}]\n\n`

View File

@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-title'
import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
/** Concatenate the text of all agent_message_chunk updates. */
@@ -56,6 +57,31 @@ describe('acp bridge — session/load replay', () => {
expect(userText).toBe('remember this')
})
it('streams and replays the same persisted session_info_update for a title event', async () => {
live = await makeBridgeHarness({ storageDir, script: [] })
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const session = live.ctx.agents.get(SessionId(sessionId))!.session
const event = await live.ctx.sessions.appendOutOfBand(session, 'session/title', {
title: 'Durable ACP title',
messageSeqs: [1],
source: { kind: 'fallback' },
}, { kind: 'session-title' })
const expected = {
sessionUpdate: 'session_info_update' as const,
title: 'Durable ACP title',
updatedAt: new Date(event.time).toISOString(),
}
expect(live.updates).toContainEqual(expected)
await live.dispose()
live = undefined
loader = await makeBridgeHarness({ storageDir, script: [] })
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
expect(loader.updates).toContainEqual(expected)
})
it('replays a persisted tool call with the TOOL-OWNED presentation (title/rawInput/console output)', async () => {
// Persist a real bash call, then replay it through a fresh bridge. A throwaway presenter pairs
// call and result in log order so replay uses the shipping tool's same cards as live streaming.

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-title'
import type { SessionNotification } from '@agentclientprotocol/sdk'
import type { ToolDefinition, ToolRegistry as ToolRegistryType } from '@deepseek-ai/dsh-tools'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -50,6 +51,23 @@ function evt<T extends SessionEvent['type']>(type: T, data: Extract<SessionEvent
}
describe('streamSessionEventUpdate', () => {
it('maps a title event to session_info_update with the event timestamp', () => {
expect(updatesFor({
type: 'session/title',
seq: 3,
time: 1_725_000_000_000,
data: {
title: 'Log-backed titles',
messageSeqs: [1],
source: { kind: 'fallback' },
},
})).toEqual([{
sessionUpdate: 'session_info_update',
title: 'Log-backed titles',
updatedAt: new Date(1_725_000_000_000).toISOString(),
}])
})
it('maps assistant/chunk text-delta to agent_message_chunk', () => {
expect(updatesFor(evt('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } })))
.toEqual([{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }])

View File

@@ -39,6 +39,7 @@ import { errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import { SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type {
FileDiff,
TerminalCallView,
@@ -286,7 +287,7 @@ function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'
class HeaderComponent implements Component {
constructor(
private readonly agent: Agent,
private readonly welcome: string,
private readonly subtitle: () => string,
private readonly palette: Palette,
) {}
@@ -299,7 +300,7 @@ class HeaderComponent implements Component {
const detail = `${model}${displayText(this.agent.session.id)}`
const top = this.palette.accent(`${'─'.repeat(Math.max(0, width - 2))}`)
const bottom = this.palette.accent(`${'─'.repeat(Math.max(0, width - 2))}`)
const lines = [title, this.palette.muted(displayText(this.welcome)), this.palette.dim(detail)]
const lines = [title, this.palette.muted(displayText(this.subtitle())), this.palette.dim(detail)]
.flatMap(line => wrapTextWithAnsi(line, usable))
.map((line) => {
const clipped = truncateToWidth(line, usable, '')
@@ -858,7 +859,8 @@ export function createTuiChat(
let activeQuestion: PendingQuestion | undefined
const welcome = config.welcome ?? 'ready.'
const header = new HeaderComponent(agent, welcome, palette)
let sessionTitle = foldSessionTitle(agent.session.events)?.title
const header = new HeaderComponent(agent, () => sessionTitle ?? welcome, palette)
const footer = new FooterComponent(agent, palette, () => toolsExpanded, () => showReasoning, () => tokens)
ui.addChild(header)
ui.addChild(chat)
@@ -868,7 +870,12 @@ export function createTuiChat(
ui.addChild(editor)
ui.addChild(footer)
ui.setFocus(editor)
runtime.terminal.setTitle(displayText(resolved.title))
const updateTerminalTitle = (): void => {
runtime.terminal.setTitle(displayText(
sessionTitle === undefined ? resolved.title : `${sessionTitle}${resolved.title}`,
))
}
updateTerminalTitle()
const requestRender = (): void => {
footer.invalidate()
@@ -997,6 +1004,11 @@ export function createTuiChat(
case 'todo/write':
todo.update(event.data.todos)
break
case 'session/title':
sessionTitle = event.data.title
header.invalidate()
updateTerminalTitle()
break
case 'turn/end':
clearStreaming()
if (event.data.reason.kind === 'error') {

View File

@@ -5,6 +5,7 @@ import { Context } from 'cordis'
import type { Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
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 type {} from '@deepseek-ai/dsh-llm-retry'
@@ -146,6 +147,34 @@ describe('TUI config', () => {
})
describe('pi-tui chat lifecycle and transcript', () => {
it('uses the latest log-backed title for the header subtitle and terminal window', async () => {
const result = await setup({
beforeMount(session) {
session.append('session/title', {
title: 'Restored session title',
messageSeqs: [1],
source: { kind: 'fallback' },
})
},
})
expect(result.terminal.title).toBe('Restored session title — DeepSeek Harness')
expect(result.terminal.output).toContain('Restored session title')
expect(result.terminal.output).not.toContain('Coding agent ready.')
result.session.append('session/title', {
title: 'Live title \u001B]0;unsafe\u0007',
messageSeqs: [1, 5],
source: { kind: 'fallback' },
})
await tick()
expect(result.terminal.title).toContain('Live title \\x1b]0;unsafe\\x07 — DeepSeek Harness')
expect(result.terminal.title).not.toContain('\u001B')
expect(result.terminal.output).toContain('Live title \\x1b]0;unsafe\\x07')
await dispose(result)
})
it('renders its header, footer, replay, streaming answer, todos, and status', async () => {
const result = await setup({
beforeMount(session) {