The TUI's referenced-prompt snapshot now rides the prompt's own admission transaction instead of a pre-admission inject: while idle, a one-shot prepended agent/prompt-submit wrapper appends the snapshot to the allow decision's additionalContexts, so a blocking hook discards the prompt and its attached context together instead of stranding the snapshot in history for the next unrelated prompt. A prompt discarded before admission releases the wrapper; steering keeps the inject path since it bypasses admission and drains at the same boundary. The session-reference snapshot adapter pinned the old context-before-prompt order; the branch-wide order (prompt first, its contexts after) is now asserted and the fixture re-recorded. tool-tasks drops the last consumer of the removed thrown-disposed contract: completion notices now inject unconditionally, which is well-defined during owner teardown — the loop treats disposal like any cancel, so the notice appends as durable idle context (persisted for resume while the session is attached, dropped with the detached log after). README pair and the owner-disposal tests state the new delivery contract.
143 lines
6.1 KiB
TypeScript
143 lines
6.1 KiB
TypeScript
import { mkdir, writeFile } from 'node:fs/promises'
|
|
import { dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import LlmService, { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
|
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 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'
|
|
|
|
class SnapshotAdapter extends LlmAdapter {
|
|
readonly requests: GenerateOptions[] = []
|
|
|
|
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
this.requests.push(options)
|
|
// The snapshot rides the prompt's admission: the loop appends the
|
|
// prompt first, then its additional contexts (the branch-wide ordering
|
|
// for plugin-sourced context).
|
|
const [prompt, context] = options.messages.slice(-2)
|
|
if (context?.role !== 'user' || prompt?.role !== 'user'
|
|
|| prompt.content[0]?.type !== 'text' || prompt.content[0].text !== 'Use @Source session') {
|
|
throw new Error('session reference context did not follow the direct user message')
|
|
}
|
|
yield { type: 'block-start', index: 0, blockType: 'text' }
|
|
yield { type: 'text-delta', index: 0, text: 'Combined reference request accepted.' }
|
|
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Combined reference request accepted.' } }
|
|
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
}
|
|
}
|
|
|
|
function nextIdle(ctx: Context, agent: Agent): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
const dispose = ctx.on('agent/status', (subject, status) => {
|
|
if (subject !== agent || status !== 'idle') return
|
|
dispose()
|
|
resolve()
|
|
})
|
|
})
|
|
}
|
|
|
|
describe('TUI session-reference snapshot', () => {
|
|
it('snapshots compacted current-surface context on send and displays only its reference card', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(SystemPrompt)
|
|
await ctx.plugin(ToolRegistry)
|
|
await ctx.plugin(AgentRegistry)
|
|
await ctx.plugin(CommandService)
|
|
await ctx.plugin(UserInteractionService)
|
|
await ctx.plugin(AgentLoop, { agents: [] })
|
|
await ctx.plugin(TestSessionQueryService)
|
|
await ctx.plugin(SessionReferenceService)
|
|
|
|
const adapter = new SnapshotAdapter()
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
const source = ctx.sessions.create(SessionId('source-session'), { meta: { cwd: '/workspace/project', createdAt: 1 } })
|
|
const oldUser = source.append('user/message', {
|
|
content: [{ type: 'text', text: 'SHADOWED OLD USER' }],
|
|
source: { kind: 'user' },
|
|
}, { surfaceOp: 'append' })
|
|
const oldAssistant = source.append('assistant/message', {
|
|
turn: 1,
|
|
step: 1,
|
|
provenance: { provider: 'mock', model: 'mock' },
|
|
content: [{ type: 'text', text: 'SHADOWED OLD ASSISTANT' }],
|
|
}, { surfaceOp: 'append' })
|
|
source.append('user/message', {
|
|
content: [{ type: 'text', text: '<compacted-summary>Retained checkpoint.</compacted-summary>' }],
|
|
source: { kind: 'plugin', plugin: 'compact' },
|
|
}, {
|
|
surfaceOp: { op: 'replace', start: oldUser.seq, end: oldAssistant.seq },
|
|
sourceEventSeqs: [oldUser.seq, oldAssistant.seq],
|
|
})
|
|
source.append('user/message', {
|
|
content: [{ type: 'text', text: 'Recent retained question.' }],
|
|
source: { kind: 'user' },
|
|
}, { surfaceOp: 'append' })
|
|
|
|
const target = ctx.agentLoop.create(
|
|
SessionId('target-session'),
|
|
{ provider: 'mock', model: 'mock' },
|
|
{ cwd: '/workspace/project' },
|
|
)
|
|
const terminal = new HeadlessTerminal(96, 24)
|
|
const controller = createTuiChat(ctx, {
|
|
sessionId: target.id,
|
|
welcome: 'Session reference snapshot.',
|
|
color: true,
|
|
title: 'DSH session reference',
|
|
}, { terminal, exit: () => {} })
|
|
await terminal.waitForFrame(0)
|
|
|
|
const mention = formatSessionReferenceMention({ sessionId: source.id, label: 'Source session' })
|
|
const idle = nextIdle(ctx, target)
|
|
const frame = terminal.frames
|
|
terminal.send(`Use ${mention}`)
|
|
terminal.send('\r')
|
|
await idle
|
|
await terminal.waitForFrame(frame)
|
|
|
|
const request = JSON.stringify(adapter.requests[0]?.messages)
|
|
expect(request).toContain('untrusted, read-only snapshot')
|
|
expect(request).toContain('Retained checkpoint.')
|
|
expect(request).toContain('Recent retained question.')
|
|
expect(request).not.toContain('SHADOWED OLD USER')
|
|
expect(request).not.toContain('SHADOWED OLD ASSISTANT')
|
|
const context = target.session.events.find(event =>
|
|
event.type === 'user/message' && event.data.source.kind === 'session-reference')
|
|
expect(context?.type === 'user/message' && context.data.source).toMatchObject({
|
|
kind: 'session-reference',
|
|
references: [{ sessionId: 'source-session', compacted: true }],
|
|
})
|
|
const user = target.session.events.find(event =>
|
|
event.type === 'user/message' && event.data.source.kind === 'user')
|
|
expect(user?.type === 'user/message' && user.data.content).toEqual([
|
|
{ type: 'text', text: 'Use @Source session' },
|
|
])
|
|
|
|
const snapshot = await terminal.snapshot({ includeScrollback: true })
|
|
if (REFRESHING) {
|
|
await mkdir(dirname(EXPECTED), { recursive: true })
|
|
await writeFile(EXPECTED, snapshot)
|
|
}
|
|
await expect(snapshot).toMatchFileSnapshot(EXPECTED)
|
|
|
|
await controller.dispose()
|
|
await ctx.fiber.dispose()
|
|
await terminal.dispose()
|
|
})
|
|
})
|