diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5886e15b1e..b0882b7847 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -245,7 +245,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:604`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:607`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/rfc/implemented/simplification/2026-07-12-simplify-session-log-representation.md b/docs/rfc/implemented/simplification/2026-07-12-simplify-session-log-representation.md index a1dfb5eafb..dc51986e79 100644 --- a/docs/rfc/implemented/simplification/2026-07-12-simplify-session-log-representation.md +++ b/docs/rfc/implemented/simplification/2026-07-12-simplify-session-log-representation.md @@ -10,7 +10,7 @@ The session log maintains two representations that cost more machinery than thei The request-header subsystem implements a custom system/tool delta codec and transmission-decision layer even though its contract says deltas are an encoding optimization, not a reconstructability requirement. Retaining the initial/resume full snapshot at each loop-instance boundary, then writing a canonical full `request/header` whenever that instance's assembled header changes, preserves replay while deleting `SystemDelta`, `ToolsDelta`, round-trip fallback, and the durable `request/header-delta` variant. Codec-only vocabulary disappears with the codec, not because its individual arms were invalid. -This proposal deliberately retains append and replacement `sourceEventSeqs`, crash-repair provenance, and all `SessionStartSource` variants: implemented RFCs give those fields an audit/interception role that zero current readers does not overturn. +The implementation retains append and replacement `sourceEventSeqs`, crash-repair provenance, and all `SessionStartSource` variants because those fields have an audit/interception role that zero current readers does not overturn. ## Decision diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index a1ddbeccc6..a0bfc9d943 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, readFile } from 'node:fs/promises' +import { mkdtemp, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -9,6 +9,7 @@ import { type AgentUnderTest, type LaunchedAcpTestAgent, } from '@deepseek-ai/dsh-acp-snapshot' +import { cleanupAcpExampleTest } from './cleanup.ts' /** * End-to-end: boot examples/acp-agent as a real subprocess speaking ACP over @@ -31,16 +32,11 @@ let spawned: LaunchedAcpTestAgent | undefined let workdir: string | undefined afterEach(async () => { - try { - await spawned?.close('SIGKILL') - } finally { - spawned = undefined - try { - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - } finally { - workdir = undefined - } - } + const ownedSpawned = spawned + const ownedWorkdir = workdir + spawned = undefined + workdir = undefined + await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir) }) describe('acp-agent over real stdio (no key required)', () => { diff --git a/examples/acp-agent/tests/cleanup.e2e.ts b/examples/acp-agent/tests/cleanup.e2e.ts new file mode 100644 index 0000000000..1f6e6493e0 --- /dev/null +++ b/examples/acp-agent/tests/cleanup.e2e.ts @@ -0,0 +1,38 @@ +/** Regression coverage for ACP example teardown. */ + +import { access, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanupAcpExampleTest } from './cleanup.ts' + +let fallbackWorkdir: string | undefined + +afterEach(async () => { + if (fallbackWorkdir !== undefined) await rm(fallbackWorkdir, { recursive: true, force: true }) + fallbackWorkdir = undefined +}) + +describe('cleanupAcpExampleTest', () => { + it('removes the workspace after process shutdown fails', async () => { + fallbackWorkdir = await mkdtemp(join(tmpdir(), 'acp-cleanup-')) + const closeFailure = new Error('close failed') + const spawned = { close: vi.fn().mockRejectedValue(closeFailure) } + + await expect(cleanupAcpExampleTest(spawned, fallbackWorkdir)) + .rejects.toMatchObject({ errors: [closeFailure] }) + await expect(access(fallbackWorkdir)).rejects.toThrow() + fallbackWorkdir = undefined + }) + + it('reports process and workspace failures together', async () => { + const closeFailure = new Error('close failed') + const spawned = { close: vi.fn().mockRejectedValue(closeFailure) } + + const failure = await cleanupAcpExampleTest(spawned, '\0').catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).errors).toHaveLength(2) + expect((failure as AggregateError).errors[0]).toBe(closeFailure) + }) +}) diff --git a/examples/acp-agent/tests/cleanup.ts b/examples/acp-agent/tests/cleanup.ts new file mode 100644 index 0000000000..28a896334a --- /dev/null +++ b/examples/acp-agent/tests/cleanup.ts @@ -0,0 +1,23 @@ +/** Shared teardown for ACP example tests. */ + +import { rm } from 'node:fs/promises' +import type { LaunchedAcpTestAgent } from '@deepseek-ai/dsh-acp-snapshot' + +/** + * Close the test agent, then remove its workspace, attempting both operations + * and reporting every failure instead of allowing the later one to mask the + * earlier one. + */ +export async function cleanupAcpExampleTest( + spawned: Pick | undefined, + workdir: string | undefined, +): Promise { + const results: PromiseSettledResult[] = [] + if (spawned !== undefined) results.push(...await Promise.allSettled([spawned.close('SIGKILL')])) + if (workdir !== undefined) results.push(...await Promise.allSettled([rm(workdir, { recursive: true, force: true })])) + + const failures = results + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map(result => result.reason as unknown) + if (failures.length > 0) throw new AggregateError(failures, 'ACP example cleanup failed') +} diff --git a/examples/acp-agent/tests/escalation.e2e.ts b/examples/acp-agent/tests/escalation.e2e.ts index da8d3408c2..3b84dd721c 100644 --- a/examples/acp-agent/tests/escalation.e2e.ts +++ b/examples/acp-agent/tests/escalation.e2e.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process' -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdtemp, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -13,6 +13,7 @@ import { type AgentUnderTest, type LaunchedAcpTestAgent, } from '@deepseek-ai/dsh-acp-snapshot' +import { cleanupAcpExampleTest } from './cleanup.ts' /** * The default ACP composition (`cordis.yml`) end to end. @@ -81,16 +82,11 @@ let spawned: Spawned | undefined let workdir: string | undefined afterEach(async () => { - try { - await spawned?.close('SIGKILL') - } finally { - spawned = undefined - try { - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - } finally { - workdir = undefined - } - } + const ownedSpawned = spawned + const ownedWorkdir = workdir + spawned = undefined + workdir = undefined + await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir) }) describe('default sandbox composition keyless smoke (real cordis.yml via the Loader)', () => { diff --git a/examples/acp-agent/tests/hooks.e2e.ts b/examples/acp-agent/tests/hooks.e2e.ts index df231184c3..dbe98ab358 100644 --- a/examples/acp-agent/tests/hooks.e2e.ts +++ b/examples/acp-agent/tests/hooks.e2e.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile, access } from 'node:fs/promises' +import { mkdtemp, writeFile, access } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -9,6 +9,7 @@ import { type AgentUnderTest, type LaunchedAcpTestAgent, } from '@deepseek-ai/dsh-acp-snapshot' +import { cleanupAcpExampleTest } from './cleanup.ts' /** * With-key e2e: the Claude Code hook bridge running against the REAL acp-agent @@ -38,16 +39,11 @@ let spawned: LaunchedAcpTestAgent | undefined let workdir: string | undefined afterEach(async () => { - try { - await spawned?.close('SIGKILL') - } finally { - spawned = undefined - try { - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - } finally { - workdir = undefined - } - } + const ownedSpawned = spawned + const ownedWorkdir = workdir + spawned = undefined + workdir = undefined + await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir) }) describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook blocks bash (real model)', () => { diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 11451811c3..2850286cde 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -214,6 +214,9 @@ function assertSessionEventEnvelope(value: Record, index: numbe /** Reject request-header vocabulary removed with the legacy delta codec. */ function assertSupportedRequestHeader(type: string, data: unknown, location: string): void { + if (type === 'request/header-delta') { + throw new Error(`${location} uses unsupported legacy request/header-delta format`) + } if (type === 'request/header' && data !== null && typeof data === 'object' && !Array.isArray(data) && (data as Record)['reason'] === 'fallback') { diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index 8bc2a4bf6c..da84ea239c 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -62,11 +62,17 @@ describe('foldRequestHeader', () => { }) describe('legacy request-header format', () => { - it('rejects a v0 seed containing request/header-delta', () => { + it('rejects request/header-delta in seeds and untyped appends', () => { const legacy = [{ type: 'request/header-delta', seq: 0, time: 1, data: { config: CONFIG }, }] as unknown as SessionEvent[] expect(() => new Session(SessionId('legacy'), legacy)).toThrow(/unsupported legacy request\/header-delta/) + + const session = new Session(SessionId('legacy-append-delta')) + const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent + expect(() => appendLegacy('request/header-delta', { config: CONFIG })) + .toThrow(/unsupported legacy request\/header-delta/) + expect(session.events).toHaveLength(0) }) it('rejects the removed fallback reason in seeds and untyped appends', () => { diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 5d0ba2c607..f8ea43fd05 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -185,7 +185,7 @@ describe('SessionPersistence service registration', () => { await fiber.dispose() }) - it('rejects a legacy header delta buffered by a pre-change live producer', async () => { + it('rejects a legacy header delta from a pre-change live producer', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const fiber = await ctx.plugin(MemoryPersistence) @@ -193,10 +193,9 @@ describe('SessionPersistence service registration', () => { // Model the runtime shape available to JavaScript or a hot-loaded plugin // compiled against the obsolete event vocabulary. const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent - appendLegacy('request/header-delta', { config: { model: 'legacy' } }) - - await expect(ctx.sessions.flush(session)) - .rejects.toThrow(/unsupported legacy request\/header-delta event at seq 0/) + expect(() => appendLegacy('request/header-delta', { config: { model: 'legacy' } })) + .toThrow(/unsupported legacy request\/header-delta format/) + expect(session.events).toHaveLength(0) await fiber.dispose() })