Merge branch 'codex/simp-ui-identity-residue' into codex/simp-hide-concrete-agent-loop
# Conflicts: # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/event-producer-consumer.md
This commit is contained in:
@@ -131,7 +131,7 @@ export interface Config {
|
|||||||
|
|
||||||
Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts)
|
Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts)
|
||||||
|
|
||||||
Source: [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts)
|
Source: [`packages/core/agent-loop/src/index.ts:360`](../packages/core/agent-loop/src/index.ts)
|
||||||
|
|
||||||
## `@deepseek-ai/dsh-bash-local`
|
## `@deepseek-ai/dsh-bash-local`
|
||||||
|
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers
|
|||||||
'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void
|
'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void
|
||||||
```
|
```
|
||||||
|
|
||||||
Source: [`packages/core/agent-loop/src/index.ts:348`](../../packages/core/agent-loop/src/index.ts)
|
Source: [`packages/core/agent-loop/src/index.ts:355`](../../packages/core/agent-loop/src/index.ts)
|
||||||
|
|
||||||
## `approval/*`
|
## `approval/*`
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandl
|
|||||||
|
|
||||||
Types: [Agent](../core-data-structures/core.md)
|
Types: [Agent](../core-data-structures/core.md)
|
||||||
|
|
||||||
Source: [`packages/core/agent-loop/src/index.ts:368`](../../packages/core/agent-loop/src/index.ts)
|
Source: [`packages/core/agent-loop/src/index.ts:375`](../../packages/core/agent-loop/src/index.ts)
|
||||||
|
|
||||||
## `ctx.agents` — `AgentRegistry`
|
## `ctx.agents` — `AgentRegistry`
|
||||||
|
|
||||||
@@ -249,7 +249,7 @@ list(): Session[]
|
|||||||
fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
|
fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
|
||||||
```
|
```
|
||||||
|
|
||||||
Source: [`packages/core/session/src/index.ts:609`](../../packages/core/session/src/index.ts)
|
Source: [`packages/core/session/src/index.ts:612`](../../packages/core/session/src/index.ts)
|
||||||
|
|
||||||
## `ctx.skills` — `SkillService`
|
## `ctx.skills` — `SkillService`
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
|||||||
|
|
||||||
| Event | Mode | Declared in | Dispatchers | Listeners |
|
| Event | Mode | Declared in | Dispatchers | Listeners |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:348`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:355`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:318`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:318`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:592`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:592`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||||
|
|||||||
+1
-1
@@ -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.
|
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
|
## Decision
|
||||||
|
|
||||||
|
|||||||
@@ -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 { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type AgentUnderTest,
|
type AgentUnderTest,
|
||||||
type LaunchedAcpTestAgent,
|
type LaunchedAcpTestAgent,
|
||||||
} from '@deepseek-ai/dsh-acp-snapshot'
|
} 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
|
* 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
|
let workdir: string | undefined
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
try {
|
const ownedSpawned = spawned
|
||||||
await spawned?.close('SIGKILL')
|
const ownedWorkdir = workdir
|
||||||
} finally {
|
spawned = undefined
|
||||||
spawned = undefined
|
workdir = undefined
|
||||||
try {
|
await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir)
|
||||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
|
||||||
} finally {
|
|
||||||
workdir = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('acp-agent over real stdio (no key required)', () => {
|
describe('acp-agent over real stdio (no key required)', () => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<LaunchedAcpTestAgent, 'close'> | undefined,
|
||||||
|
workdir: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
const results: PromiseSettledResult<unknown>[] = []
|
||||||
|
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')
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { spawnSync } from 'node:child_process'
|
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 { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type AgentUnderTest,
|
type AgentUnderTest,
|
||||||
type LaunchedAcpTestAgent,
|
type LaunchedAcpTestAgent,
|
||||||
} from '@deepseek-ai/dsh-acp-snapshot'
|
} from '@deepseek-ai/dsh-acp-snapshot'
|
||||||
|
import { cleanupAcpExampleTest } from './cleanup.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default ACP composition (`cordis.yml`) end to end.
|
* The default ACP composition (`cordis.yml`) end to end.
|
||||||
@@ -81,16 +82,11 @@ let spawned: Spawned | undefined
|
|||||||
let workdir: string | undefined
|
let workdir: string | undefined
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
try {
|
const ownedSpawned = spawned
|
||||||
await spawned?.close('SIGKILL')
|
const ownedWorkdir = workdir
|
||||||
} finally {
|
spawned = undefined
|
||||||
spawned = undefined
|
workdir = undefined
|
||||||
try {
|
await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir)
|
||||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
|
||||||
} finally {
|
|
||||||
workdir = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('default sandbox composition keyless smoke (real cordis.yml via the Loader)', () => {
|
describe('default sandbox composition keyless smoke (real cordis.yml via the Loader)', () => {
|
||||||
|
|||||||
@@ -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 { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type AgentUnderTest,
|
type AgentUnderTest,
|
||||||
type LaunchedAcpTestAgent,
|
type LaunchedAcpTestAgent,
|
||||||
} from '@deepseek-ai/dsh-acp-snapshot'
|
} 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
|
* 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
|
let workdir: string | undefined
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
try {
|
const ownedSpawned = spawned
|
||||||
await spawned?.close('SIGKILL')
|
const ownedWorkdir = workdir
|
||||||
} finally {
|
spawned = undefined
|
||||||
spawned = undefined
|
workdir = undefined
|
||||||
try {
|
await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir)
|
||||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
|
||||||
} finally {
|
|
||||||
workdir = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook blocks bash (real model)', () => {
|
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook blocks bash (real model)', () => {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ interface Config {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Agents listed in config are auto-created at startup. `cwd` seeds a fresh config-created session; a materialized exact `sessionId` remount and an explicit `resumeSessionId` keep the persisted session header. While the factory is active, a declarative lookup, resume, setup, or publication failure is contained, logged, and emitted as `agent-loop/config-start-failed(sessionId, error)` because no live `Agent` exists for an `agent/*` signal; cancellation caused by factory teardown is silent. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin.
|
Agents listed in config are auto-created at startup. `cwd` seeds a fresh config-created session; a materialized exact `sessionId` remount and an explicit `resumeSessionId` keep the persisted session header. An overlapping remount waits for an already-disposed same-id agent to finish detaching both registries before it inspects persistence, so asynchronous teardown cannot strand the configured identity. While the factory is active, a declarative lookup, resume, setup, or publication failure is contained, logged, and emitted as `agent-loop/config-start-failed(sessionId, error)` because no live `Agent` exists for an `agent/*` signal; cancellation caused by factory teardown is silent. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin.
|
||||||
|
|
||||||
### Internal concrete driver
|
### Internal concrete driver
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ function renderThrown(value: unknown): string {
|
|||||||
/** Factory-level ownership of every preparing or live transaction. */
|
/** Factory-level ownership of every preparing or live transaction. */
|
||||||
class FactoryOwnership {
|
class FactoryOwnership {
|
||||||
private accepting = true
|
private accepting = true
|
||||||
|
private readonly inactive = Promise.withResolvers<void>()
|
||||||
private transactions = new Set<AgentCreationTransaction>()
|
private transactions = new Set<AgentCreationTransaction>()
|
||||||
private startupTasks = new Set<Promise<void>>()
|
private startupTasks = new Set<Promise<void>>()
|
||||||
|
|
||||||
@@ -73,8 +74,14 @@ class FactoryOwnership {
|
|||||||
void task.then(forget, forget)
|
void task.then(forget, forget)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve `task`, or stop waiting when factory teardown begins. */
|
||||||
|
async waitWhileActive(task: Promise<void>): Promise<void> {
|
||||||
|
await Promise.race([task, this.inactive.promise])
|
||||||
|
}
|
||||||
|
|
||||||
async dispose(): Promise<void> {
|
async dispose(): Promise<void> {
|
||||||
this.accepting = false
|
this.accepting = false
|
||||||
|
this.inactive.resolve()
|
||||||
const reason = new Error('agent loop is not active')
|
const reason = new Error('agent loop is not active')
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
...[...this.transactions].map(transaction => transaction.disposeForFactory(reason)),
|
...[...this.transactions].map(transaction => transaction.disposeForFactory(reason)),
|
||||||
@@ -454,6 +461,8 @@ export class AgentLoop extends Service implements AgentFactory {
|
|||||||
agentOptions: AgentOptions,
|
agentOptions: AgentOptions,
|
||||||
meta: Pick<SessionHeader, 'cwd'>,
|
meta: Pick<SessionHeader, 'cwd'>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId)
|
||||||
|
if (!this.ownership.isActive()) return
|
||||||
const exists = (await persistence.list()).some(header => header.id === sessionId)
|
const exists = (await persistence.list()).some(header => header.id === sessionId)
|
||||||
if (!this.ownership.isActive()) return
|
if (!this.ownership.isActive()) return
|
||||||
if (exists) {
|
if (exists) {
|
||||||
@@ -463,6 +472,32 @@ export class AgentLoop extends Service implements AgentFactory {
|
|||||||
this.create(sessionId, agentOptions, meta)
|
this.create(sessionId, agentOptions, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wait for an already-disposed same-id lifecycle to finish registry teardown. */
|
||||||
|
private async waitForDrainingConfiguredIdentity(ownerCtx: Context, sessionId: SessionId): Promise<void> {
|
||||||
|
const current = ownerCtx.agents.get(sessionId)
|
||||||
|
if (current?.status !== 'disposed') return
|
||||||
|
|
||||||
|
const released = Promise.withResolvers<void>()
|
||||||
|
const checkReleased = (): void => {
|
||||||
|
if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) {
|
||||||
|
released.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const disposeAgentListener = ownerCtx.on('agent/disposed', (agent) => {
|
||||||
|
if (agent.id === sessionId) checkReleased()
|
||||||
|
})
|
||||||
|
const disposeSessionListener = ownerCtx.on('session/disposed', (session) => {
|
||||||
|
if (session.id === sessionId) checkReleased()
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
checkReleased()
|
||||||
|
await this.ownership.waitWhileActive(released.promise)
|
||||||
|
} finally {
|
||||||
|
disposeAgentListener()
|
||||||
|
disposeSessionListener()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an agent and session under one caller-supplied identity, owned by
|
* Create an agent and session under one caller-supplied identity, owned by
|
||||||
* the accessing fiber. Constructor-driven config calls mint a fresh combined
|
* the accessing fiber. Constructor-driven config calls mint a fresh combined
|
||||||
|
|||||||
@@ -92,6 +92,50 @@ describe('config-driven session id', () => {
|
|||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('waits for a draining exact-id lifecycle during an overlapping reload', async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-overlap-'))
|
||||||
|
dirs.push(root)
|
||||||
|
const ctx = await makeCoreContext()
|
||||||
|
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||||
|
const sessionId = SessionId('stdio-exact-overlap')
|
||||||
|
const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] }
|
||||||
|
const firstLoop = await ctx.plugin(AgentLoop, config)
|
||||||
|
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
|
||||||
|
const first = ctx.agents.get(sessionId) as Agent
|
||||||
|
|
||||||
|
const flushGate = Promise.withResolvers<undefined>()
|
||||||
|
let flushStarted = false
|
||||||
|
ctx.on('session/flush', (session) => {
|
||||||
|
if (session !== first.session) return
|
||||||
|
flushStarted = true
|
||||||
|
return flushGate.promise
|
||||||
|
})
|
||||||
|
first.inject([{ type: 'text', text: 'persist before replacement' }], {
|
||||||
|
source: { kind: 'plugin', plugin: 'test' },
|
||||||
|
})
|
||||||
|
expect(flushStarted).toBe(true)
|
||||||
|
|
||||||
|
const firstDisposal = firstLoop.dispose()
|
||||||
|
await expect.poll(() => first.status).toBe('disposed')
|
||||||
|
const failures: unknown[] = []
|
||||||
|
ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) })
|
||||||
|
const secondLoop = await ctx.plugin(AgentLoop, config)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
expect(ctx.agents.get(sessionId)).toBe(first)
|
||||||
|
expect(failures).toEqual([])
|
||||||
|
|
||||||
|
flushGate.resolve(undefined)
|
||||||
|
await firstDisposal
|
||||||
|
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
|
||||||
|
const second = ctx.agents.get(sessionId) as Agent
|
||||||
|
expect(second).not.toBe(first)
|
||||||
|
expect(JSON.stringify(second.session.deriveMessages())).toContain('persist before replacement')
|
||||||
|
expect(failures).toEqual([])
|
||||||
|
|
||||||
|
await secondLoop.dispose()
|
||||||
|
await ctx.fiber.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
it('contains an exact-id persistence lookup failure', async () => {
|
it('contains an exact-id persistence lookup failure', async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-failure-'))
|
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-failure-'))
|
||||||
dirs.push(root)
|
dirs.push(root)
|
||||||
|
|||||||
@@ -214,6 +214,9 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
|
|||||||
|
|
||||||
/** Reject request-header vocabulary removed with the legacy delta codec. */
|
/** Reject request-header vocabulary removed with the legacy delta codec. */
|
||||||
function assertSupportedRequestHeader(type: string, data: unknown, location: string): void {
|
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'
|
if (type === 'request/header'
|
||||||
&& data !== null && typeof data === 'object' && !Array.isArray(data)
|
&& data !== null && typeof data === 'object' && !Array.isArray(data)
|
||||||
&& (data as Record<string, unknown>)['reason'] === 'fallback') {
|
&& (data as Record<string, unknown>)['reason'] === 'fallback') {
|
||||||
|
|||||||
@@ -62,11 +62,17 @@ describe('foldRequestHeader', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('legacy request-header format', () => {
|
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 = [{
|
const legacy = [{
|
||||||
type: 'request/header-delta', seq: 0, time: 1, data: { config: CONFIG },
|
type: 'request/header-delta', seq: 0, time: 1, data: { config: CONFIG },
|
||||||
}] as unknown as SessionEvent[]
|
}] as unknown as SessionEvent[]
|
||||||
expect(() => new Session(SessionId('legacy'), legacy)).toThrow(/unsupported legacy request\/header-delta/)
|
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', () => {
|
it('rejects the removed fallback reason in seeds and untyped appends', () => {
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ describe('SessionPersistence service registration', () => {
|
|||||||
await fiber.dispose()
|
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()
|
const ctx = new Context()
|
||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
const fiber = await ctx.plugin(MemoryPersistence)
|
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
|
// Model the runtime shape available to JavaScript or a hot-loaded plugin
|
||||||
// compiled against the obsolete event vocabulary.
|
// compiled against the obsolete event vocabulary.
|
||||||
const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent
|
const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent
|
||||||
appendLegacy('request/header-delta', { config: { model: 'legacy' } })
|
expect(() => appendLegacy('request/header-delta', { config: { model: 'legacy' } }))
|
||||||
|
.toThrow(/unsupported legacy request\/header-delta format/)
|
||||||
await expect(ctx.sessions.flush(session))
|
expect(session.events).toHaveLength(0)
|
||||||
.rejects.toThrow(/unsupported legacy request\/header-delta event at seq 0/)
|
|
||||||
await fiber.dispose()
|
await fiber.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,15 @@ function isTTYPair(input: Readable, output: Writable): boolean {
|
|||||||
return Boolean((input as { isTTY?: boolean }).isTTY && (output as { isTTY?: boolean }).isTTY)
|
return Boolean((input as { isTTY?: boolean }).isTTY && (output as { isTTY?: boolean }).isTTY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Render an arbitrary failure without allowing hostile coercion to escape the UI boundary. */
|
||||||
|
function renderThrown(value: unknown): string {
|
||||||
|
try {
|
||||||
|
return String(value)
|
||||||
|
} catch {
|
||||||
|
return '<unrenderable thrown value>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface PendingQuestion {
|
interface PendingQuestion {
|
||||||
request: AskUserQuestionRequest
|
request: AskUserQuestionRequest
|
||||||
questionIndex: number
|
questionIndex: number
|
||||||
@@ -230,7 +239,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
|
|||||||
queuedInput.length = 0
|
queuedInput.length = 0
|
||||||
submittedWork = sawRunning
|
submittedWork = sawRunning
|
||||||
if (dropped > 0) {
|
if (dropped > 0) {
|
||||||
ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (${dropped} line(s)): ${String(error)}`)
|
ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (${dropped} line(s)): ${renderThrown(error)}`)
|
||||||
}
|
}
|
||||||
maybeExit()
|
maybeExit()
|
||||||
})
|
})
|
||||||
@@ -390,7 +399,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
|
|||||||
const text = line.trim()
|
const text = line.trim()
|
||||||
if (!text) return
|
if (!text) return
|
||||||
if (failedStartup !== undefined) {
|
if (failedStartup !== undefined) {
|
||||||
ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ${String(failedStartup.error)}`)
|
ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ${renderThrown(failedStartup.error)}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const agent = target
|
const agent = target
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ function chunkEvent(chunk: StreamChunk): SessionEvent {
|
|||||||
|
|
||||||
const CONFIG: Config = { welcome: 'hi there', sessionId: 'main' }
|
const CONFIG: Config = { welcome: 'hi there', sessionId: 'main' }
|
||||||
|
|
||||||
|
function unrenderableFailure(): unknown {
|
||||||
|
return { [Symbol.toPrimitive](): never { throw new Error('coercion escaped') } }
|
||||||
|
}
|
||||||
|
|
||||||
async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
|
async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(AgentRegistry)
|
await ctx.plugin(AgentRegistry)
|
||||||
@@ -753,14 +757,14 @@ describe('createStdioChat input', () => {
|
|||||||
it('drops later input after the configured startup fails', async () => {
|
it('drops later input after the configured startup fails', async () => {
|
||||||
const { ctx, input } = await setup()
|
const { ctx, input } = await setup()
|
||||||
const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
|
const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
|
||||||
const failure = new Error('persisted session is corrupt')
|
const failure = unrenderableFailure()
|
||||||
ctx.emit('agent-loop/config-start-failed', SessionId('main'), failure)
|
ctx.emit('agent-loop/config-start-failed', SessionId('main'), failure)
|
||||||
|
|
||||||
input.feed('cannot run')
|
input.feed('cannot run')
|
||||||
await new Promise(r => setImmediate(r))
|
await new Promise(r => setImmediate(r))
|
||||||
|
|
||||||
expect(error).toHaveBeenCalledWith(
|
expect(error).toHaveBeenCalledWith(
|
||||||
'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): Error: persisted session is corrupt',
|
'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): <unrenderable thrown value>',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -844,11 +848,11 @@ describe('createStdioChat EOF exit', () => {
|
|||||||
await flushExit()
|
await flushExit()
|
||||||
expect(exit).not.toHaveBeenCalled()
|
expect(exit).not.toHaveBeenCalled()
|
||||||
|
|
||||||
ctx.emit('agent-loop/config-start-failed', SessionId('main'), new Error('missing persisted session'))
|
ctx.emit('agent-loop/config-start-failed', SessionId('main'), unrenderableFailure())
|
||||||
await flushExit()
|
await flushExit()
|
||||||
|
|
||||||
expect(error).toHaveBeenCalledWith(
|
expect(error).toHaveBeenCalledWith(
|
||||||
'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): Error: missing persisted session',
|
'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): <unrenderable thrown value>',
|
||||||
)
|
)
|
||||||
expect(exit).toHaveBeenCalledWith(0)
|
expect(exit).toHaveBeenCalledWith(0)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user