diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index dc21fb919d..a3eec63b8f 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -40,7 +40,7 @@ After a successful first-party `read`, `write`, or `edit` call, the `tools/post- A content edit appends `Updated instructions from: `, states that the new content replaces the previous content, and includes the complete current file. If precedence changes from one candidate to another, the message also names the previous path and says it no longer applies. If no candidate remains, the plugin appends `Instructions removed: ` and states that the previously loaded instructions no longer apply. -Dynamic messages use a raw `context/message` envelope because the plugin owns the complete system-reminder framing. Core context injection therefore supports `envelope: 'raw'`; callers that omit it retain the canonical `` wrapper. `context/message.meta` carries opaque JSON state that is persisted but never rendered to the model. +Dynamic messages carry their complete system-reminder framing in `content`, and every `context/message` reaches the model verbatim as a user-role message (there is no core wrapper to opt out of). `context/message.meta` carries opaque JSON state that is persisted but never rendered to the model. Shell commands are not discovery triggers. Local bash calls start fresh shells, and inferring reached paths from arbitrary command strings would require shell semantics the prompt plugin does not own. @@ -76,7 +76,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc ## Consequences -Workspace guidance is isolated per session and shared by both product front doors and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract includes optional raw framing and JSON metadata, both propagated through prompt-submit and post-tool `additionalContexts` arrays without flattening entries. +Workspace guidance is isolated per session and shared by both product front doors and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract carries JSON metadata propagated through prompt-submit and post-tool `additionalContexts` arrays without flattening entries. Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, delimiter escaping, and symlink rejection reduce risk but do not eliminate prompt injection. Permission and sandbox layers treat workspace files as data rather than authority. diff --git a/eslint.config.mjs b/eslint.config.mjs index dd40c005d7..03dcf7edac 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,6 +14,7 @@ export default tseslint.config( '**/.doc-typecheck-*/**', 'website/.generated/**', 'vendor/**', // vendored source keeps upstream style and idioms + 'native/**', // imported landlock-run subtree: self-contained workspace with its own gates (native/README.md) '**/*.js', '**/*.mjs', '*.config.ts', // root tool configs (vitest, tsdown) — no project service diff --git a/examples/acp-agent/code-mode-workspace-context.cordis.yml b/examples/acp-agent/code-mode-workspace-context.cordis.yml index f2f24ca3a5..807de9b3c3 100644 --- a/examples/acp-agent/code-mode-workspace-context.cordis.yml +++ b/examples/acp-agent/code-mode-workspace-context.cordis.yml @@ -1,5 +1,5 @@ -# Code Mode workspace-context snapshot recording overlay. The scenario needs -# filesystem tools to trigger nested instruction discovery after a read. +# Code Mode workspace-context snapshot recording overlay. The default filesystem +# tools trigger nested instruction discovery after a read. - id: base name: '@cordisjs/plugin-include' config: @@ -21,13 +21,5 @@ Verify your work by running the code or tests. Keep answers brief and factual. - insert: - - id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - - id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - - id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - id: code-runtime name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/examples/acp-agent/fs.cordis.snapshot.yml b/examples/acp-agent/fs.cordis.snapshot.yml index da0b2ca59b..d55521a8dc 100644 --- a/examples/acp-agent/fs.cordis.snapshot.yml +++ b/examples/acp-agent/fs.cordis.snapshot.yml @@ -1,5 +1,6 @@ -# Keyless filesystem snapshots apply the filesystem and replay overlays directly -# because include patches cannot target entries behind a nested include. +# Keyless filesystem snapshots apply the spill and replay overlays directly +# because include patches cannot target entries behind a nested include. The +# sandboxed filesystem stack already lives in the base cordis.yml. - id: base name: '@cordisjs/plugin-include' config: @@ -9,14 +10,6 @@ name: '@deepseek-ai/dsh-llm-deepseek' disabled: true - insert: - - id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - - id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - - id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - id: spill-local name: '@deepseek-ai/dsh-spill-local' config: diff --git a/examples/acp-agent/workspace-context.cordis.yml b/examples/acp-agent/workspace-context.cordis.yml index 962a3334fe..f9dadc8189 100644 --- a/examples/acp-agent/workspace-context.cordis.yml +++ b/examples/acp-agent/workspace-context.cordis.yml @@ -22,12 +22,3 @@ You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Verify your work by running the code or tests. Keep answers brief and factual. - - insert: - - id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - - id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - - id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index a04c844e9b..7dd1e06931 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -42,7 +42,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when A same-file edit starts with `Updated instructions from: ` and says to use the new content instead of the previously loaded content. A candidate switch additionally names the old path. When no candidate remains, the message is `Instructions removed: ` followed by `The previously loaded instructions from this file no longer apply.` Literal `` text inside an instruction file is escaped so file content cannot close the plugin-owned frame. -The core `context/message` envelope is disabled for these messages because the plugin already owns the complete `` framing. This is caller-selected with `envelope: 'raw'`; ordinary injected context still receives the canonical `` envelope. +The plugin owns the complete `` framing, and every `context/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping. ## State And Refresh diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 83bbfa9d26..21ef979459 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -92,7 +92,6 @@ export function apply(ctx: Context, config: Config): void { if (update !== undefined) { agent.inject(update.context.content, { source: update.context.source, - envelope: update.context.envelope, meta: update.context.meta, }) applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions) diff --git a/packages/context/workspace-context/src/render.ts b/packages/context/workspace-context/src/render.ts index 34e427d0e3..910853b13b 100644 --- a/packages/context/workspace-context/src/render.ts +++ b/packages/context/workspace-context/src/render.ts @@ -163,6 +163,12 @@ function buildInstructionText( ): string { const marker = markerText(maxBytes, omitted, truncated) const body = [marker, style.intro, ...files.map(file => style.section(file))].filter(block => block.length > 0) + // Caller-owned framing: the plugin bakes the complete `` + // frame into the message content. The session surface projects context + // verbatim and does not wrap it, so any framing must live here in the + // producer's content (the pattern a future `meta`-driven renderer would + // generalize — see the deferred note in + // ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md). return [SYSTEM_REMINDER_OPEN, body.join('\n\n'), SYSTEM_REMINDER_CLOSE].join('\n') } diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index b86daeafcf..0ab455349a 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -61,9 +61,8 @@ export interface ReconciledInstructionContext { versionUpdates: InstructionVersionUpdate[] } -/** Plugin-owned raw context with required replay metadata. */ +/** Plugin-owned context with required replay metadata. */ export interface WorkspaceHookContext extends HookContext { - envelope: 'raw' meta: JsonValue } @@ -76,7 +75,7 @@ function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[ ...change.digest !== undefined ? { digest: change.digest } : {}, })) const meta: JsonValue = { kind: 'workspace-instructions', version: 1, changes: serializedChanges } - return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, envelope: 'raw', meta } + return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, meta } } /** diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 7efe77d992..9c5cb3b0d3 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -183,7 +183,6 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { session.append('context/message', { content, source: options?.source ?? { kind: 'user' }, - ...options?.envelope !== undefined ? { envelope: options.envelope } : {}, ...options?.meta !== undefined ? { meta: options.meta } : {}, }, { surfaceOp: 'append' }) }, @@ -212,7 +211,6 @@ function workspaceChangeContext(scope: string, digest: string): HookContext { return { content: [{ type: 'text', text: `instructions for ${scope}` }], source: { kind: 'plugin', plugin: 'workspace-context' }, - envelope: 'raw', meta: { kind: 'workspace-instructions', version: 1, @@ -227,7 +225,6 @@ function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: H lastSeq = agent.session.append('context/message', { content: context.content, source: context.source, - ...context.envelope !== undefined ? { envelope: context.envelope } : {}, ...context.meta !== undefined ? { meta: context.meta } : {}, }, { surfaceOp: 'append' }).seq } @@ -1753,7 +1750,6 @@ describe('dynamic nested workspace context injection', () => { expect(result.isError).toBe(false) expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) - expect(workspaceContextOf(result)?.envelope).toBe('raw') expect(workspaceContextOf(result)?.meta).toMatchObject({ kind: 'workspace-instructions', version: 1, @@ -2564,7 +2560,6 @@ describe('dynamic nested workspace context injection', () => { expect(blocksText(result.content)).toBe('downstream replacement') expect(result.additionalContexts).toHaveLength(2) expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) - expect(workspaceContextOf(result)?.envelope).toBe('raw') expect(workspaceContextOf(result)?.meta).toMatchObject({ kind: 'workspace-instructions', changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md') }], @@ -2577,7 +2572,8 @@ describe('dynamic nested workspace context injection', () => { }) const agent = stubAgent(root) appendAdditionalContexts(agent, result) - expect(blocksText(agent.session.deriveMessages()[1]?.content)).toContain('\ndownstream context\n') + expect(blocksText(agent.session.deriveMessages()[1]?.content)).toContain('downstream context') + expect(blocksText(agent.session.deriveMessages()[1]?.content)).not.toContain(' { const otherWorkspaceEvent = agent.session.append('context/message', { content: otherContext.content, source: otherContext.source, - ...otherContext.envelope !== undefined ? { envelope: otherContext.envelope } : {}, ...otherContext.meta !== undefined ? { meta: otherContext.meta } : {}, }, { surfaceOp: 'append' }) observeInstructionSessionEvent(agent.session, otherWorkspaceEvent, pending, versions) @@ -2933,7 +2928,6 @@ describe('workspace context pending state', () => { const confirmed = agent.session.append('context/message', { content: context.content, source: context.source, - ...context.envelope !== undefined ? { envelope: context.envelope } : {}, ...context.meta !== undefined ? { meta: context.meta } : {}, }, { surfaceOp: 'append' }) observeInstructionSessionEvent(agent.session, confirmed, pending, versions) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 3575724552..00a8f25ab7 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4,9 +4,10 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import 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 LlmService, { LlmAdapter, type GenerateOptions, type LlmCallConfig, type StreamChunk } from '@deepseek-ai/dsh-llm' import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, 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' @@ -14,6 +15,7 @@ import type {} from '@deepseek-ai/dsh-llm-retry' import { createTuiChat, mountTui, + renderSkillInvocation, resolveTuiConfig, type TuiRuntime, } from '../src/index.ts' @@ -135,7 +137,9 @@ describe('TUI config', () => { modelDialogMaxHeight: 20, showHardwareCursor: false, color: true, + truecolor: false, title: 'DeepSeek Harness', + autoTitle: true, }) expect(resolveTuiConfig({ showReasoning: false, @@ -148,7 +152,9 @@ describe('TUI config', () => { modelDialogMaxHeight: 16, showHardwareCursor: true, color: false, + truecolor: true, title: 'DSH', + autoTitle: false, })).toEqual({ showReasoning: false, maxToolOutputLines: 2, @@ -160,11 +166,122 @@ describe('TUI config', () => { modelDialogMaxHeight: 16, showHardwareCursor: true, color: false, + truecolor: true, title: 'DSH', + autoTitle: false, }) }) }) +describe('resume command and /resume', () => { + const RESUME = 'RESUME_SESSION_ID={session} dsh' + const header = (id: string, createdAt: number, cwd: string): SessionHeader => + ({ version: 0, id: SessionId(id), createdAt, cwd }) + + it('prints the resume command on exit once the session is persisted', async () => { + const result = await setup({ + cwd: '/workspace', + config: { resumeCommand: RESUME }, + sessionPersistence: { list: async () => [header('main-session', 1000, '/workspace')] }, + }) + result.terminal.send('/exit') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('To resume this session: RESUME_SESSION_ID=main-session dsh') + expect(result.exit).toHaveBeenCalledWith(0) + await dispose(result) + }) + + it('omits the exit hint when the session is not yet persisted', async () => { + const result = await setup({ cwd: '/workspace', config: { resumeCommand: RESUME } }) + result.terminal.send('/exit') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).not.toContain('To resume this session') + expect(result.exit).toHaveBeenCalledWith(0) + await dispose(result) + }) + + it('omits the exit hint when the session listing fails', async () => { + const result = await setup({ + cwd: '/workspace', + config: { resumeCommand: RESUME }, + sessionPersistence: { list: () => Promise.reject(new Error('disk gone')) }, + }) + result.terminal.send('/exit') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).not.toContain('To resume this session') + expect(result.exit).toHaveBeenCalledWith(0) + await dispose(result) + }) + + it('lists this workspace\'s sessions newest-first and marks the current one', async () => { + const result = await setup({ + cwd: '/workspace', + config: { resumeCommand: RESUME }, + sessionPersistence: { + list: async () => [ + header('main-session', 1000, '/workspace'), + header('older-session', 500, '/workspace'), + header('newer-session', 2000, '/workspace'), + header('foreign-session', 3000, '/elsewhere'), + ], + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + const output = result.terminal.output + expect(output).toContain('Resumable sessions') + expect(output).toContain('RESUME_SESSION_ID=main-session dsh') + expect(output).toContain('(current)') + expect(output).toContain('RESUME_SESSION_ID=newer-session dsh') + expect(output).not.toContain('foreign-session') + // Newest-first: the newer session's command precedes the current session's. + // Match the full resume command, not the bare id: the banner detail line + // echoes the current session id (`main-session`) above the listing. + expect(output.indexOf('RESUME_SESSION_ID=newer-session')).toBeLessThan( + output.indexOf('RESUME_SESSION_ID=main-session'), + ) + expect(output.indexOf('RESUME_SESSION_ID=main-session')).toBeLessThan( + output.indexOf('RESUME_SESSION_ID=older-session'), + ) + await dispose(result) + }) + + it('warns from /resume when resume is not configured', async () => { + const result = await setup({ cwd: '/workspace' }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Resume is not configured') + await dispose(result) + }) + + it('warns from /resume when no persistence backend is mounted', async () => { + const result = await setup({ cwd: '/workspace', config: { resumeCommand: RESUME } }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('no persistence backend is mounted') + await dispose(result) + }) + + it('notes from /resume when no workspace sessions are persisted yet', async () => { + const result = await setup({ + cwd: '/workspace', + config: { resumeCommand: RESUME }, + sessionPersistence: { list: async () => [header('foreign-session', 10, '/elsewhere')] }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('No resumable sessions found') + await dispose(result) + }) +}) + 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({ @@ -322,8 +439,7 @@ describe('pi-tui chat lifecycle and transcript', () => { ) await tick() - expect(result.terminal.output).toContain('◒ Working · 8s') - expect(result.terminal.output).toContain('esc interrupt') + expect(result.terminal.output).toContain('Enter sends steering, Esc cancels') expect(result.terminal.output).toContain('Steering') expect(result.terminal.output).toContain('user context') expect(result.terminal.output).toContain('Prompt blocked') @@ -415,6 +531,155 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) + it('badges queued steering on the running status line and clears it as each drains', async () => { + // Pin a cwd free of the substring under test; the footer renders the path. + const result = await setup({ status: 'running', cwd: '/workspace' }) + // Running with nothing queued: the plain steering hint, no badge. + expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels') + 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 }) + } + const drainSteering = (text: string): void => { + result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + } + + // A steering queue for a different agent never touches this status line. + const other = { ...result.agent, id: SessionId('other') } as Agent + result.terminal.output = '' + result.ctx.emit('agent/queued', other, [{ type: 'text', text: 'elsewhere' }], { source: { kind: 'user' }, steering: true }) + await tick() + expect(result.terminal.output).not.toContain('queued') + + // Two steering messages queue while the turn runs. + queueSteering('first') + result.terminal.output = '' + queueSteering('second') + await tick() + expect(result.terminal.output).toContain('2 queued · Enter sends steering, Esc cancels') + + // 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 }) + drainSteering('first') + await tick() + expect(result.terminal.output).toContain('1 queued') + expect(result.terminal.output).not.toContain('2 queued') + + // Draining the last queued message returns the plain hint. + result.terminal.output = '' + drainSteering('second') + await tick() + expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels') + expect(result.terminal.output).not.toContain('queued') + + // A loop-authored steering drain past zero clamps rather than underflowing. + result.terminal.output = '' + drainSteering('continuation') + queueSteering('after') + await tick() + expect(result.terminal.output).toContain('1 queued') + + // The turn ending resets the badge, so the next running turn starts clean. + result.agent.status = 'idle' + result.ctx.emit('agent/status', result.agent, 'idle') + result.agent.status = 'running' + result.terminal.output = '' + result.ctx.emit('agent/status', result.agent, 'running') + await tick() + expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels') + expect(result.terminal.output).not.toContain('queued') + + await dispose(result) + }) + + it('derives the fine-grained turn phase from session lifecycle events', async () => { + // A live event before the turn runs has no status controller to move. + 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.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') + expect(idle.terminal.output).not.toContain('queued') + await dispose(idle) + + const result = await setup({ status: 'running' }) + expect(result.terminal.output).toContain('Waiting for the first token') + + result.terminal.output = '' + result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } }) + result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'reasoning-delta', index: 0, text: 'mull it over' } }) + await tick() + expect(result.terminal.output).toContain('Thinking') + + result.terminal.output = '' + result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'block-start', index: 1, blockType: 'text' } }) + result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', index: 1, text: 'answering' } }) + await tick() + expect(result.terminal.output).toContain('Responding') + + result.terminal.output = '' + result.session.append('tool/call', { turn: 1, step: 0, callId: 'c1' as never, name: 'bash', arguments: '{}' }) + await tick() + expect(result.terminal.output).toContain('Executing tools') + + // The next step reopens the wait window and resets the executing label. + result.terminal.output = '' + result.session.append('step/start', { turn: 1, step: 1 }) + await tick() + expect(result.terminal.output).toContain('Waiting for the first token') + expect(result.terminal.output).not.toContain('Executing tools') + + await dispose(result) + }) + + it('refreshes the running status elapsed time on its own timer', async () => { + const result = await setup({ status: 'running' }) + result.terminal.output = '' + // The loader repaints "0s" until the controller's own interval fires; a + // non-zero elapsed proves the refresh, not just the loader's animation. + await new Promise(resolve => setTimeout(resolve, 1_300)) + expect(result.terminal.output).toMatch(/Waiting for the first token [1-9]s/) + await dispose(result) + }) + + it('shows minutes and seconds once a step passes a minute', async () => { + const result = await setup({ status: 'running' }) + const base = Date.now() + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(base + 95_000) + result.terminal.output = '' + result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'hi' } }) + await tick() + expect(result.terminal.output).toContain('total 1m') + nowSpy.mockRestore() + await dispose(result) + }) + + it('preserves the turn phase and elapsed time across a mid-turn color-scheme change', async () => { + const result = await setup({ status: 'running' }) + const base = Date.now() + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(base) + // Advance into `responding`, anchoring the phase clock at `base`. + result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'answering' } }) + await tick() + + // Four seconds later the terminal reports a light color scheme, rebuilding + // the status loader; the phase and its elapsed time must survive the rebuild. + nowSpy.mockReturnValue(base + 4_000) + result.terminal.output = '' + result.terminal.send('\x1b[?997;2n') + await tick() + await tick() + expect(result.terminal.output).toContain('Responding 4s') + expect(result.terminal.output).not.toContain('Waiting for the first token') + + nowSpy.mockRestore() + await dispose(result) + }) + it('renders the ANSI palette and every markdown/content style', async () => { const result = await setup({ cwd: '/workspace', @@ -518,6 +783,41 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(logicalResult) }) + it('shows the session cache hit rate in the footer and updates it live', async () => { + // Empty session: no input billed yet, so the cache segment is hidden. + // A cwd without "cache" in it keeps the negative assertion unambiguous. + const empty = await setup({ cwd: '/opt' }) + expect(empty.terminal.output).toContain('↑0 ↓0') + expect(empty.terminal.output).not.toContain('cache') + await dispose(empty) + + const result = await setup({ + // Pin a short cwd so the footer never clips the cache segment: the + // default is process.cwd(), and a deep worktree path truncates + // `cache 60%` at the terminal width. + cwd: '/opt', + beforeMount(session) { + // Cold call: 10 billed input tokens, none served from cache. + appendAssistant(session, [{ type: 'text', text: 'cold' }], { inputTokens: 10, outputTokens: 5 }) + }, + }) + expect(result.terminal.output).toContain('cache 0%') + + result.terminal.output = '' + // Warm call lands live: 5 uncached + 30 cache-read + 5 cache-write billed + // input, so 30 of the 50 total prompt tokens are hits → 60%. + appendAssistant(result.session, [{ type: 'text', text: 'warm' }], { + inputTokens: 5, + outputTokens: 5, + cacheReadTokens: 30, + cacheWriteTokens: 5, + }) + await tick() + expect(result.terminal.output).toContain('cache 60%') + expect(result.terminal.output).not.toContain('cache 0%') + await dispose(result) + }) + it('sends, steers, handles commands, global keys, and disposed-agent input', async () => { const result = await setup() @@ -539,17 +839,15 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.send('\x03') result.terminal.send('\x12') result.terminal.send('\x0f') - result.terminal.send('/cancel') - result.terminal.send('\r') expect(result.agent.cancelled).toContainEqual({ kind: 'user' }) result.agent.status = 'idle' - for (const command of ['/help', '/reasoning', '/tools', '/redraw']) { + for (const command of ['/help', '/reasoning', '/tools', '/redraw', '/reload']) { result.terminal.send(command) result.terminal.send('\r') await tick() } - for (const command of ['/clear', '/cancel', '/wat']) { + for (const command of ['/clear', '/wat']) { result.terminal.send(command) result.terminal.send('\r') } @@ -562,8 +860,9 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('Keyboard shortcuts') expect(result.terminal.output).toContain('Reasoning blocks') expect(result.terminal.output).toContain('Tool cards') - expect(result.terminal.output).toContain('already idle') expect(result.terminal.output).toContain('Unknown command') + // /reload without a Loader in the context degrades to a warning. + expect(result.terminal.output).toContain('/reload needs the cordis Loader') expect(result.exit).toHaveBeenCalledWith(0) await result.controller.dispose() await result.ctx.fiber.dispose() @@ -917,6 +1216,153 @@ describe('pi-tui chat lifecycle and transcript', () => { }) }) +describe('skill slash command', () => { + const withSkills = async (ctx: Context): Promise => { + ctx.provide('tools', { get() { return undefined } } as never) + await ctx.plugin(SkillService) + const skills = ctx.get('skills') + if (skills === undefined) throw new Error('skills service not mounted') + skills.register({ name: 'demo-skill', description: 'Demo skill for tests', source: 'runtime', provider: 'runtime', content: 'Demo instructions body.' }) + skills.register({ name: 'hidden-skill', description: 'Model-hidden skill', source: 'runtime', provider: 'runtime', content: 'Hidden instructions body.', disableModelInvocation: true }) + } + + it('offers non-hidden skills as slash completions and hides model-disabled ones', async () => { + const result = await setup({ configureContext: withSkills }) + result.terminal.send('/skill') + await tick() + expect(result.terminal.output).toContain('demo-skill') + expect(result.terminal.output).not.toContain('hidden-skill') + await dispose(result) + }) + + it('loads a skill as a user turn, appending typed instructions', async () => { + const result = await setup({ configureContext: withSkills }) + result.terminal.send('/skill:demo-skill') + result.terminal.send('\r') + await tick() + expect(result.agent.sent).toEqual([[{ type: 'text', text: '\nDemo instructions body.\n' }]]) + + result.agent.status = 'running' + result.terminal.send('/skill:demo-skill focus on tests') + result.terminal.send('\r') + await tick() + expect(result.agent.steered).toEqual([[{ type: 'text', text: '\nDemo instructions body.\n\n\nfocus on tests' }]]) + await dispose(result) + }) + + it('invokes a model-disabled skill by its exact name', async () => { + const result = await setup({ configureContext: withSkills }) + result.terminal.send('/skill:hidden-skill') + result.terminal.send('\r') + await tick() + expect(result.agent.sent).toEqual([[{ type: 'text', text: '\nHidden instructions body.\n' }]]) + await dispose(result) + }) + + it('reports an unknown skill and an empty skill name without sending', async () => { + const result = await setup({ configureContext: withSkills }) + result.terminal.send('/skill:does-not-exist') + result.terminal.send('\r') + await tick() + result.terminal.send('/skill:') + result.terminal.send('\r') + await tick() + // A space right after the colon parses to an empty name, not a name of + // "focus"; the documented syntax puts the name immediately after the colon. + result.terminal.send('/skill: focus') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Unknown skill: does-not-exist') + expect(result.terminal.output).toContain('Usage: /skill:') + expect(result.agent.sent).toEqual([]) + await dispose(result) + }) + + it('warns when no skill service is mounted', async () => { + const result = await setup() + result.terminal.send('/skill:demo-skill') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Skills are not available') + expect(result.agent.sent).toEqual([]) + await dispose(result) + }) + + it('surfaces skill lookup failures as an error notice', async () => { + const result = await setup({ + configureContext: async (ctx) => { + ctx.provide('tools', { get() { return undefined } } as never) + ctx.provide('skills', { + list: () => Promise.reject(new Error('list boom')), + get: () => Promise.reject(new Error('get boom')), + } as never) + }, + }) + result.terminal.send('/skill:demo-skill') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('failed to load') + expect(result.terminal.output).toContain('get boom') + await dispose(result) + }) + + it('drops skill list and lookup results that settle after disposal', async () => { + const pendingList: Array<(value: SkillSummary[]) => void> = [] + const pendingGet: Array<{ resolve: (value: SkillDefinition | undefined) => void; reject: (error: unknown) => void }> = [] + const result = await setup({ + configureContext: async (ctx) => { + ctx.provide('tools', { get() { return undefined } } as never) + ctx.provide('skills', { + list: () => new Promise((resolve) => { pendingList.push(resolve) }), + get: () => new Promise((resolve, reject) => { pendingGet.push({ resolve, reject }) }), + } as never) + }, + }) + result.terminal.send('/skill:demo-skill') + result.terminal.send('\r') + await tick() + result.terminal.send('/skill:other-skill') + result.terminal.send('\r') + await tick() + await dispose(result) + + for (const resolve of pendingList) resolve([{ name: 'late', description: 'late', source: 'runtime', provider: 'runtime' }]) + pendingGet[0]?.resolve({ name: 'demo-skill', description: 'late', source: 'runtime', provider: 'runtime', content: 'late body' }) + pendingGet[1]?.reject(new Error('late failure')) + await tick() + expect(result.agent.sent).toEqual([]) + expect(result.terminal.output).not.toContain('late failure') + expect(result.terminal.output).not.toContain('late body') + }) +}) + +describe('renderSkillInvocation', () => { + const skill: SkillDefinition = { + name: 'demo-skill', + description: 'Demo skill', + source: 'runtime', + provider: 'runtime', + content: 'Body text.', + } + + it('renders directory, url, opaque, and absent resource bases', () => { + expect(renderSkillInvocation({ ...skill, resourceBase: { kind: 'directory', path: '/skills/demo' } }, '')).toBe( + '\nReferences in this skill are relative to /skills/demo.\n\nBody text.\n', + ) + expect(renderSkillInvocation({ ...skill, resourceBase: { kind: 'url', url: 'https://x/y' } }, 'go')).toBe( + '\nReferences in this skill are relative to https://x/y.\n\nBody text.\n\n\ngo', + ) + expect(renderSkillInvocation({ ...skill, resourceBase: { kind: 'opaque', description: 'held in memory' } }, '')).toBe( + '\nheld in memory\n\nBody text.\n', + ) + expect(renderSkillInvocation(skill, '')).toBe('\nBody text.\n') + }) + + it('throws on an unknown resource base kind', () => { + expect(() => renderSkillInvocation({ ...skill, resourceBase: { kind: 'future' } as never }, '')).toThrow('unreachable variant') + }) +}) + describe('tool cards and surface replay', () => { const tools: Record = { bash: { @@ -1289,6 +1735,37 @@ describe('terminal mounting', () => { await ctx.fiber.dispose() }) + it('degrades /reload to a warning when mounted as a real plugin without a Loader', async () => { + // Production shape: the TUI runs inside a plugin fiber, where a bare + // `ctx.loader` proxy read would THROW `cannot get property without + // inject` — only the non-throwing `ctx.get` lookup degrades gracefully. + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + ctx.provide('tools', { get: () => undefined } as never) + const session = ctx.sessions.create(SessionId('main')) + ctx.agents.register({ + id: session.id, options: {}, session, status: 'idle', ctx, + send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + }) + const terminal = new FakeTerminal() + // Mirror dsh-tui's own inject (minus loader, the absence under test). + await ctx.plugin({ + inject: ['agents', 'userInteraction', 'tools'], + apply: (pluginCtx: Context) => { + mountTui(pluginCtx, { color: false }, { terminal, exit: vi.fn() }) + }, + }) + await tick() + expect(terminal.started).toBe(1) + terminal.send('/reload') + terminal.send('\r') + await tick() + expect(terminal.output).toContain('/reload needs the cordis Loader') + await ctx.fiber.dispose() + }) + it('waits for its configured agent before starting the TUI', async () => { const ctx = new Context() provideTokenMeter(ctx) @@ -1335,7 +1812,7 @@ describe('terminal mounting', () => { expect(terminal.output).toBe('') expect(exit).not.toHaveBeenCalled() ctx.emit('agent-loop/config-start-failed', SessionId('main-session'), new Error('resume \u001b]2;failure-controlled\u0007')) - expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: Error: resume \\x1b]2;failure-controlled\\x07\n') + expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: resume \\x1b]2;failure-controlled\\x07\n') expect(exit).toHaveBeenCalledWith(1) const session = ctx.sessions.create(SessionId('main-session')) @@ -1365,7 +1842,7 @@ describe('terminal mounting', () => { }) expect(terminal.started).toBe(0) - expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: \n') + expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: \n') expect(exit).toHaveBeenCalledWith(1) await ctx.fiber.dispose() }) @@ -1470,4 +1947,394 @@ describe('terminal mounting', () => { expect(terminal.output).toContain('\x1b[2mdeepseek-v4-flash') await disposeTuiTestHarness(result) }) + it('runs /reload against every file-backed loader subtree, reports completion, and rejects re-entry while in flight', async () => { + const refreshed: string[] = [] + let releaseRefresh!: () => void + const gate = new Promise((resolve) => { releaseRefresh = resolve }) + const result = await setup({ + configureContext: async (ctx) => { + ctx.provide('tools', { get: () => undefined } as never) + // A structural Loader: two file-backed subtrees and one plain entry. + // The first subtree blocks on a gate so re-entry can be probed + // deterministically mid-flight. + ctx.provide('loader', { + entries: () => [ + { subtree: { refresh: async () => { refreshed.push('root'); await gate } } }, + {}, + { subtree: { refresh: async () => { refreshed.push('nested') } } }, + ], + } as never) + }, + }) + result.terminal.send('/reload') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Reloading 2 config tree(s)') + // Second /reload while the first is gated: refused, no extra refreshes. + result.terminal.send('/reload') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('A config reload is already running.') + expect(refreshed.sort()).toEqual(['nested', 'root']) + releaseRefresh() + await tick() + expect(result.terminal.output).toContain('Config reload complete.') + // The guard released: a third /reload runs again. + result.terminal.send('/reload') + result.terminal.send('\r') + await tick() + expect(refreshed).toHaveLength(4) + await dispose(result) + }) + + it('reports a /reload failure if a refresh ever rejects', async () => { + const result = await setup({ + configureContext: async (ctx) => { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('loader', { + entries: () => [{ subtree: { refresh: () => Promise.reject(new Error('disk gone')) } }], + } as never) + }, + }) + result.terminal.send('/reload') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Config reload failed: disk gone') + // The failure arm also releases the re-entrancy guard. + result.terminal.send('/reload') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).not.toContain('A config reload is already running.') + await dispose(result) + }) + + it('refuses /reload while the agent is running and allows it back at idle', async () => { + const refreshed: string[] = [] + const result = await setup({ + status: 'running', + configureContext: async (ctx) => { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('loader', { + entries: () => [{ subtree: { refresh: async () => { refreshed.push('tree') } } }], + } as never) + }, + }) + result.terminal.send('/reload') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('/reload requires an idle agent (status: running).') + expect(refreshed).toHaveLength(0) + // Back at idle the same command runs. + result.agent.status = 'idle' + result.terminal.send('/reload') + result.terminal.send('\r') + await tick() + expect(refreshed).toHaveLength(1) + expect(result.terminal.output).toContain('Config reload complete.') + await dispose(result) + }) + +}) + +describe('banner sweep reveal', () => { + it('renders the product name through the brand-gradient path when truecolor is enabled', async () => { + // The product name carries a per-letter 24-bit gradient from the brand + // indigo to light blue; the per-letter layout is pinned by the + // `banner-gradient` terminal snapshot. + const result = await setup({ config: { color: true, truecolor: true } }) + expect(result.terminal.output).toContain('\x1b[38;2;77;107;254m') + expect(result.terminal.output).toContain('\x1b[38;2;36;152;255m') + expect(result.terminal.output).toContain('HARNESS') + await dispose(result) + }) + + it('sweeps the whole borderless banner in when no welcome is configured, ending complete', async () => { + const intervals = vi.spyOn(globalThis, 'setInterval') + const cleared = vi.spyOn(globalThis, 'clearInterval') + const result = await setup({ omitWelcome: true }) + const revealHandle = intervals.mock.results.at(-1)?.value as ReturnType + // Run the sweep to natural completion — it clears its own timer at the end. + const done = (): boolean => cleared.mock.calls.some(call => call[0] === revealHandle) + const deadline = Date.now() + 5000 + while (!done() && Date.now() < deadline) await tick() + intervals.mockRestore() + cleared.mockRestore() + // The finished banner carries the title and the model • session detail. + expect(result.terminal.output).toContain('DEEPSEEK') + expect(result.terminal.output).toContain('HARNESS') + expect(result.terminal.output).toContain('main-session') + // Borderless: no box-drawing frame around the banner. + expect(result.terminal.output).not.toContain('╭') + expect(result.terminal.output).not.toContain('╮') + // A mid-sweep frame rendered a clipped title: `DEEPSEEK` with no `HARNESS` + // on the same line. + const clipped = result.terminal.output + .split('\n') + .some(line => line.includes('DEEPSEEK') && !line.includes('HARNESS')) + expect(clipped).toBe(true) + await dispose(result) + }) + + it('renders a configured welcome verbatim in a complete banner with no sweep', async () => { + const result = await setup() + await tick() + expect(result.terminal.output).toContain('Coding agent ready.') + expect(result.terminal.output).toContain('DEEPSEEK') + expect(result.terminal.output).not.toContain('╭') + // No reveal frames: the banner is drawn whole from the first render, so no + // clipped-title frame ever appears. + const clipped = result.terminal.output + .split('\n') + .some(line => line.includes('DEEPSEEK') && !line.includes('HARNESS')) + expect(clipped).toBe(false) + await dispose(result) + }) + + it('omits the subtitle line entirely when no welcome is configured', async () => { + const result = await setup({ omitWelcome: true }) + const deadline = Date.now() + 5000 + while (!result.terminal.output.includes('main-session') && Date.now() < deadline) await tick() + // Banner is title + detail only — no subtitle between them. + expect(result.terminal.output).toContain('deepseek-v4-flash') + expect(result.terminal.output).not.toContain('ready.') + await dispose(result) + }) + + it('stops a mid-sweep animation on dispose', async () => { + // The output-stability probe alone is insensitive to a leaked interval + // (pi-tui's stopped guard silences post-stop renders), so capture the + // reveal's own interval handle and assert dispose clears exactly it. + const intervals = vi.spyOn(globalThis, 'setInterval') + const result = await setup({ omitWelcome: true }) + const revealHandle = intervals.mock.results.at(-1)?.value as ReturnType + expect(revealHandle).toBeDefined() + const cleared = vi.spyOn(globalThis, 'clearInterval') + await dispose(result) + expect(cleared.mock.calls.some(call => call[0] === revealHandle)).toBe(true) + intervals.mockRestore() + cleared.mockRestore() + const settled = result.terminal.output.length + await tick() + await tick() + expect(result.terminal.output.length).toBe(settled) + }) +}) + +/** Streams one fixed reply (or throws) so a test can drive the auto-title call. */ +class TitleAdapter extends LlmAdapter { + lastOptions: GenerateOptions | undefined + calls = 0 + constructor(private readonly reply: string | Error) { + super() + } + + async *stream(options: GenerateOptions): AsyncIterable { + this.calls += 1 + this.lastOptions = options + if (this.reply instanceof Error) throw this.reply + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: this.reply } + yield { type: 'block-end', index: 0, block: { type: 'text', text: this.reply } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +/** Provide the `llm` service (with `adapter` on provider `mock`) plus the tools stub the TUI injects. */ +function withLlm(adapter: LlmAdapter): (ctx: Context) => Promise { + return async (ctx: Context) => { + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['mock'], adapter) + ctx.provide('tools', { get: () => undefined } as never) + } +} + +describe('TUI auto-title', () => { + const agentOptions: Agent['options'] = { provider: 'mock', model: 'mock-model' } + + it('replaces the title with a model-generated title after the first user message', async () => { + const adapter = new TitleAdapter('fix the login redirect') + const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) }) + appendUser(result.session, 'the login page throws a 500 on submit, please investigate') + await tick() + expect(result.terminal.title).toBe('fix the login redirect') + // The request carries the task framing, the user's first message, and no tools. + expect(adapter.lastOptions?.provider).toBe('mock') + expect(adapter.lastOptions?.model).toBe('mock-model') + expect(adapter.lastOptions?.system).toContain('short title') + expect(adapter.lastOptions?.tools).toBeUndefined() + expect(adapter.lastOptions?.messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'the login page throws a 500 on submit, please investigate' }] }, + ]) + await dispose(result) + }) + + it('requests a title only once, even after later user messages', async () => { + const adapter = new TitleAdapter('the settled title') + const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) }) + appendUser(result.session, 'the first request that earns the title') + await tick() + expect(result.terminal.title).toBe('the settled title') + appendUser(result.session, 'a second request that must not re-title') + await tick() + expect(adapter.calls).toBe(1) + expect(result.terminal.title).toBe('the settled title') + await dispose(result) + }) + + it('uses the first non-empty line and truncates an over-long title with an ellipsis', async () => { + const adapter = new TitleAdapter('\n this title is deliberately far too long to fit a terminal tab \nextra') + const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) }) + appendUser(result.session, 'do the big thing') + await tick() + expect(result.terminal.title).toBe('this title is deliberately far too long…') + expect(result.terminal.title.length).toBe(40) + await dispose(result) + }) + + it('skips a whitespace-only first message without consuming the one-shot slot', async () => { + const adapter = new TitleAdapter('the real title') + const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) }) + appendUser(result.session, ' ') + await tick() + expect(adapter.lastOptions).toBeUndefined() + expect(result.terminal.title).toBe('DeepSeek Harness') + appendUser(result.session, 'the first real request') + await tick() + expect(result.terminal.title).toBe('the real title') + await dispose(result) + }) + + it('leaves the title unchanged when the model returns no usable text', async () => { + const adapter = new TitleAdapter(' \n ') + const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) }) + appendUser(result.session, 'anything at all') + await tick() + expect(result.terminal.title).toBe('DeepSeek Harness') + await dispose(result) + }) + + it('leaves the title unchanged when the title request fails', async () => { + const adapter = new TitleAdapter(new Error('router unavailable')) + const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) }) + appendUser(result.session, 'trigger a failing title request') + await tick() + expect(result.terminal.title).toBe('DeepSeek Harness') + await dispose(result) + }) + + it('re-derives the title on resume from the already-logged first user message', async () => { + const adapter = new TitleAdapter('resumed session title') + const result = await setup({ + config: { autoTitle: true }, + agentOptions, + configureContext: withLlm(adapter), + beforeMount: (session) => { + appendUser(session, 'the original first request') + appendUser(session, 'a later request that must not seed the title') + }, + }) + await tick() + // The title comes from the stored first message, not any later one. + expect(adapter.lastOptions?.messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'the original first request' }] }, + ]) + expect(result.terminal.title).toBe('resumed session title') + // A message that arrives after the resume must not re-title. + appendUser(result.session, 'a follow-up message') + await tick() + expect(adapter.calls).toBe(1) + expect(result.terminal.title).toBe('resumed session title') + await dispose(result) + }) + + it('keeps the static title when auto-title is disabled', async () => { + const adapter = new TitleAdapter('should not run') + const result = await setup({ config: { autoTitle: false }, agentOptions, configureContext: withLlm(adapter) }) + appendUser(result.session, 'a normal message with the feature off') + await tick() + expect(adapter.lastOptions).toBeUndefined() + expect(result.terminal.title).toBe('DeepSeek Harness') + await dispose(result) + }) + + it('keeps the static title when no llm service is available', async () => { + const result = await setup({ config: { autoTitle: true }, agentOptions }) + appendUser(result.session, 'no model can answer this') + await tick() + expect(result.terminal.title).toBe('DeepSeek Harness') + await dispose(result) + }) + + it('keeps the static title when the agent has no provider', async () => { + const adapter = new TitleAdapter('unused') + const result = await setup({ + config: { autoTitle: true }, + agentOptions: { model: 'mock-model' }, + configureContext: withLlm(adapter), + }) + appendUser(result.session, 'the provider is missing') + await tick() + expect(adapter.lastOptions).toBeUndefined() + expect(result.terminal.title).toBe('DeepSeek Harness') + await dispose(result) + }) + + it('keeps the static title when the agent has no model', async () => { + const adapter = new TitleAdapter('unused') + const result = await setup({ + config: { autoTitle: true }, + agentOptions: { provider: 'mock' }, + configureContext: withLlm(adapter), + }) + appendUser(result.session, 'the model is missing') + await tick() + expect(adapter.lastOptions).toBeUndefined() + expect(result.terminal.title).toBe('DeepSeek Harness') + await dispose(result) + }) + + it('aborts an in-flight title request on shutdown', async () => { + const seen: { aborted: boolean } = { aborted: false } + class HangingAdapter extends LlmAdapter { + async *stream(options: GenerateOptions): AsyncIterable { + await new Promise((_resolve, reject) => { + options.signal?.addEventListener('abort', () => { + seen.aborted = true + reject(new Error('aborted')) + }) + }) + yield { type: 'finish', reason: { kind: 'stop' } } + } + } + const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(new HangingAdapter()) }) + appendUser(result.session, 'start a title request that never resolves') + await tick() + await dispose(result) + expect(seen.aborted).toBe(true) + expect(result.terminal.title).toBe('DeepSeek Harness') + }) + + it('does not set the title when the UI is torn down before the stream completes', async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { release = resolve }) + class GatedAdapter extends LlmAdapter { + // Yields a full reply, then blocks on the gate so the post-stream title + // apply runs only after the test has torn the UI down. Ignores `signal`, + // so shutdown's abort cannot cut the stream short. + async *stream(_options: GenerateOptions): AsyncIterable { + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'title that arrives too late' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: 'title that arrives too late' } } + yield { type: 'finish', reason: { kind: 'stop' } } + await gate + } + } + const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(new GatedAdapter()) }) + appendUser(result.session, 'start a title that finishes after teardown') + await tick() + await dispose(result) + release() + await tick() + expect(result.terminal.title).toBe('DeepSeek Harness') + }) }) diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 5d98cee538..32f2fa072b 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 4650a0b753..867e67542b 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -14,6 +14,8 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import GoalService from '@deepseek-ai/dsh-goal' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' +import { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -44,6 +46,44 @@ import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/tool-catalog.md' +const CATALOG_RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1' + +/** + * Minimal bash service for harvesting `dsh-tool-fs-search` schemas. The search + * plugin now probes `rg` at registration time, but the generated catalog must + * remain independent of the host PATH and never execute a real search. + */ +class CatalogSearchBashExecutor extends BashExecutor { + override resolve(request: BashExecRequest): BashExecSpec { + return { + command: request.command, + workdir: request.workdir ?? root, + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + signal: request.signal, + sandboxMode: request.sandboxMode, + } + } + + override run(spec: BashExecSpec): Promise { + if (spec.command !== CATALOG_RG_PROBE_COMMAND) { + throw new Error(`gen-tool-catalog: unexpected search bash command during schema harvest: ${spec.command}`) + } + return Promise.resolve({ + exitCode: 0, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: spec.timeoutMs, + stdout: { text: '', truncated: false }, + stderr: { text: '', truncated: false }, + }) + } + + override start(): BashProcess { + throw new Error('gen-tool-catalog: search schema harvest must not start background processes') + } +} /** * Register the descriptor needed to mount schema-producing consumers. Declares @@ -180,14 +220,15 @@ const TOOL_PACKAGES: ToolPackage[] = [ writes: ['tool/call', 'tool/result'], async mount(ctx) { // The tools inject `bash` (search executes fixed `rg` commands through - // the executor seam, not ctx.fs); boot the local executor to satisfy it. - // `ctx.spillStore` is optional (read via ctx.get) and does not affect the - // schemas, so no spill backend is mounted. - await ctx.plugin(LocalBashExecutor) + // the executor seam, not ctx.fs). Use a catalog-only executor so the + // registration-time `rg` probe stays deterministic and the generator + // never depends on the host PATH. `ctx.spillStore` is optional (read via + // ctx.get) and does not affect the schemas, so no spill backend is mounted. + await ctx.plugin(CatalogSearchBashExecutor) await ctx.plugin(ToolFsSearch) }, note: - 'glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.', + 'glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.', }, { pkg: '@deepseek-ai/dsh-tool-goal', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index f492319cf7..11eef97698 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -48,12 +48,14 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' }, 'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' }, 'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, + 'packages/fs/fs-sandbox': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, 'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' }, 'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' }, 'packages/lsp/lsp-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-lsp.' }, 'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' }, + 'packages/sandbox/sandbox-policy': { kind: 'indirect', reason: 'The policy service holds the mode dsh-tool-bash and dsh-tool-fs render in their denial markers.' }, 'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' }, 'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' }, 'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' }, diff --git a/tsconfig.build.json b/tsconfig.build.json index 71e7a8d165..e332336315 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -57,11 +57,13 @@ { "path": "./packages/bash/bash-local" }, { "path": "./packages/sandbox/sandbox" }, { "path": "./packages/sandbox/sandbox-local" }, + { "path": "./packages/sandbox/sandbox-policy" }, { "path": "./packages/bash/bash-sandbox" }, { "path": "./packages/bash/tool-bash" }, { "path": "./packages/fs/fs" }, { "path": "./packages/fs/fs-local" }, { "path": "./packages/fs/fs-policy" }, + { "path": "./packages/fs/fs-sandbox" }, { "path": "./packages/fs/tool-fs" }, { "path": "./packages/fs/tool-fs-search" }, { "path": "./packages/web/web" }, diff --git a/tsconfig.json b/tsconfig.json index 99b7fe8b35..295e2a6365 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -68,11 +68,13 @@ { "path": "./packages/bash/bash-local" }, { "path": "./packages/sandbox/sandbox" }, { "path": "./packages/sandbox/sandbox-local" }, + { "path": "./packages/sandbox/sandbox-policy" }, { "path": "./packages/bash/bash-sandbox" }, { "path": "./packages/bash/tool-bash" }, { "path": "./packages/fs/fs" }, { "path": "./packages/fs/fs-local" }, { "path": "./packages/fs/fs-policy" }, + { "path": "./packages/fs/fs-sandbox" }, { "path": "./packages/fs/tool-fs" }, { "path": "./packages/fs/tool-fs-search" }, { "path": "./packages/compact/compact" },