Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/event-producer-consumer.md
#	docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md
#	docs/rfc/implemented/architecture/2026-06-20-branded-ids.md
#	docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md
#	docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md
#	docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md
#	packages/bash/tool-bash/README.md
#	packages/bash/tool-bash/src/index.ts
#	packages/bash/tool-bash/tests/tools.spec.ts
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/tests/properties.spec.ts
#	packages/core/agent/README.md
#	packages/core/agent/src/index.ts
#	packages/guard/repeat-tool-guard/src/index.ts
#	packages/hooks/hooks-claude/tests/bridge.spec.ts
#	packages/subagent/subagent-acp/tests/mock-acp-server.ts
#	packages/ui/acp/tests/dispose.spec.ts
#	packages/ui/stdio-agent/src/index.ts
#	packages/ui/stdio-agent/src/stdio-chat.ts
#	packages/ui/stdio-agent/tests/stdio-chat.spec.ts
#	packages/util/brand/src/index.ts
This commit is contained in:
Tianyi Cui
2026-07-14 18:51:24 +08:00
513 changed files with 3607 additions and 9725 deletions

View File

@@ -34,67 +34,41 @@ declare module 'cordis' {
interface Events {
/**
* A session was created in the store. A synchronous listener throw vetoes
* publication and rollback emits the matching `session/disposed` edge;
* returned-promise rejection is observed and logged but cannot retroactively
* veto this synchronous boundary. A synchronous listener that requests the
* advanced detach does not remove the entry immediately: removal and the
* paired `session/disposed` edge wait until the creation dispatch unwinds.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
* session's owner scope, captured when the session was ENTERED (an agent's
* session is entered through `agent.ctx`, so its events dispatch in that
* agent's scope; a bare `sessions.create()` from a plain plugin dispatches
* subject-less). A listener registered through `agent.ctx` hears only that
* agent's sessions; a plain plugin listener hears every session.
* Creation announcement during session publication. A synchronous throw vetoes and rolls
* back with a paired disposal; detach requested during dispatch is deferred.
* A returned-promise rejection is logged but cannot retroactively veto this
* synchronous boundary.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners
* receive only sessions entered through that agent's context.
* @param session - the session just entered and announced.
* @mode emit
*/
'session/created'(this: Scoped<Session>, session: Session): void
/**
* A previously announced session left the store. Emitted exactly once on
* normal detach or publication rollback, and never for a prepared/entered
* session whose `session/created` announcement did not begin. Listener
* failures (including returned-promise rejections) are logged and contained
* per listener so teardown always reaches quiescence.
* Scope-filtered dispatch uses the same owner carrier captured at entry;
* agent-scoped listeners hear only their own session's teardown.
* Emitted once when an announced session leaves the store, including
* publication rollback, but never for an entry whose creation announcement
* did not begin. Listener failures are logged and contained.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope.
* @param session - the session that is no longer live in the store.
* @mode emit
*/
'session/disposed'(this: Scoped<Session>, session: Session): void
/**
* An event was appended to a session log (sync, fire-and-forget). This is
* the per-append feed a UI or invariant plugin tails. The log push is the
* commit point; synchronous throws and returned-promise rejections from
* observers are logged and contained per listener, so they cannot make a
* committed append appear to fail or starve later listeners. The exact
* callback list and Cordis internal-dispatch checks resolve before the push;
* callbacks themselves run only after it.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
* session's owner scope, captured when the session was ENTERED (an agent's
* session is entered through `agent.ctx`, so its events dispatch in that
* agent's scope; a bare `sessions.create()` from a plain plugin dispatches
* subject-less). A listener registered through `agent.ctx` hears only that
* agent's sessions; a plain plugin listener hears every session.
* Post-commit, fire-and-forget append feed. The listener snapshot resolves
* before the log push, but callbacks run after it; observer failures are
* logged and contained without making the committed append fail.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners
* receive only events from sessions entered through that agent's context.
* @param session - the session whose log grew.
* @param event - the appended event, exactly as recorded.
* @mode emit
*/
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
/**
* Awaited durability checkpoint. The agent loop awaits
* `ctx.sessions.flush(session)` at every turn end; persistence
* plugins (JSONL, SQLite) drain their write-behind buffers here and on
* fiber dispose. Awaited (parallel), not a waterfall: every listener runs
* and the caller waits for all of them, but none can veto. Dispatch it
* through {@link SessionStore.flush} — the store owns the carrier — never
* via a raw `ctx.parallel`.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
* session's owner scope, captured when the session was ENTERED (an agent's
* session is entered through `agent.ctx`, so its events dispatch in that
* agent's scope; a bare `sessions.create()` from a plain plugin dispatches
* subject-less). A listener registered through `agent.ctx` hears only that
* agent's sessions; a plain plugin listener hears every session.
* Awaited parallel durability checkpoint: every listener runs and the
* caller awaits all of them, with no waterfall veto. Dispatch through
* {@link SessionStore.flush}. Scope-filtered dispatch
* (`@deepseek-ai/dsh-scope`) reuses the session's owner scope.
* @param session - the session whose buffered events must reach durable storage.
* @mode parallel
*/
@@ -103,13 +77,9 @@ declare module 'cordis' {
}
/**
* Renders a `context/message` or `steering/message` event as a tagged
* synthetic user-role message (the system-reminder pattern: zero adapter
* burden, models distinguish it from real user prompts by the envelope).
*
* Live-adapter review has validated the tagged-envelope rendering against
* current DeepSeek behavior; provider-specific mismatches belong in that
* adapter, not in the canonical session vocabulary.
* Render injected context as tagged synthetic user-role content, keeping the
* canonical session vocabulary provider-neutral. Adapter-specific exceptions
* belong in the adapter.
*/
function renderTagged(tag: string, content: ContentBlock[], source: MessageSource): ContentBlock[] {
const open = `<${tag} source=${JSON.stringify(source.kind)}>`

View File

@@ -1,17 +1,4 @@
/**
* Lossless-JSON validation and snapshot materialization for session data.
*
* The session event log is the durable source of truth (the event-sourcing / session-persistence RFCs): every
* `event.data` must round-trip losslessly through JSON so any persistence
* backend can store and reload it byte-identically. This invariant belongs to
* the log itself — `Session.append` enforces it at the source, so a
* non-serializable event never enters `session.events` and the live log can
* never diverge from what a backend can persist. Other public boundaries use
* {@link snapshotJsonValue} when they must validate and detach in one pass;
* {@link isJsonValue} remains the non-copying structural predicate.
*
* @module @deepseek-ai/dsh-session/json
*/
/** Lossless-JSON validation and detached snapshots for durable session data. @module @deepseek-ai/dsh-session/json */
/**
* A value that round-trips losslessly through JSON: `null`, a boolean, a finite
@@ -25,19 +12,10 @@
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
/**
* Materialize one detached lossless-JSON snapshot in a SINGLE recursive pass.
* Each array slot or own enumerable string-keyed object value is read exactly
* once, validated, and copied immediately. This is intentionally not
* `isJsonValue(value)` followed by `structuredClone(value)`: a stateful getter
* could return plain JSON to the check and an exotic class instance to the
* clone, whose prototype `structuredClone` would erase before a later check.
*
* Accepts the same scalar/object vocabulary as {@link isJsonValue}: arrays use
* the ordinary `Array.prototype` (subclass instances are not plain JSON
* containers), while null-prototype objects are accepted and normalized to
* ordinary plain objects. Sparse arrays, cycles, negative zero, non-finite
* numbers, unsupported scalar types, and exotic object or array shells return
* `undefined`. A throwing getter is a caller failure and propagates unchanged.
* Validate and detach lossless JSON in one read per property, so a stateful
* getter cannot change between validation and copying. Accepts ordinary arrays,
* plain or null-prototype objects, and JSON scalars; rejects sparse, cyclic,
* exotic, negative-zero, and non-finite values. Getter throws propagate.
*
* @param value - the candidate value to validate and detach.
* @returns the detached snapshot, or `undefined` when the value is not
@@ -104,28 +82,12 @@ export function snapshotJsonValue<T>(value: T): T | undefined {
}
/**
* Whether `value` is losslessly JSON-serializable: only `null`, finite numbers
* other than negative zero, booleans, strings, plain arrays, and plain objects
* of such values. Rejects `BigInt`, function, symbol, `undefined`, `-0` (which
* JSON rewrites to `0`), non-finite numbers (`NaN`/`Infinity`, which JSON turns
* into `null`), and exotic objects (`Map`/`Set`/`Date`/class instances) —
* anything `JSON.stringify` would drop, throw on, or convert lossily. Sparse
* arrays are rejected too: a hole serializes to `null`, so `[1, , 3]` would not
* round-trip. Detects circular references (which would throw) and reports them
* as non-serializable rather than propagating the throw.
*
* Scope — this is a structural plain-data predicate, not an invocation of
* `JSON.stringify`: only an object's OWN ENUMERABLE STRING-keyed properties are
* inspected (`Object.values`). Symbol-keyed and non-enumerable properties are
* omitted from the durable data surface. Custom `toJSON` behavior is not
* executed; boundaries that persist a value first materialize a new plain-data
* record with {@link snapshotJsonValue}. Getters are invoked during this check,
* so callers that need a stable detached value use that one-pass materializer
* instead of checking and then rereading a side-effecting record.
* Test the same lossless JSON boundary as {@link snapshotJsonValue} without
* detaching it. Only own enumerable string properties participate; `toJSON`
* is ignored and getters run, so persistence boundaries use the snapshotter.
* @param value - the candidate event data to test.
* @param seen - objects on the current descent path, for circular-reference
* detection; the recursion threads it — callers omit it.
* @returns true when `value` survives a JSON round-trip losslessly.
* @param seen - current recursion path; callers omit it.
* @returns whether `value` survives JSON round-trip losslessly.
*/
export function isJsonValue(value: unknown, seen: Set<object> = new Set()): boolean {
if (value === null) return true

View File

@@ -1,37 +1,7 @@
/**
* Crash-recovery repair for an interrupted session log.
*
* A persistence backend flushes only at `turn/end`, so a crash can leave a
* durable log whose final turn never closed: real, fully-written events sit
* after the last `turn/end` with no closing boundary. A single turn can be huge
* in a long-horizon task (many steps, large tool output), so those events MUST
* be preserved — truncating the turn would silently destroy real work. Instead,
* on reload the backend CLOSES the orphaned turn by appending the minimal
* synthetic boundary events:
*
* 1. an error `tool/result` for every `tool-call` in the interrupted turn that
* never got its matching `tool/result` (so the rehydrated history is a
* VALID provider transcript — see below),
* 2. a `step/end` if a step was still open, then
* 3. a `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason.
*
* The marker records that the turn was cut short by a crash, not completed by
* the model. See the session-persistence RFC.
*
* Why the synthetic tool results matter: `deriveMessages()` renders the
* `tool-call` blocks inside a durable `assistant/message` but only emits a
* matching tool-result when a `tool/result` EVENT exists. A crash between the
* assistant message and its tool results (the loop runs the tools AFTER logging
* the assistant message, so a process killed mid-tool leaves the calls without
* results) would otherwise reload a history with a dangling assistant tool-call
* — which every provider rejects as an invalid transcript on the next request.
* Synthesizing an error result per orphaned call keeps resume safe.
*
* This module computes those synthetic closers from an event list; backends
* return them inline from `load` (so the reconstructed session is balanced and
* immediately usable) and persist them during that mutating load before any
* later append continues the log.
*
* Crash-recovery repair for an interrupted session log. It preserves a fully
* written final turn and supplies the missing tool, step, and turn boundaries
* needed to resume with a provider-valid transcript.
* @module @deepseek-ai/dsh-session/repair
*/
@@ -39,36 +9,19 @@ import type { CallId } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from './types.ts'
/**
* Scan `events` for an open turn/step at the tail and return the synthetic
* boundary events that close them, with `seq` continuing the log and `time`
* copied from the last real event (the closers stand in for the crash moment;
* reusing the last timestamp keeps them deterministic and never invents a
* "future" time). Returns an empty array when the log is already balanced
* (ends on a `turn/end`, or is empty) — the common, non-crash case.
* Return deterministic synthetic events that close an open tail turn. Unmatched
* calls receive error results first, followed by an open `step/end` and an
* interrupted `turn/end`; sequences continue the log and timestamps reuse the
* last real event. A balanced or empty log returns no events.
*
* The closers, in order: an error `tool/result` for each unmatched `tool-call`
* in the interrupted turn, then a `step/end` if a step is open, then the
* `turn/end {interrupted}`. The tool-results come first so a step that issued
* tool calls is balanced (every call has a result) before its `step/end`.
*
* Only the LAST turn can be open: the invariants plugin guarantees a `turn/end`
* before any later `turn/start`, so an interior open turn is impossible in a
* valid committed log. Likewise at most one step is open within that turn.
* @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
* @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
*/
export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] {
let openTurn: number | null = null
let openStep: number | null = null
// Track tool calls vs. their results WITHIN the currently-open turn only: a
// call is "pending" until its matching tool/result arrives. Reset at every
// turn boundary so a committed earlier turn (already balanced) never leaks a
// phantom pending call into the interrupted-turn repair.
// Track pending tool calls with their callSeq (the seq of the `tool/call`
// event, captured for surface sourceEventSeqs provenance on the synthetic
// result). CallSeq is set from `tool/call` events; the assistant/message
// block scan may register a call first (it appears earlier in the log), and
// the later `tool/call` event fills in the seq.
// Reset at each turn boundary so earlier calls cannot leak into tail repair.
// Assistant blocks register calls; later tool/call events add provenance seqs.
const pendingCalls = new Map<CallId, { step: number; callSeq?: number }>()
for (const event of events) {
switch (event.type) {
@@ -97,10 +50,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
}
break
case 'tool/call':
// Capture the tool/call event seq for surface provenance on the
// synthesized tool/result. The entry may already exist (registered by
// the assistant/message above) or may be new (if the assistant/message
// came from a prior step that was already closed).
// Add the tool/call seq used as provenance on a synthetic result.
{
const entry = pendingCalls.get(event.data.callId)
if (entry) {
@@ -129,10 +79,8 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
const time = last.time
const closers: SessionEvent[] = []
// Synthesize an error tool/result for each tool-call left unanswered by the
// crash, so deriveMessages() yields a valid provider transcript on resume (a
// dangling assistant tool-call is rejected by every provider). Insertion
// order follows the Map (insertion = log order of the assistant messages).
// Close calls before their step: providers reject dangling assistant calls,
// and Map insertion order preserves their transcript order.
for (const [callId, { step, callSeq }] of pendingCalls) {
closers.push({
type: 'tool/result',

View File

@@ -1,10 +1,7 @@
/**
* Derived-message cache tests: the session projects each surface node exactly
* once (O(new nodes) per call), rebuilds on a surface replace (the
* replaceGeneration signal), returns a fresh array snapshot
* per call over shared frozen messages, and stays deep-equal to a from-scratch
* replay derivation at every step — the incremental==scratch property the
* reconstructability RFC's invariant enforces in dev at request time.
* Derived-message cache contract against a scratch oracle: project new nodes
* once, rebuild on surface replacements, return fresh arrays over shared
* frozen messages, and remain value-equal to replay at every step.
*/
import { describe, expect, it } from 'vitest'
@@ -28,7 +25,6 @@ describe('derived-message cache', () => {
userText(session, 'two')
session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'reply' }] }, { surfaceOp: 'append' })
expect(session.deriveMessages()).toEqual(scratch(session))
// An empty-content assistant/message (usage host) projects to nothing.
session.append('assistant/message', { turn: 1, step: 2, content: [], usage: { inputTokens: 1, outputTokens: 0 } }, { surfaceOp: 'append' })
expect(session.deriveMessages()).toEqual(scratch(session))
})
@@ -48,7 +44,6 @@ describe('derived-message cache', () => {
expect(session.deriveMessages()).toHaveLength(1)
expect(session.deriveMessages()).toEqual(scratch(session))
// The array a caller took before the replace is untouched.
expect(beforeReplace).toHaveLength(2)
})
@@ -61,7 +56,7 @@ describe('derived-message cache', () => {
const second = session.deriveMessages()
expect(first).toHaveLength(1)
expect(second).toHaveLength(2)
// Shared projection objects: the same frozen message instance, once ever.
// Array snapshots share their frozen message projections.
expect(second[0]).toBe(first[0])
expect(Object.isFrozen(first[0])).toBe(true)
})
@@ -73,8 +68,7 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
const session = new Session(SessionId('per-event'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
// The fold path (deriveMessages) and the per-event path share the
// projection, so an external reconstructor cannot disagree with the cache.
// Full and per-event derivation share one projection.
expect(session.deriveEventMessage(event)).toEqual(session.deriveMessages().at(-1))
})

View File

@@ -1,16 +1,6 @@
/**
* Negative-path tests for the persistence log catalog generator
* (`scripts/gen-persistence-catalog.ts`).
*
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
* the freshness half is exercised by `pnpm run verify-persistence-catalog` in
* CI. What a freshness diff CANNOT prove is that the generator REJECTS
* malformed source the way it promises to — a member without description
* prose, a forbidden `@mode` tag, a non-literal member name, a duplicate event
* declaration, a missing or ambiguous `SurfaceEventType` union, a stale union
* member. These tests drive the exported collectors against synthetic fixture
* packages to prove each guard fires (and that well-formed declarations pass),
* mirroring the gen-cordis-catalog negative tests.
*/
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'

View File

@@ -13,10 +13,8 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEventMap, SessionEventType, SurfaceIntent } from '@deepseek-ai/dsh-session'
// An appendable event: its type/data plus, for surface-eligible types, the
// explicit surface intent the generator declares (mirroring how a real caller
// passes it). The intent is part of the generated fixture, NOT synthesized by
// `build`, so each arbitrary states the marker it produces.
// Each arbitrary supplies its own surface intent; `build` must not synthesize
// one or the property would fail to exercise malformed fixture choices.
type Appendable = {
[T in SessionEventType]: { type: T; data: SessionEventMap[T]; intent?: SurfaceIntent }
}[SessionEventType]

View File

@@ -155,11 +155,8 @@ describe('interruptedTurnClosers', () => {
})
it('handles tool/call without a matching assistant/message entry gracefully', () => {
// A tool/call event exists in the log but no assistant/message registered
// the callId in pendingCalls (e.g., a plugin appended it directly, or the
// assistant/message from a prior step didn't have this call). The repair
// should still close the turn — it just won't synthesize a result for this
// call (there's nothing to answer).
// A raw tool/call with no assistant-registered pending call has nothing to
// answer; repair still closes the step and turn without synthesizing a result.
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },

View File

@@ -81,9 +81,6 @@ describe('Session', () => {
const before = structuredClone(session.events)
// A misbehaving consumer tries to mutate the messages it was handed.
// Derived messages are frozen shared projections (cloned once off the
// log, then deep-frozen): every mutation attempt THROWS in strict mode —
// isolation by unrepresentability, not by per-call cloning.
const messages = session.deriveMessages()
const userBlock = messages[0]!.content[0]!
expect(() => { if (userBlock.type === 'text') userBlock.text = 'HACKED' }).toThrow(TypeError)
@@ -132,11 +129,8 @@ describe('Session', () => {
it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => {
const session = new Session(SessionId('s5b'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
// The typed overload makes surfaceOp mandatory only when the type argument is
// a SPECIFIC SurfaceEventType literal. A caller iterating raw events widens it
// to the SessionEventType union, where the conditional rest collapses to
// optional — the exact shape `for (const e of log) append(e.type, e.data)`
// produces. Reproduce that here and assert the runtime guard rejects it.
// A widened SessionEventType bypasses the overload's conditional requirement,
// so the runtime guard must still reject the missing surface marker.
const widenedType = 'user/message' as SessionEventType
expect(() => session.append(widenedType, { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }))
.toThrow(/surface-eligible and requires a surfaceOp marker/)
@@ -663,10 +657,8 @@ describe('SessionStore', () => {
})
it('enter() rejects a stale prepared session whose id is already live (no overwrite)', async () => {
// prepare()/enter() are public cross-package primitives that a caller may
// separate with arbitrary work. A stale prepared session must NOT overwrite
// a live store entry of the same id — its detach disposer would later delete
// the REAL session, breaking the store-uniqueness invariant.
// A stale prepared object must not replace the live same-id entry; its later
// detach would otherwise remove the wrong session.
const ctx = new Context()
await ctx.plugin(SessionStore)
const stale = ctx.sessions.prepare(SessionId('racy'))