Merge remote-tracking branch 'origin/master' into codex/pr48-repo-hardening-rfcs

# Conflicts:
#	docs/adr/README.md
#	docs/rfc/009-session-persistence-and-resumability.md
#	docs/rfc/README.md
#	docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md
#	docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md
#	examples/acp-agent/tests/acp.e2e.ts
#	packages/acp/README.md
#	packages/acp/src/index.ts
#	packages/acp/tests/stream-update.spec.ts
#	packages/agent-loop/src/loop.ts
#	packages/tools/src/index.ts
This commit is contained in:
Tianyi Cui
2026-06-18 23:41:14 +08:00
104 changed files with 2586 additions and 590 deletions

View File

@@ -44,7 +44,7 @@ On any violation it throws `InvariantError` (`code: 'INVARIANT'`).
## Why runtime, not deep-readonly types
A `DeepReadonly<SessionEvent>` is high type-noise across every log consumer, and a plugin can cast straight through it. A dev-mode freeze plus these assertions catch real corruption at zero production cost and zero type noise. The always-on half of that defense — cloning derived messages so request/adapter mutation can't reach back into the log — lives in `dsh-session`'s `deriveMessages`. This package is the dev-mode tripwire. See [ADR 0012](../../docs/adr/0012-dev-invariants-over-deep-readonly.md).
A `DeepReadonly<SessionEvent>` is high type-noise across every log consumer, and a plugin can cast straight through it. A dev-mode freeze plus these assertions catch real corruption at zero production cost and zero type noise. The always-on half of that defense — cloning derived messages so request/adapter mutation can't reach back into the log — lives in `dsh-session`'s `deriveMessages`. This package is the dev-mode tripwire. See [dev-mode invariants](../../docs/rfc/implemented/2026-06-11-dev-invariants-over-deep-readonly.md).
## Seeded sessions

View File

@@ -10,7 +10,7 @@
* taxonomy: the assertions below ARE the contract.
*
* Why runtime assertions instead of compile-time deep-readonly types? See
* ADR 0012. Briefly: a `DeepReadonly<SessionEvent>` is high type-noise across
* the dev-invariants RFC. Briefly: a `DeepReadonly<SessionEvent>` is high type-noise across
* every log consumer and a plugin casts straight through it; a dev-mode freeze
* + assertions catch real corruption at zero production cost and zero type
* noise. The always-on half of that defense (cloning derived messages) lives
@@ -75,7 +75,7 @@ interface SessionTrace {
* frozen: `Session.append()` accepts event data from arbitrary plugins/tools,
* so a caller can hand us a SHALLOW-frozen object whose descendants are still
* mutable. Skipping an already-frozen node (the obvious idempotence shortcut)
* would leave exactly the kind of mutable history ADR 0012 means to catch. A
* would leave exactly the kind of mutable history the dev-invariants RFC means to catch. A
* `WeakSet` of visited objects keeps it terminating on cycles and avoids
* re-walking shared subtrees / already-processed seed events.
*/
@@ -111,7 +111,7 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
// Boundary/step-scoped events have explicit cases; every OTHER event type —
// including plugin-added (merge-extensible) SessionEventMap keys — is caught
// by the `default` and must be turn-enclosed (ADR 0017). No assertNever: an
// by the `default` and must be turn-enclosed (the turn-enclosure RFC). No assertNever: an
// unknown variant is valid, not a compile error.
switch (event.type) {
case 'turn/start': {
@@ -182,7 +182,7 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
}
break
}
// Turn-enclosure (ADR 0017): EVERY session event not handled by a boundary
// Turn-enclosure (the turn-enclosure RFC): EVERY session event not handled by a boundary
// case above must sit inside an open turn. The durable session log uses the
// turn as its commit/replay boundary (the JSONL backend treats anything
// after the last turn/end as a crash tail), so a bare event between turns is

View File

@@ -88,7 +88,7 @@ describe('session-log invariants', () => {
it('rejects a message event appended outside any open turn (turn-enclosure)', async () => {
const { ctx } = await setup({ freeze: false })
const session = ctx.sessions.create()
// No turn open: every message-bearing event must be turn-enclosed (ADR 0017).
// No turn open: every message-bearing event must be turn-enclosed (the turn-enclosure RFC).
expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }))
.toThrow(/outside any open turn/)
expect(() => session.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }))
@@ -99,7 +99,7 @@ describe('session-log invariants', () => {
const { ctx } = await setup({ freeze: false })
const session = ctx.sessions.create()
// usage and error are turn-scoped: outside a turn they would land past the
// commit boundary and be dropped on resume (ADR 0017).
// commit boundary and be dropped on resume (the turn-enclosure RFC).
expect(() => session.append('usage', { turn: 1, step: 1, usage: { inputTokens: 1, outputTokens: 1 } }))
.toThrow(/outside any open turn/)
expect(() => session.append('error', { turn: 1, step: 1, message: 'boom' }))
@@ -317,7 +317,7 @@ describe('dev-freeze', () => {
// A caller hands in a SHALLOW-frozen block whose nested array is still
// mutable. deepFreeze must descend into the already-frozen object and
// freeze the descendant, not short-circuit on the frozen container —
// otherwise dev-mode misses exactly the history mutation ADR 0012 catches.
// otherwise dev-mode misses exactly the history mutation the dev-invariants RFC catches.
// `append` snapshots `data`, so the freeze applies to the LOGGED clone, not
// the caller's input — read the event back and assert on its data.
const innerContent: { type: 'text'; text: string }[] = [{ type: 'text', text: 'inner' }]