Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	apps/cli/src/web.ts
#	apps/web/tests/smoke-fixture.e2e.ts
#	docs/architecture.i18n.yaml
#	packages/client/connection/src/client/fixture.ts
#	packages/client/runtime/src/client/sessions/conversation.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/package.json
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/service-orchestration.spec.ts
#	packages/host/runtime/src/api-proxy.ts
#	packages/host/runtime/src/boot.ts
#	packages/host/webserver/tests/webserver.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-07-24 16:29:03 +08:00
503 changed files with 19038 additions and 3597 deletions

View File

@@ -0,0 +1,197 @@
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
activeAtToken,
formatFileMention,
WorkspaceFileSearch,
} from '../src/file-autocomplete.ts'
const searches: WorkspaceFileSearch[] = []
const roots: string[] = []
async function workspace(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'dsh-file-autocomplete-'))
roots.push(root)
await mkdir(join(root, 'src'), { recursive: true })
await mkdir(join(root, 'docs'), { recursive: true })
await mkdir(join(root, '.hidden'), { recursive: true })
await mkdir(join(root, 'node_modules', 'ignored-package'), { recursive: true })
await writeFile(join(root, 'README.md'), 'readme')
await writeFile(join(root, 'src', 'tui.spec.ts'), 'test')
await writeFile(join(root, 'src', 'terminal-view.ts'), 'view')
await writeFile(join(root, 'docs', 'design notes.md'), 'design')
await writeFile(join(root, '.hidden', 'secret.txt'), 'hidden')
await writeFile(join(root, 'node_modules', 'ignored-package', 'index.js'), 'ignored')
try {
await symlink(join(root, 'src', 'tui.spec.ts'), join(root, 'linked-test.ts'))
} catch {
// Windows may deny symlink creation without Developer Mode; the product
// still skips every non-file/non-directory Dirent on platforms that expose one.
}
return root
}
function search(root: string, overrides: Partial<ConstructorParameters<typeof WorkspaceFileSearch>[1]> = {}): WorkspaceFileSearch {
const instance = new WorkspaceFileSearch(root, {
maxResults: overrides.maxResults ?? 20,
maxEntries: overrides.maxEntries ?? 10_000,
excludedDirectories: overrides.excludedDirectories ?? ['.git', 'node_modules'],
})
searches.push(instance)
return instance
}
afterEach(async () => {
for (const instance of searches.splice(0)) instance.dispose()
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
})
describe('TUI file autocomplete grammar', () => {
it('recognizes boundary and quoted mentions without treating emails as references', () => {
expect(activeAtToken('@src/tu', 7)).toEqual({ prefix: '@src/tu', query: 'src/tu', quoted: false })
expect(activeAtToken('read @"docs/design n', 20)).toEqual({
prefix: '@"docs/design n',
query: 'docs/design n',
quoted: true,
})
expect(activeAtToken('mail a@b.test', 13)).toBeUndefined()
expect(activeAtToken('done @src/x" next', 17)).toBeUndefined()
})
it('formats files, directories, quotes, and rejects unsafe editor values', () => {
expect(formatFileMention({ path: 'src/index.ts', kind: 'file' }, false)).toBe('@src/index.ts')
expect(formatFileMention({ path: 'src', kind: 'directory' }, false)).toBe('@src/')
expect(formatFileMention({ path: 'docs/design notes.md', kind: 'file' }, false))
.toBe('@"docs/design notes.md"')
expect(formatFileMention({ path: 'README.md', kind: 'file' }, true)).toBe('@"README.md"')
expect(formatFileMention({ path: 'bad\nname', kind: 'file' }, false)).toBeUndefined()
expect(formatFileMention({ path: 'bad "name".md', kind: 'file' }, false)).toBeUndefined()
expect(formatFileMention({ path: 'bad"name.md', kind: 'file' }, false)).toBeUndefined()
})
})
describe('WorkspaceFileSearch', () => {
it('lists live directory levels, descends, quotes spaces, and filters hidden/excluded entries', async () => {
const root = await workspace()
const files = search(root)
const signal = new AbortController().signal
expect(await files.list('', signal)).toEqual([
{ path: 'docs', kind: 'directory' },
{ path: 'src', kind: 'directory' },
{ path: 'README.md', kind: 'file' },
])
expect(await files.list('src/', signal)).toEqual([
{ path: 'src/terminal-view.ts', kind: 'file' },
{ path: 'src/tui.spec.ts', kind: 'file' },
])
expect(await files.list('src/ts', signal)).toEqual([
{ path: 'src/tui.spec.ts', kind: 'file' },
{ path: 'src/terminal-view.ts', kind: 'file' },
])
expect(await files.list('docs/design n', signal)).toEqual([
{ path: 'docs/design notes.md', kind: 'file' },
])
expect(await files.list('node_modules/', signal)).toEqual([])
expect(await files.list('.hidden/', signal)).toEqual([
{ path: '.hidden/secret.txt', kind: 'file' },
])
const absoluteSrc = `${join(root, 'src').replaceAll('\\', '/')}/`
expect(await files.list(`${absoluteSrc}tui`, signal)).toEqual([
{ path: `${absoluteSrc}tui.spec.ts`, kind: 'file' },
{ path: `${absoluteSrc}terminal-view.ts`, kind: 'file' },
])
expect(await files.list('~/.dsh-file-autocomplete-missing/', signal)).toEqual([])
expect(await files.list('../', signal)).toEqual([])
expect(await files.list('README.md/', signal)).toEqual([])
})
it('does not traverse directory symlinks during direct completion', async () => {
const root = await workspace()
const outside = await mkdtemp(join(tmpdir(), 'dsh-file-autocomplete-outside-'))
roots.push(outside)
await writeFile(join(outside, 'outside-secret.txt'), 'secret')
await symlink(
outside,
join(root, 'escape'),
process.platform === 'win32' ? 'junction' : 'dir',
)
const files = search(root)
const signal = new AbortController().signal
expect(await files.list('escape/', signal)).toEqual([])
expect(await files.list('escape/outside', signal)).toEqual([])
})
it('ranks basename and subsequence fuzzy matches across the bounded workspace index', async () => {
const root = await workspace()
await writeFile(join(root, 'src', 'tspc-helper.ts'), 'helper')
const files = search(root, { maxResults: 2 })
const signal = new AbortController().signal
expect(await files.list('tspc', signal)).toEqual([
{ path: 'src/tspc-helper.ts', kind: 'file' },
{ path: 'src/tui.spec.ts', kind: 'file' },
])
expect(await files.list('README.md', signal)).toEqual([
{ path: 'README.md', kind: 'file' },
])
expect(await files.list('terminal', signal)).toEqual([
{ path: 'src/terminal-view.ts', kind: 'file' },
])
expect(await files.list('secret', signal)).toEqual([])
expect(await files.list('.hidden', signal)).toEqual([
{ path: '.hidden', kind: 'directory' },
{ path: '.hidden/secret.txt', kind: 'file' },
])
})
it('invalidates cached traversal, enforces the entry cap, and settles disposal', async () => {
const root = await workspace()
const capped = search(root, { maxEntries: 2 })
const signal = new AbortController().signal
expect(await capped.list('README', signal)).toEqual([
{ path: 'README.md', kind: 'file' },
])
const files = search(root)
expect(await files.list('fresh-file', signal)).toEqual([])
await writeFile(join(root, 'fresh-file.ts'), 'fresh')
expect(await files.list('fresh-file', signal)).toEqual([])
files.invalidate()
expect(await files.list('fresh-file', signal)).toEqual([
{ path: 'fresh-file.ts', kind: 'file' },
])
files.dispose()
expect(await files.list('fresh-file', signal)).toEqual([])
files.dispose()
})
it('cancels individual callers, skips missing directories, and validates limits', async () => {
const root = await workspace()
expect(() => search(root, { maxResults: 0 })).toThrow('maxResults')
expect(() => search(root, { maxEntries: 1.5 })).toThrow('maxEntries')
expect(() => search(root, { excludedDirectories: ['nested/name'] })).toThrow('basenames')
const files = search(root)
expect(await files.list('missing/', new AbortController().signal)).toEqual([])
const preAborted = new AbortController()
preAborted.abort(new Error('pre-aborted'))
await expect(files.list('tui', preAborted.signal)).rejects.toThrow('pre-aborted')
files.invalidate()
const running = new AbortController()
const pending = files.list('tui', running.signal)
running.abort(new Error('superseded'))
await expect(pending).rejects.toThrow('superseded')
files.invalidate()
const nonErrorAbort = new AbortController()
const nonErrorPending = files.list('tui', nonErrorAbort.signal)
nonErrorAbort.abort('cancelled')
await expect(nonErrorPending).rejects.toThrow('file search aborted')
})
})

View File

@@ -0,0 +1,16 @@
import SessionQueryService from '@deepseek-ai/dsh-session-query'
/** Test-only backend-independent query service. */
export class TestSessionQueryService extends SessionQueryService {
override searchSessions(
..._args: Parameters<SessionQueryService['searchSessions']>
): ReturnType<SessionQueryService['searchSessions']> {
return Promise.resolve({ items: [] })
}
override searchEvents(
..._args: Parameters<SessionQueryService['searchEvents']>
): ReturnType<SessionQueryService['searchEvents']> {
return Promise.resolve({ items: [] })
}
}

View File

@@ -11,10 +11,10 @@ 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'
import { TestSessionQueryService } from './session-query.ts'
const EXPECTED = join(dirname(fileURLToPath(import.meta.url)), 'snapshots/session-reference.expected.txt')
const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh'
@@ -57,7 +57,7 @@ describe('TUI session-reference snapshot', () => {
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
const adapter = new SnapshotAdapter()

View File

@@ -0,0 +1,24 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=5 viewportRow=4 bufferRow=4
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| " deepseek-v4-flash • main-session"
style 1-34 dim
3| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
4| " @tsc "
style 5-5 inverse
5| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
6| " → File · terminal-special-case.t src/terminal-special-case.ts "
style 1-32 fg=bright-blue
7| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
8-35| <blank>

View File

@@ -1,4 +1,5 @@
import { mkdir, readdir, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it, vi } from 'vitest'
@@ -31,6 +32,7 @@ const CHECKPOINTS = [
'retry-cancelled',
'retry-exhausted',
'banner-gradient',
'file-autocomplete',
'code-mode-pending',
'dynamic-workflow-pending',
'cordis-tools-pending',
@@ -337,6 +339,24 @@ describe('TUI terminal-state snapshots', () => {
await disposeSnapshot(harness)
})
it('pins fuzzy file candidates and the active path-only mention', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-file-snapshot-'))
await mkdir(join(cwd, 'src'), { recursive: true })
await writeFile(join(cwd, 'src', 'terminal-special-case.ts'), 'export const marker = true\n')
await writeFile(join(cwd, 'src', 'terminal-state.ts'), 'export const state = true\n')
const harness = await setupSnapshot({ cwd, formatCwd: () => '/workspace/project' })
try {
harness.terminal.send('@tsc')
await vi.waitFor(async () => {
expect(await harness.terminal.snapshot()).toContain('File · terminal-special-case.t')
})
await checkpoint('file-autocomplete', harness.terminal)
} finally {
await disposeSnapshot(harness)
await rm(cwd, { recursive: true, force: true })
}
})
it('pins Code Mode run_code with its production presenter', async () => {
const harness = await setupSnapshot({ configureContext: configureAdvancedTools })
const call = {

View File

@@ -1,4 +1,5 @@
import { homedir } from 'node:os'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
@@ -11,11 +12,11 @@ import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek
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,
FILE_REFERENCE_PROMPT,
mountTui,
renderSkillInvocation,
resolveTuiConfig,
@@ -23,6 +24,7 @@ import {
type TuiOverlaySession,
type TuiRuntime,
} from '../src/index.ts'
import { WorkspaceFileSearch } from '../src/file-autocomplete.ts'
import {
appendAssistant,
appendUser,
@@ -30,6 +32,7 @@ import {
disposeTuiTestHarness,
type TuiHarnessOptions,
} from './harness.ts'
import { TestSessionQueryService } from './session-query.ts'
const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = {
schema: { type: 'null' },
@@ -153,6 +156,9 @@ describe('TUI config', () => {
questionDialogMaxHeight: 20,
modelDialogWidth: 72,
modelDialogMaxHeight: 20,
fileSearchMaxResults: 20,
fileSearchMaxEntries: 10_000,
fileSearchExcludedDirectories: ['.git', 'node_modules'],
showHardwareCursor: false,
color: true,
truecolor: false,
@@ -167,6 +173,9 @@ describe('TUI config', () => {
questionDialogMaxHeight: 14,
modelDialogWidth: 64,
modelDialogMaxHeight: 16,
fileSearchMaxResults: 7,
fileSearchMaxEntries: 123,
fileSearchExcludedDirectories: ['.git', 'generated'],
showHardwareCursor: true,
color: false,
truecolor: true,
@@ -180,6 +189,9 @@ describe('TUI config', () => {
questionDialogMaxHeight: 14,
modelDialogWidth: 64,
modelDialogMaxHeight: 16,
fileSearchMaxResults: 7,
fileSearchMaxEntries: 123,
fileSearchExcludedDirectories: ['.git', 'generated'],
showHardwareCursor: true,
color: false,
truecolor: true,
@@ -847,31 +859,43 @@ describe('pi-tui chat lifecycle and transcript', () => {
appendAssistant(session, [{ type: 'text', text: 'home' }], { inputTokens: 25_000, outputTokens: 10_000 })
},
})
expect(homeResult.terminal.output).toContain('~ ↑25k ↓10k')
await vi.waitFor(() => {
expect(homeResult.terminal.output).toContain('~ ↑25k ↓10k')
})
await dispose(homeResult)
const childResult = await setup({ cwd: join(home, 'projects', 'dsh-tui') })
expect(childResult.terminal.output).toContain(join('~', 'projects', 'dsh-tui'))
await vi.waitFor(() => {
expect(childResult.terminal.output).toContain(join('~', 'projects', 'dsh-tui'))
})
await dispose(childResult)
const unsetResult = await setup({ cwd: null })
expect(unsetResult.terminal.output).toContain('cwd unset')
await vi.waitFor(() => {
expect(unsetResult.terminal.output).toContain('cwd unset')
})
await dispose(unsetResult)
const homeParent = resolve(home, '..')
const parentResult = await setup({ cwd: homeParent })
expect(parentResult.terminal.output).toContain(homeParent)
await vi.waitFor(() => {
expect(parentResult.terminal.output).toContain(homeParent)
})
await dispose(parentResult)
const outsideResult = await setup({ cwd: '/opt' })
expect(outsideResult.terminal.output).toContain('/opt')
await vi.waitFor(() => {
expect(outsideResult.terminal.output).toContain('/opt')
})
await dispose(outsideResult)
const logicalResult = await setup({
cwd: '/w',
formatCwd: cwd => `logical:${cwd}\x1b`,
})
expect(logicalResult.terminal.output).toContain('logical:/w\\x1b')
await vi.waitFor(() => {
expect(logicalResult.terminal.output).toContain('logical:/w\\x1b')
})
await dispose(logicalResult)
})
@@ -1065,7 +1089,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
const source = ctx.sessions.create(SessionId('source-session'), { meta: { cwd: process.cwd(), createdAt: 1 } })
sourceId = source.id
@@ -1109,13 +1133,132 @@ describe('pi-tui chat lifecycle and transcript', () => {
await dispose(result)
})
it('fuzzy-completes files and directories while sending only the selected path text', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-file-completion-'))
await mkdir(join(cwd, 'src'), { recursive: true })
await mkdir(join(cwd, 'docs'), { recursive: true })
await writeFile(join(cwd, 'src', 'source-file.ts'), 'export const source = true\n')
await writeFile(join(cwd, 'docs', 'design notes.md'), '# Design\n')
await writeFile(join(cwd, 'unsafe\nfile.ts'), 'unsafe name\n')
const result = await setup({
cwd,
tools: {
read: {
name: 'read',
description: 'Read a file.',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: () => Promise.resolve([]),
},
},
})
try {
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
expect(assembly.sections).toContainEqual({
name: 'ui:tui-file-reference',
text: FILE_REFERENCE_PROMPT,
})
result.terminal.send('@sfts')
await vi.waitFor(() => {
expect(result.terminal.output).toContain('File · source-file.ts')
})
expect(result.terminal.output).toContain('src/source-file.ts')
result.terminal.send('\t')
await tick()
result.terminal.send('\r')
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
expect(result.agent.sent[0]).toEqual([{ type: 'text', text: '@src/source-file.ts' }])
expect(result.agent.sentOptions[0]?.contexts).toEqual([])
result.terminal.send('@do')
await vi.waitFor(() => {
expect(result.terminal.output).toContain('Folder · docs/')
})
result.terminal.send('\t')
await vi.waitFor(() => {
expect(result.terminal.output).toContain('File · design notes.md')
})
result.terminal.send('\t')
await tick()
result.terminal.send('\r')
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) })
expect(result.agent.sent[1]).toEqual([{ type: 'text', text: '@"docs/design notes.md"' }])
expect(result.agent.sentOptions[1]?.contexts).toEqual([])
result.terminal.send('@unsafe')
await tick()
expect(result.terminal.output).not.toContain('File · unsafe')
result.terminal.send('\x03')
} finally {
await result.controller.dispose()
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
expect(assembly.sections).not.toContainEqual({
name: 'ui:tui-file-reference',
text: FILE_REFERENCE_PROMPT,
})
await result.ctx.fiber.dispose()
await rm(cwd, { recursive: true, force: true })
}
})
it('isolates failed file discovery from editor autocomplete', async () => {
const list = vi.spyOn(WorkspaceFileSearch.prototype, 'list').mockRejectedValue(new Error('search failed'))
const result = await setup()
try {
result.terminal.send('@failed')
await vi.waitFor(() => { expect(list).toHaveBeenCalled() })
await tick()
expect(result.agent.sent).toEqual([])
} finally {
list.mockRestore()
await dispose(result)
}
})
it('shows file-reference guidance only while read is visible to the agent', async () => {
const read: ToolDefinition = {
name: 'read',
description: 'Read a file.',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: () => Promise.resolve([]),
}
let visibility: 'none' | 'global' | 'agent' = 'none'
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', {
get(name: string, scope?: Agent) {
if (name !== 'read' || visibility === 'none') return undefined
return (scope === undefined) === (visibility === 'global') ? read : undefined
},
} as never)
},
})
const fileReferenceText = async (): Promise<string | undefined> => {
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
return assembly.sections.find(section => section.name === 'ui:tui-file-reference')?.text
}
try {
expect(await fileReferenceText()).toBe('')
visibility = 'global'
expect(await fileReferenceText()).toBe('')
visibility = 'agent'
expect(await fileReferenceText()).toBe(FILE_REFERENCE_PROMPT)
visibility = 'none'
expect(await fileReferenceText()).toBe('')
} finally {
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(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
const source = ctx.sessions.create(unsafeId, { meta: { cwd: unsafeCwd, createdAt: 1 } })
appendUser(source, 'safe background')
@@ -1147,7 +1290,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
},
})
@@ -1212,7 +1355,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
},
})
@@ -1332,7 +1475,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
ctx.sessions.create(SessionId('source'))
},
@@ -1382,7 +1525,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const lateSuccess = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
ctx.sessions.create(SessionId('source'))
},
@@ -1621,6 +1764,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
handler: () => ({ kind: 'error' as const, text: 'plugin error result' }),
})
result.terminal.send('/plugin-ch')
await tick()
expect(result.terminal.output).toContain('<value> — Run a plugin command')
result.terminal.send('\x03')
result.terminal.send('/plugin-check value ')
result.terminal.send('\r')
await tick()