mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
A claimed slash command consumed only the text half of the composer submission: /goal with reference images executed, cleared the draft, and silently stranded the images in the rail. Model-visible attachment intent had no route through the command plane. The submission envelope is now modeled end to end. CommandDefinition input.images declares acceptance; the declaration rides the descriptor to every client, onto the minted CommandClaim, and into the input machine's claim snapshot. commands.execute carries the submission's base64 images and enforces the declaration in the executor: non-declaring commands, a missing attachment store, and exceeded batch limits settle as logged error results before the handler runs. Admission reuses the attachment package's new admitEncodedImages, extracted from api-proxy's prompt path so both wire endpoints share one limits/validation/commit sequence. Producers own model visibility: /goal submits one user followup (image blocks + a fixed reference line) after a successful create/edit so goal rounds read the images from session history; /plan folds them into its steered message. Grammar misfits (/goal pause, bare /plan, /plan off) return direct errors and the composer keeps the images. On the client, enter adjudication carries a SubmitEnvelope and every command route that cannot consume images throws a localized refusal that renders as one composer notice with draft and images retained; the claimed pre-gate applies the same copy. An accepting claim serializes the draft images, forwards them to commands.execute, and clears plus releases them only on a success outcome. The assembled web test roster gains the ui-input-trigger and ui-commands plugins, mirroring the shipped composition, so slash submissions exercise the command plane; a new keyless snapshot pins the refusal banner and the accepting /goal flow over the built client graph.
281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
import CommandRuntime, { type CommandResult } from '@deepseek-ai/dsh-commands'
|
|
import {
|
|
CompactionId,
|
|
CompactionEngine,
|
|
ManualCompactionError,
|
|
type CompactionAgentContext,
|
|
type CompactionResult,
|
|
type CompactionTrigger,
|
|
type ManualCompactAgentContext,
|
|
} from '@deepseek-ai/dsh-compaction'
|
|
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
|
import * as commandCompact from '@deepseek-ai/dsh-command-compact'
|
|
|
|
const COMPACTION_ID = CompactionId('command-compact-test')
|
|
|
|
const RESULT: CompactionResult = {
|
|
compactionId: COMPACTION_ID,
|
|
startSeq: 1,
|
|
summarySeq: 2,
|
|
endSeq: 3,
|
|
summary: [{ type: 'text', text: 'summary' }],
|
|
shadowedRange: { start: 1, end: 7 },
|
|
shadowedSeqs: [1, 3, 7],
|
|
shadowedTokenCount: 42,
|
|
}
|
|
|
|
class StubCompactionEngine extends CompactionEngine {
|
|
result: CompactionResult | null = RESULT
|
|
failure: unknown
|
|
operation: (() => Promise<CompactionResult | null>) | undefined
|
|
calls: { agent: ManualCompactAgentContext; signal: AbortSignal }[] = []
|
|
|
|
override compactIfNeeded(
|
|
_agent: CompactionAgentContext,
|
|
_trigger: CompactionTrigger,
|
|
_signal: AbortSignal,
|
|
): Promise<CompactionResult | null> {
|
|
return Promise.resolve(null)
|
|
}
|
|
|
|
override compactRegion(): Promise<CompactionResult> {
|
|
return Promise.resolve(RESULT)
|
|
}
|
|
|
|
override compactNow(
|
|
agent: ManualCompactAgentContext,
|
|
signal: AbortSignal,
|
|
sourceCommandId?: Parameters<CompactionEngine['compactNow']>[2],
|
|
): Promise<CompactionResult | null> {
|
|
this.calls.push({ agent, signal })
|
|
if (this.operation !== undefined) return this.operation()
|
|
return this.failure === undefined
|
|
? Promise.resolve(this.result === null ? null : this.appendResult(agent, this.result, sourceCommandId))
|
|
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise arbitrary backend rejection values.
|
|
: Promise.reject(this.failure)
|
|
}
|
|
|
|
private appendResult(
|
|
agent: ManualCompactAgentContext,
|
|
result: CompactionResult,
|
|
sourceCommandId: Parameters<CompactionEngine['compactNow']>[2],
|
|
): CompactionResult {
|
|
const provenance = {
|
|
compactionId: result.compactionId,
|
|
...sourceCommandId === undefined ? {} : { sourceCommandId },
|
|
}
|
|
agent.session.append('compaction/start', { ...provenance, turn: null })
|
|
agent.session.append('compaction/summary', {
|
|
...provenance,
|
|
summary: result.summary,
|
|
shadowedRange: result.shadowedRange,
|
|
shadowedSeqs: result.shadowedSeqs,
|
|
shadowedTokenCount: result.shadowedTokenCount,
|
|
provider: 'command-test',
|
|
model: 'command-test',
|
|
})
|
|
agent.session.append('compaction/end', { ...provenance, turn: null })
|
|
return { ...result, ...provenance }
|
|
}
|
|
}
|
|
|
|
interface Harness {
|
|
readonly ctx: Context
|
|
readonly compact: StubCompactionEngine
|
|
readonly agent: Agent
|
|
readonly plugin: Awaited<ReturnType<Context['plugin']>>
|
|
}
|
|
|
|
async function harness(): Promise<Harness> {
|
|
const ctx = new Context()
|
|
await ctx.plugin(CommandRuntime)
|
|
const compact = new StubCompactionEngine(ctx)
|
|
const plugin = await ctx.plugin(commandCompact)
|
|
const session = Session.create(SessionId('command-compact'))
|
|
const agent = {
|
|
session,
|
|
status: 'idle',
|
|
options: {},
|
|
reserveTurnAdmission: () => () => undefined,
|
|
} as unknown as Agent
|
|
return { ctx, compact, agent, plugin }
|
|
}
|
|
|
|
async function run(
|
|
test: Harness,
|
|
suffix = '',
|
|
controller = new AbortController(),
|
|
): Promise<NonNullable<Awaited<ReturnType<CommandRuntime['execute']>>>> {
|
|
const execution = await test.ctx.commands.execute(test.agent, `/compact${suffix}`, [], controller.signal)
|
|
if (execution === undefined) throw new Error('compact command was not registered')
|
|
return execution
|
|
}
|
|
|
|
/** Assert the executor-owned lifecycle pair and absence from model history. */
|
|
function expectLastLifecycle(
|
|
test: Harness,
|
|
args: string,
|
|
outcome: CommandResult,
|
|
): string {
|
|
const lifecycle = test.agent.session.events
|
|
.filter(event => event.type === 'command/run' || event.type === 'command/done')
|
|
.slice(-2)
|
|
const runEvent = lifecycle[0]
|
|
const doneEvent = lifecycle[1]
|
|
if (runEvent?.type !== 'command/run' || doneEvent?.type !== 'command/done') {
|
|
throw new Error(`expected command lifecycle pair, got ${lifecycle.map(event => event.type).join(',')}`)
|
|
}
|
|
expect(lifecycle.map(event => ({ type: event.type, data: event.data }))).toEqual([
|
|
{
|
|
type: 'command/run',
|
|
data: {
|
|
commandId: runEvent.data.commandId,
|
|
name: 'compact',
|
|
args,
|
|
source: { kind: 'user' },
|
|
},
|
|
},
|
|
{
|
|
type: 'command/done',
|
|
data: {
|
|
commandId: runEvent.data.commandId,
|
|
...outcome,
|
|
},
|
|
},
|
|
])
|
|
expect(doneEvent.data.commandId).toBe(runEvent.data.commandId)
|
|
expect(test.agent.session.surface.nodes).toEqual([])
|
|
expect(test.agent.session.deriveMessages()).toEqual([])
|
|
return runEvent.data.commandId
|
|
}
|
|
|
|
describe('@deepseek-ai/dsh-command-compact registration', () => {
|
|
it('registers one argument-free command with Loader-safe exports and disposes it', async () => {
|
|
const test = await harness()
|
|
expect(commandCompact.name).toBe('command-compact')
|
|
expect(commandCompact.inject).toEqual(['commands', 'compaction'])
|
|
expect('default' in commandCompact).toBe(false)
|
|
const loader = Object.create(Loader.prototype) as Loader
|
|
expect(loader.unwrapExports(commandCompact)).toBe(commandCompact)
|
|
expect(test.ctx.commands.list(test.agent)).toContainEqual({
|
|
name: 'compact',
|
|
description: 'Compact older conversation history',
|
|
})
|
|
|
|
await test.plugin.dispose()
|
|
expect(test.ctx.commands.find(test.agent, 'compact')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('/compact human command', () => {
|
|
it('reports success with useful accounting and forwards the exact target and signal', async () => {
|
|
const test = await harness()
|
|
const controller = new AbortController()
|
|
const execution = await run(test, '', controller)
|
|
expect(execution.result).toEqual({
|
|
kind: 'success',
|
|
text: 'Compacted 3 history items (~42 tokens).',
|
|
sourceEventSeq: RESULT.summarySeq,
|
|
})
|
|
expect(execution.commandId).toBe(expectLastLifecycle(test, '', execution.result))
|
|
expect(test.compact.calls).toEqual([{ agent: test.agent, signal: controller.signal }])
|
|
})
|
|
|
|
it('returns direct no-history and argument-rejection results', async () => {
|
|
const test = await harness()
|
|
test.compact.result = null
|
|
const empty = await run(test)
|
|
expect(empty.result).toEqual({
|
|
kind: 'success',
|
|
text: 'No compactable history yet.',
|
|
})
|
|
expect(empty.commandId).toBe(expectLastLifecycle(test, '', empty.result))
|
|
|
|
const rejected = await run(test, ' now')
|
|
expect(rejected.result).toEqual({
|
|
kind: 'error',
|
|
text: 'Usage: /compact (no arguments)',
|
|
})
|
|
expect(rejected.commandId).toBe(expectLastLifecycle(test, ' now', rejected.result))
|
|
expect(test.compact.calls).toHaveLength(1)
|
|
})
|
|
|
|
it.each([
|
|
['busy', 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.'],
|
|
['cancelled', 'Compaction cancelled.'],
|
|
['changed', 'The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.'],
|
|
['summary', 'Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.'],
|
|
['commit', 'Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.'],
|
|
['persistence', 'Compaction finished, but the session could not be saved.'],
|
|
] as const)('maps expected %s failures to direct errors', async (code, text) => {
|
|
const test = await harness()
|
|
test.compact.failure = new ManualCompactionError(code, 'backend detail')
|
|
const execution = await run(test)
|
|
expect(execution.result).toEqual({ kind: 'error', text })
|
|
expect(execution.commandId).toBe(expectLastLifecycle(test, '', execution.result))
|
|
})
|
|
|
|
it('preserves cancellation and unexpected implementation failures', async () => {
|
|
const cancelled = await harness()
|
|
const controller = new AbortController()
|
|
const abort = new Error('operator cancelled')
|
|
cancelled.compact.operation = () => {
|
|
controller.abort(abort)
|
|
return Promise.reject(new ManualCompactionError('summary', 'late failure'))
|
|
}
|
|
await expect(run(cancelled, '', controller)).rejects.toBe(abort)
|
|
expectLastLifecycle(cancelled, '', { kind: 'error', text: abort.message })
|
|
|
|
const unexpected = await harness()
|
|
const bug = new Error('unexpected backend bug')
|
|
unexpected.compact.failure = bug
|
|
await expect(run(unexpected)).rejects.toBe(bug)
|
|
expectLastLifecycle(unexpected, '', { kind: 'error', text: bug.message })
|
|
})
|
|
|
|
it('drains an aborted handler through close and flush before plugin disposal settles', async () => {
|
|
const test = await harness()
|
|
const controller = new AbortController()
|
|
const abort = new Error('operator cancelled')
|
|
const started = Promise.withResolvers<undefined>()
|
|
const allowClose = Promise.withResolvers<undefined>()
|
|
const closed = Promise.withResolvers<undefined>()
|
|
const allowFlush = Promise.withResolvers<undefined>()
|
|
const flushed = Promise.withResolvers<undefined>()
|
|
test.compact.operation = async () => {
|
|
started.resolve(undefined)
|
|
await allowClose.promise
|
|
closed.resolve(undefined)
|
|
await allowFlush.promise
|
|
flushed.resolve(undefined)
|
|
throw abort
|
|
}
|
|
|
|
const execution = run(test, '', controller)
|
|
await started.promise
|
|
controller.abort(abort)
|
|
await expect(execution).rejects.toBe(abort)
|
|
|
|
let disposed = false
|
|
const disposal = test.plugin.dispose()
|
|
void disposal.then(() => { disposed = true })
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
expect(test.ctx.commands.find(test.agent, 'compact')).toBeUndefined()
|
|
expect(disposed).toBe(false)
|
|
|
|
allowClose.resolve(undefined)
|
|
await closed.promise
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
expect(disposed).toBe(false)
|
|
|
|
allowFlush.resolve(undefined)
|
|
await flushed.promise
|
|
await disposal
|
|
expect(disposed).toBe(true)
|
|
})
|
|
})
|