Merge origin/master into codex/sandbox-policy-context
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import type { AgentLlmTarget, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent'
|
||||
import { errorChain, type ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { errorChain, LlmError, type ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { TuiOverlaySession } from '../extension/types.ts'
|
||||
import { displayText } from '../components/text.ts'
|
||||
import {
|
||||
@@ -37,6 +37,8 @@ export interface ModelController {
|
||||
resetContextResolution(): void
|
||||
/** Forget the tracked selector overlay (shutdown). */
|
||||
clearOverlay(): void
|
||||
/** Remove the adapter-registration listener (channel detach). */
|
||||
detach(): void
|
||||
}
|
||||
|
||||
type ContextResolution =
|
||||
@@ -55,8 +57,15 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
|
||||
let modelOverlay: TuiOverlaySession | undefined
|
||||
let modelCommands = Promise.resolve()
|
||||
|
||||
// A route whose adapter has not registered yet. Loader activation order is
|
||||
// service-driven, so the TUI can mount before a configured adapter plugin
|
||||
// activates; that transient NO_ADAPTER is not an error — the resolution
|
||||
// waits for the next `llm/adapters-updated` commit instead of surfacing it.
|
||||
let awaitingAdapter = false
|
||||
|
||||
const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
|
||||
contextWindow = undefined
|
||||
awaitingAdapter = false
|
||||
const resolution: Promise<ContextResolution> = selected === undefined
|
||||
? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const)
|
||||
: ctx.llm.resolveModelInfo(selected.provider, selected.model).then(
|
||||
@@ -67,6 +76,10 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
|
||||
void resolution.then((result) => {
|
||||
if (contextResolution !== resolution) return
|
||||
if (result.kind === 'error') {
|
||||
if (selected !== undefined && result.error instanceof LlmError && result.error.code === 'NO_ADAPTER') {
|
||||
awaitingAdapter = true
|
||||
return
|
||||
}
|
||||
deps.appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error')
|
||||
return
|
||||
}
|
||||
@@ -74,6 +87,15 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
|
||||
deps.requestRender()
|
||||
})
|
||||
}
|
||||
// The wait cannot go stale against `target.current`: every target change
|
||||
// re-enters resolveContextWindow, which clears it. A commit that still
|
||||
// lacks the route parks the resolution again rather than erroring, so
|
||||
// unrelated topology changes stay silent. The disposer rides the channel's
|
||||
// detachListeners() through detach(), matching the sibling listeners.
|
||||
const disposeAdapterListener = ctx.on('llm/adapters-updated', () => {
|
||||
if (deps.isDisposed() || !awaitingAdapter) return
|
||||
resolveContextWindow(target.current)
|
||||
})
|
||||
resolveContextWindow(target.current)
|
||||
|
||||
const selectModel = (
|
||||
@@ -187,5 +209,8 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
|
||||
clearOverlay(): void {
|
||||
modelOverlay = undefined
|
||||
},
|
||||
detach(): void {
|
||||
disposeAdapterListener()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,23 +402,26 @@ export class ToolCardComponent implements Component {
|
||||
const glyph = this.result === undefined ? '○' : '●'
|
||||
const rawBody = this.renderBody()
|
||||
const view = this.resultView ?? this.callView
|
||||
// A generic card's own content, or a read card's `content` fallback (the
|
||||
// A generic card's own content, a read card's `content` fallback (the
|
||||
// envelope-stripped file text — the TUI has no dedicated read rendering, so a
|
||||
// read renders exactly as before the read card existed), or a web card's
|
||||
// fallback to the raw result content (the `web` view carries no `content`
|
||||
// copy), all render as one dim Markdown block below, so links/lists/headings
|
||||
// keep the unified dim styling rather than reading as bare text. Terminal and
|
||||
// diff cards own their body styling, so they are excluded (mirrors
|
||||
// renderBody's post-terminal/diff fallback).
|
||||
// read renders exactly as before the read card existed), or a search/web
|
||||
// card's fallback to the raw result content (neither the `search` nor the
|
||||
// `web` view carries a `content` copy), all render as one dim Markdown block
|
||||
// below, so links/lists/headings keep the unified dim styling rather than
|
||||
// reading as bare text. A search card thus stays byte-identical to the
|
||||
// pre-search-card generic fallback. Terminal and diff cards own their body
|
||||
// styling, so they are excluded (mirrors renderBody's post-terminal/diff fallback).
|
||||
const markdownContent = view.card === 'generic' || view.card === 'read'
|
||||
? view.content ?? this.result?.content
|
||||
: view.card === 'web'
|
||||
// A web resultView is only assigned alongside this.result (the result
|
||||
// handler sets both) and the pending callView is never a web card, so
|
||||
// the optional-chain undefined side is unreachable here.
|
||||
/* v8 ignore next */
|
||||
: view.card === 'search'
|
||||
? this.result?.content
|
||||
: undefined
|
||||
: view.card === 'web'
|
||||
// A web resultView is only assigned alongside this.result (the result
|
||||
// handler sets both) and the pending callView is never a web card, so
|
||||
// the optional-chain undefined side is unreachable here.
|
||||
/* v8 ignore next */
|
||||
? this.result?.content
|
||||
: undefined
|
||||
const unknownXml = this.definition === undefined && markdownContent !== undefined
|
||||
? renderUnknownXml(
|
||||
displayText(contentText(markdownContent)),
|
||||
@@ -535,11 +538,12 @@ export class ToolCardComponent implements Component {
|
||||
// rather than under the dim result-output color.
|
||||
return { prelude: [...hunks, footer], lines: [] }
|
||||
}
|
||||
// A generic or read card carries its own envelope-stripped `content`; a `web`
|
||||
// card carries no `content` copy and falls back to the raw result content
|
||||
// here. (Mirrors the `markdownContent` selection in render(); a read card has
|
||||
// no dedicated TUI rendering, so its `content` takes the same body path,
|
||||
// keeping read output as it was before the read card existed.)
|
||||
// A generic or read card carries its own envelope-stripped `content`; a
|
||||
// search or web card carries no `content` copy and falls back to the raw
|
||||
// result content here. (Mirrors the `markdownContent` selection in render();
|
||||
// a read card has no dedicated TUI rendering, so its `content` takes the same
|
||||
// body path, keeping read output as it was before the read card existed, and
|
||||
// a search card stays byte-identical to the pre-search-card fallback.)
|
||||
const content = (view.card === 'generic' || view.card === 'read' ? view.content : undefined) ?? this.result?.content
|
||||
const prelude: string[] = []
|
||||
const lines: string[] = []
|
||||
|
||||
@@ -1563,6 +1563,7 @@ export function createTuiChat(
|
||||
disposeAgent()
|
||||
disposeSchemeListener()
|
||||
disposeTargetListeners()
|
||||
modelController.detach()
|
||||
}
|
||||
|
||||
// Sweep reveal of the whole banner: the header wipes in left-to-right over
|
||||
|
||||
@@ -10,6 +10,7 @@ import AgentRegistry, {
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage,
|
||||
createToolResultMessage,
|
||||
LlmError,
|
||||
ReasoningEffortId,
|
||||
type LlmCallConfig,
|
||||
type LlmModelReasoningInfo,
|
||||
@@ -3630,6 +3631,96 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
await dispose(reasoningFailed)
|
||||
})
|
||||
|
||||
it('defers a NO_ADAPTER context resolution until the provider registers instead of surfacing an error', async () => {
|
||||
// Loader activation order is service-driven: the TUI can mount before a
|
||||
// configured adapter plugin activates, so the initial resolveModelInfo
|
||||
// fails with NO_ADAPTER. That transient state must not print an error;
|
||||
// the resolution retries on llm/adapters-updated.
|
||||
const adapters = new Set<string>()
|
||||
const result = await setup({
|
||||
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },
|
||||
contextTokens: 50_000,
|
||||
catalog: {
|
||||
providers: [],
|
||||
models: [],
|
||||
resolveModelInfo: () => adapters.has('openai-codex')
|
||||
? Promise.resolve({ context: { contextWindow: 100_000 } })
|
||||
: Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')),
|
||||
},
|
||||
})
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('Could not resolve model context')
|
||||
|
||||
// A topology commit that still lacks the route parks the wait again.
|
||||
result.ctx.emit('llm/adapters-updated')
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('% context')
|
||||
expect(result.terminal.output).not.toContain('Could not resolve model context')
|
||||
|
||||
adapters.add('openai-codex')
|
||||
result.ctx.emit('llm/adapters-updated')
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('% context')
|
||||
})
|
||||
expect(result.terminal.output).not.toContain('Could not resolve model context')
|
||||
|
||||
// A commit after satisfaction is a no-op for the resolved value.
|
||||
result.ctx.emit('llm/adapters-updated')
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('Could not resolve model context')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('stops listening for adapter registrations after channel detach', async () => {
|
||||
// The listener disposer rides detachListeners() through the controller's
|
||||
// detach(): after dispose, a registry commit must not re-enter resolution
|
||||
// at all (the isDisposed() guard is a fallback, not the removal).
|
||||
const calls: string[] = []
|
||||
const result = await setup({
|
||||
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },
|
||||
catalog: {
|
||||
providers: [],
|
||||
models: [],
|
||||
resolveModelInfo: (provider) => {
|
||||
calls.push(provider)
|
||||
return Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER'))
|
||||
},
|
||||
},
|
||||
})
|
||||
await tick()
|
||||
const callsAtDetach = calls.length
|
||||
await result.controller.dispose()
|
||||
result.ctx.emit('llm/adapters-updated')
|
||||
await tick()
|
||||
expect(calls.length).toBe(callsAtDetach)
|
||||
await result.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('drops a deferred NO_ADAPTER resolution when the target moved before the adapter registered', async () => {
|
||||
const result = await setup({
|
||||
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },
|
||||
catalog: {
|
||||
providers: [{ id: 'alpha', name: 'Alpha' }],
|
||||
models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }],
|
||||
resolveModelInfo: provider => provider === 'alpha'
|
||||
? Promise.resolve({ context: { contextWindow: 64_000 } })
|
||||
: Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')),
|
||||
},
|
||||
})
|
||||
await tick()
|
||||
// Switching the model re-resolves and clears the deferred wait, so the
|
||||
// stale route's adapter arriving afterwards must be a no-op.
|
||||
result.terminal.send('/model alpha/a1')
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('Model selected: alpha/a1')
|
||||
})
|
||||
result.ctx.emit('llm/adapters-updated')
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('Could not resolve model context')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('does not render a model catalog that resolves after TUI disposal', async () => {
|
||||
const deferred = Promise.withResolvers<never[]>()
|
||||
const result = await setup({
|
||||
@@ -4387,6 +4478,20 @@ describe('tool cards and surface replay', () => {
|
||||
presentCall: () => ({ card: 'generic', title: 'Becomes terminal' }),
|
||||
presentResult: () => ({ card: 'terminal', output: 'converted terminal' }),
|
||||
},
|
||||
// A search card carries no result text of its own; the TUI has no dedicated
|
||||
// search arm and falls back to the raw result content, rendered as the same
|
||||
// dim generic body a pre-search-card grep/glob result showed.
|
||||
search: {
|
||||
name: 'search', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'Grep todo', kind: 'search' }),
|
||||
presentResult: () => ({
|
||||
card: 'search',
|
||||
shape: 'matches',
|
||||
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'todo one' }] }],
|
||||
truncated: false,
|
||||
total: 1,
|
||||
}),
|
||||
},
|
||||
symbolic: {
|
||||
name: 'symbolic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'Symbol input', rawInput: Symbol('input') }),
|
||||
@@ -4424,6 +4529,7 @@ describe('tool cards and surface replay', () => {
|
||||
['c12', 'symbolic', '{}'],
|
||||
['c13', 'knownXml', '{}'],
|
||||
['c16', 'webCard', '{}'],
|
||||
['c17', 'search', '{"pattern":"todo"}'],
|
||||
] as const
|
||||
appendAssistant(result.session, [
|
||||
{ type: 'text', text: 'Calling tools' },
|
||||
@@ -4525,6 +4631,14 @@ describe('tool cards and surface replay', () => {
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: 'c17' as never,
|
||||
content: [{ type: 'text', text: 'Found 1 match\n\na.ts\nLine 1: todo one' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
@@ -4556,6 +4670,11 @@ describe('tool cards and surface replay', () => {
|
||||
expect(output).toContain('$ blank desc command')
|
||||
// A card whose title only repeats the name renders header-only (empty body).
|
||||
expect(output).toContain('Tool / emptyBody')
|
||||
// A search result view carries no `content` of its own, so the card renders
|
||||
// the raw model-facing result text through the same dim generic body — the
|
||||
// TUI has no dedicated search arm.
|
||||
expect(output).toContain('Tool / search')
|
||||
expect(output).toContain('Line 1: todo one')
|
||||
// A diff card drops its title (the paths + change footer carry the meaning).
|
||||
// The first file's path is head-visible; the second file and the change
|
||||
// footer sit past this card's 4-line budget and appear only when expanded.
|
||||
|
||||
Reference in New Issue
Block a user