236 lines
9.7 KiB
TypeScript
236 lines
9.7 KiB
TypeScript
/**
|
|
* Assembled-app regression for persisted workspace-instruction resume state.
|
|
* @module workspace-context-resume-snapshot
|
|
*/
|
|
|
|
import { createHash } from 'node:crypto'
|
|
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
|
import { dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { Context } from 'cordis'
|
|
import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot'
|
|
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
import SessionStore, {
|
|
SESSION_FORMAT_VERSION,
|
|
SessionId,
|
|
type SessionEvent,
|
|
type SessionHeader,
|
|
} from '@deepseek-ai/dsh-session'
|
|
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
|
import { renderWorkspaceContext } from '@deepseek-ai/dsh-workspace-context'
|
|
import { resolveConfig, workspaceBaselineIdentity } from '@deepseek-ai/dsh-workspace-context/src/config.ts'
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), 'workspace-context-resume-snapshots/offline-edit')
|
|
const replayFixture = join(fixtureDir, 'replay.jsonl')
|
|
const replayOverride = join(fixtureDir, 'replay.override.json')
|
|
const sessionExpected = join(fixtureDir, 'session.expected.jsonl')
|
|
const precedenceExpected = join(dirname(fixtureDir), 'precedence-change/session.expected.jsonl')
|
|
const configPath = fileURLToPath(new URL('../workspace-context-resume.cordis.snapshot.yml', import.meta.url))
|
|
const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url))
|
|
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
|
const sessionId = SessionId('workspace-context-resume')
|
|
const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
|
|
const oldInstruction = 'Old workspace instruction.'
|
|
const newInstruction = 'New workspace instruction after offline edit.'
|
|
|
|
interface SeedBaselineOptions {
|
|
files?: Array<{ name: string; content: string }>
|
|
instructionFileCandidates?: string[]
|
|
}
|
|
|
|
async function seedVisibleBaseline(
|
|
root: string,
|
|
cwd: string,
|
|
options: SeedBaselineOptions = {},
|
|
): Promise<string> {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
|
const meta: SessionHeader = {
|
|
version: SESSION_FORMAT_VERSION,
|
|
id: sessionId,
|
|
createdAt: 1,
|
|
cwd,
|
|
delegationDepth: 0,
|
|
}
|
|
const files = options.files ?? [{ name: 'AGENTS.md', content: oldInstruction }]
|
|
const baseline = renderWorkspaceContext(files.map(file => ({
|
|
absolutePath: join(cwd, file.name),
|
|
displayPath: file.name,
|
|
content: file.content,
|
|
})), { maxBytes: 65536 })
|
|
const config = resolveConfig({
|
|
dshHome: join(cwd, '.dsh'),
|
|
maxBytes: 65536,
|
|
...options.instructionFileCandidates === undefined
|
|
? {}
|
|
: { instructionFileCandidates: options.instructionFileCandidates },
|
|
})
|
|
const events: SessionEvent[] = [
|
|
{ type: 'turn/start', seq: 0, time: 10, data: { turn: 1 } },
|
|
{
|
|
type: 'user/message',
|
|
seq: 1,
|
|
time: 11,
|
|
data: createUserMessage({ content: [{ type: 'text', text: 'Remember the workspace instruction.' }], source: { kind: 'user' } }),
|
|
surfaceOp: 'append',
|
|
},
|
|
{
|
|
type: 'user/message',
|
|
seq: 2,
|
|
time: 12,
|
|
data: createUserMessage({
|
|
content: [{ type: 'text', text: baseline.text }],
|
|
source: {
|
|
kind: 'workspace-instructions',
|
|
form: 'instructions',
|
|
baseline: true,
|
|
baselineIdentity: workspaceBaselineIdentity(config, cwd, cwd),
|
|
changes: files.map(file => ({
|
|
action: 'set',
|
|
scope: `.\0${file.name}`,
|
|
path: file.name,
|
|
digest: createHash('sha1').update(file.content).digest('hex'),
|
|
})),
|
|
},
|
|
}),
|
|
surfaceOp: 'append',
|
|
},
|
|
{ type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'completed' } } },
|
|
]
|
|
try {
|
|
await ctx.sessionPersistence.create(meta)
|
|
await ctx.sessionPersistence.append(sessionId, events)
|
|
const location = ctx.sessionPersistence.locate(meta)
|
|
if (location === undefined) throw new Error('JSONL backend did not locate the seeded session')
|
|
return location.path
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
}
|
|
}
|
|
|
|
describe('workspace-context resume snapshot', () => {
|
|
it('appends an offline replacement without duplicating the visible baseline', async () => {
|
|
let cwd = ''
|
|
let sessionPath = ''
|
|
const result = await runLoaderSmoke({
|
|
label: 'workspace-context resume headless stream-json snapshot',
|
|
tempDirPrefix: 'dsh-workspace-context-resume-',
|
|
binScript,
|
|
libBinScript: binScript,
|
|
configPath,
|
|
binArgs: [configPath, 'Acknowledge the current workspace instruction.'],
|
|
tsconfigPath,
|
|
env: {
|
|
DSH_SNAPSHOT_FILE: replayFixture,
|
|
DSH_SNAPSHOT_OVERRIDE: replayOverride,
|
|
},
|
|
prepare: async (runCwd) => {
|
|
cwd = runCwd
|
|
await mkdir(join(runCwd, '.git'), { recursive: true })
|
|
await writeFile(join(runCwd, 'AGENTS.md'), `${newInstruction}\n`)
|
|
sessionPath = await seedVisibleBaseline(join(runCwd, '.sessions'), runCwd)
|
|
},
|
|
inspect: async () => {
|
|
const normalization: NormalizeContext = { sessionIds: [sessionId], cwd }
|
|
const session = scrubRequestHeaders(normalizeSessionLog(await readFile(sessionPath, 'utf8'), normalization))
|
|
if (refreshing) await writeFile(sessionExpected, session)
|
|
expect(session).toBe(await readFile(sessionExpected, 'utf8'))
|
|
|
|
const records = session.trimEnd().split('\n').map(line => JSON.parse(line) as {
|
|
type?: string
|
|
data?: {
|
|
source?: { kind?: string; baseline?: boolean; changes?: Array<Record<string, unknown>> }
|
|
content?: Array<{ type?: string; text?: string }>
|
|
}
|
|
})
|
|
const workspaceEvents = records.filter(record => record.type === 'user/message'
|
|
&& record.data?.source?.kind === 'workspace-instructions')
|
|
expect(workspaceEvents.filter(record => record.data?.source?.baseline === true)).toHaveLength(1)
|
|
expect(workspaceEvents.filter(record => record.data?.source?.baseline !== true)).toHaveLength(1)
|
|
expect(workspaceEvents.at(-1)?.data?.source?.changes).toMatchObject([{
|
|
action: 'replace', scope: '.\0AGENTS.md', path: 'AGENTS.md',
|
|
}])
|
|
expect(JSON.stringify(workspaceEvents.at(-1)?.data?.content)).toContain(newInstruction)
|
|
|
|
const files = await readdir(join(cwd, '.sessions'), { recursive: true })
|
|
expect(files.filter(file => file.endsWith('.jsonl'))).toHaveLength(1)
|
|
},
|
|
})
|
|
|
|
expect(result.stderr).toBe('')
|
|
const records = result.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
|
|
expect(records.at(-1)).toMatchObject({
|
|
type: 'result',
|
|
sessionId,
|
|
output: 'RESUME_DONE',
|
|
})
|
|
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
|
|
|
it('supersedes an incompatible baseline when precedence changed offline', async () => {
|
|
let cwd = ''
|
|
let sessionPath = ''
|
|
const result = await runLoaderSmoke({
|
|
label: 'workspace-context precedence-change resume snapshot',
|
|
tempDirPrefix: 'dsh-workspace-context-precedence-',
|
|
binScript,
|
|
libBinScript: binScript,
|
|
configPath,
|
|
binArgs: [configPath, 'Acknowledge the current workspace instruction.'],
|
|
tsconfigPath,
|
|
env: {
|
|
DSH_SNAPSHOT_FILE: replayFixture,
|
|
DSH_SNAPSHOT_OVERRIDE: replayOverride,
|
|
},
|
|
prepare: async (runCwd) => {
|
|
cwd = runCwd
|
|
await mkdir(join(runCwd, '.git'), { recursive: true })
|
|
await writeFile(join(runCwd, 'AGENTS.md'), 'Current AGENTS rule.\n')
|
|
await writeFile(join(runCwd, 'CLAUDE.md'), 'Current CLAUDE rule.\n')
|
|
sessionPath = await seedVisibleBaseline(join(runCwd, '.sessions'), runCwd, {
|
|
files: [
|
|
{ name: 'CLAUDE.md', content: 'Old CLAUDE rule.' },
|
|
{ name: 'AGENTS.md', content: 'Old AGENTS rule.' },
|
|
],
|
|
instructionFileCandidates: ['CLAUDE.md', 'AGENTS.md'],
|
|
})
|
|
},
|
|
inspect: async () => {
|
|
const normalization: NormalizeContext = { sessionIds: [sessionId], cwd }
|
|
const session = scrubRequestHeaders(normalizeSessionLog(await readFile(sessionPath, 'utf8'), normalization))
|
|
if (refreshing) {
|
|
await mkdir(dirname(precedenceExpected), { recursive: true })
|
|
await writeFile(precedenceExpected, session)
|
|
}
|
|
expect(session).toBe(await readFile(precedenceExpected, 'utf8'))
|
|
|
|
const records = session.trimEnd().split('\n').map(line => JSON.parse(line) as {
|
|
type?: string
|
|
data?: {
|
|
source?: { kind?: string; baseline?: boolean }
|
|
content?: Array<{ type?: string; text?: string }>
|
|
}
|
|
})
|
|
const baselines = records.filter(record => record.type === 'user/message'
|
|
&& record.data?.source?.kind === 'workspace-instructions'
|
|
&& record.data.source.baseline === true)
|
|
expect(baselines).toHaveLength(2)
|
|
const replacement = JSON.stringify(baselines.at(-1)?.data?.content)
|
|
expect(replacement).toContain('replaces all earlier workspace instruction baselines')
|
|
expect(replacement.indexOf('Instructions from: AGENTS.md'))
|
|
.toBeLessThan(replacement.indexOf('Instructions from: CLAUDE.md'))
|
|
},
|
|
})
|
|
|
|
expect(result.stderr).toBe('')
|
|
expect(result.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>).at(-1))
|
|
.toMatchObject({
|
|
type: 'result',
|
|
sessionId,
|
|
output: 'RESUME_DONE',
|
|
})
|
|
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
|
})
|