deepseek-harness/packages/core/agent-loop/tests/loop.spec.ts

1262 lines
53 KiB
TypeScript
Raw Normal View History

import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes the two gaps in the "brand ids that cross package boundaries" policy and fixes the dependency direction so a capability package never pulls in an unrelated one. - Extract the `Branded<B>` primitive into a new standalone type-only package `@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps. dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session, dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a generic execution backend must not couple to the LLM or session vocabulary). - Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id, the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary that casts SessionId -> OwnerToken. - Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters at the config boundary and the inner create()/resume casts disappear (only the genuinely-new per-run session-id string is cast). - Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store Map keys and public params/exports (SessionStore, AgentRegistry + factory options, the ACP session-id surface + ToolPresenter CallId map, the persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps). - Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point the Branded type-equiv at dsh-brand, fix stale param types in the session/ agent/bash READMEs, regenerate the cordis catalog + module graph. Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
Document the codebase thoroughly and tighten type safety Docs: per-folder README.md for packages/ (family overview + one per package: service, events, API, extension points, TODOs), examples/, and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md symlinks) for packages/ and vendor/; module-level doc comments in every packages/*/src file; richer JSDoc on all exported API (event side effects, disposal contracts, error behavior). Root AGENTS.md gains a "Type Safety and Documentation" policy section: the codebase aims to be very type-safe and well documented; type gymnastics are acceptable in core packages when they improve plugin-author DX; verbose docs are fine as long as they stay strictly in sync with the code. Type safety: removed the upstream-inherited "noImplicitAny": false from tsconfig.base.json — packages/* now compile under full strict mode; vendor/loader and vendor/include set it locally (vendor/cordis already did). Eliminated every `: any` / `as any` from packages and examples (catch clauses use unknown + a CodedError narrowing type; event data access uses discriminated-union narrowing). Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL — SchemaSpec with per-property `required: true` booleans, type-level InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and defineTool() so first-party tools get typed execute(args) with zero casts (raw JSON Schema still accepted for MCP interop; chosen over schemastery because it targets JSON Schema generation directly). echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
2026-07-14 02:32:35 +08:00
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
2026-07-14 02:32:35 +08:00
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts'
2026-07-14 02:32:35 +08:00
function driverDone(agent: Agent): Promise<void> {
return (agent as Agent & { done: Promise<void> }).done
}
async function harness(adapter: MockAdapter, persona = '') {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
/**
* Wait for the agent's NEXT transition to idle. Always event-based: callers
* invoke this right after send(), when the loop hasn't woken yet (status is
* still 'idle' synchronously), so polling the current status would lie.
*/
2026-07-14 02:32:35 +08:00
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
}
})
})
}
2026-07-14 02:32:35 +08:00
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
}
describe('agent loop', () => {
it('runs a simple turn: queued message → model → idle, with ordered events', async () => {
const adapter = new MockAdapter([textResponse('hello there')])
const ctx = await harness(adapter)
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
refactor(events): remove the turn boundary mirror events Complete the boundary-mirror removal begun with the step mirrors: drop `agent/turn-start` and `agent/turn-end` from the agent event taxonomy. Turn and step boundaries are now read exclusively off the durable `session/event` feed (`turn/start`/`turn/end`/`step/start`/`step/end`) — there is no `agent/*` mirror for any boundary. - loop.ts: delete both turn emits; `closeTurn` loses its `emit` parameter and its now-unreachable idempotency guard (it is called exactly once per turn, on mutually exclusive normal/catch paths); `failTurn` loses the dead post-close branch that only a throwing turn-end LISTENER could reach. - ui-stdio: render turn boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map (the `turn/start` event carries only the turn number, and the session id is not reliably the agent id). ui-stdio is a disposable test REPL, so this migration retires the sole justification the event-domain-semantics RFC gave for KEEPING the turn mirrors. - Tests: reason/turn-number collectors and the boundary-ordering test now read `session/event`; the throwing-turn-boundary-LISTENER tests are deleted (that code path no longer exists). A new test covers the outer-catch disposed branch via a pre-step listener that disposes-then-throws (the surviving real path). - Docs: promote the "remove agent boundary mirror events" RFC to implemented (amended/narrowed — `agent/steering` is RETAINED, not a boundary mirror); update the event-domain-semantics + turn-enclosure RFCs, architecture.md, the cookbook, the ACP/agent/ui-stdio prose, and regenerate the cordis catalog. `agent/steering` and `agent/stream-chunk` are explicitly out of scope (not durable-boundary mirrors). ACP is unaffected — it already settles from the log's `turn/end` + `agent/status`; snapshot goldens are byte-unchanged.
2026-07-02 03:26:45 +08:00
// All boundaries — turn and step — are durable session events on the
// session/event feed (no agent/* mirror). Record them in fire order to
// assert the full boundary nesting.
const order: string[] = []
ctx.on('session/event', (_session, event) => {
refactor(events): remove the turn boundary mirror events Complete the boundary-mirror removal begun with the step mirrors: drop `agent/turn-start` and `agent/turn-end` from the agent event taxonomy. Turn and step boundaries are now read exclusively off the durable `session/event` feed (`turn/start`/`turn/end`/`step/start`/`step/end`) — there is no `agent/*` mirror for any boundary. - loop.ts: delete both turn emits; `closeTurn` loses its `emit` parameter and its now-unreachable idempotency guard (it is called exactly once per turn, on mutually exclusive normal/catch paths); `failTurn` loses the dead post-close branch that only a throwing turn-end LISTENER could reach. - ui-stdio: render turn boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map (the `turn/start` event carries only the turn number, and the session id is not reliably the agent id). ui-stdio is a disposable test REPL, so this migration retires the sole justification the event-domain-semantics RFC gave for KEEPING the turn mirrors. - Tests: reason/turn-number collectors and the boundary-ordering test now read `session/event`; the throwing-turn-boundary-LISTENER tests are deleted (that code path no longer exists). A new test covers the outer-catch disposed branch via a pre-step listener that disposes-then-throws (the surviving real path). - Docs: promote the "remove agent boundary mirror events" RFC to implemented (amended/narrowed — `agent/steering` is RETAINED, not a boundary mirror); update the event-domain-semantics + turn-enclosure RFCs, architecture.md, the cookbook, the ACP/agent/ui-stdio prose, and regenerate the cordis catalog. `agent/steering` and `agent/stream-chunk` are explicitly out of scope (not durable-boundary mirrors). ACP is unaffected — it already settles from the log's `turn/end` + `agent/status`; snapshot goldens are byte-unchanged.
2026-07-02 03:26:45 +08:00
if (event.type === 'turn/start' || event.type === 'step/start' || event.type === 'step/end' || event.type === 'turn/end') {
order.push(event.type)
}
})
send(agent, 'hi')
await waitForIdle(ctx, agent)
refactor(events): remove the turn boundary mirror events Complete the boundary-mirror removal begun with the step mirrors: drop `agent/turn-start` and `agent/turn-end` from the agent event taxonomy. Turn and step boundaries are now read exclusively off the durable `session/event` feed (`turn/start`/`turn/end`/`step/start`/`step/end`) — there is no `agent/*` mirror for any boundary. - loop.ts: delete both turn emits; `closeTurn` loses its `emit` parameter and its now-unreachable idempotency guard (it is called exactly once per turn, on mutually exclusive normal/catch paths); `failTurn` loses the dead post-close branch that only a throwing turn-end LISTENER could reach. - ui-stdio: render turn boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map (the `turn/start` event carries only the turn number, and the session id is not reliably the agent id). ui-stdio is a disposable test REPL, so this migration retires the sole justification the event-domain-semantics RFC gave for KEEPING the turn mirrors. - Tests: reason/turn-number collectors and the boundary-ordering test now read `session/event`; the throwing-turn-boundary-LISTENER tests are deleted (that code path no longer exists). A new test covers the outer-catch disposed branch via a pre-step listener that disposes-then-throws (the surviving real path). - Docs: promote the "remove agent boundary mirror events" RFC to implemented (amended/narrowed — `agent/steering` is RETAINED, not a boundary mirror); update the event-domain-semantics + turn-enclosure RFCs, architecture.md, the cookbook, the ACP/agent/ui-stdio prose, and regenerate the cordis catalog. `agent/steering` and `agent/stream-chunk` are explicitly out of scope (not durable-boundary mirrors). ACP is unaffected — it already settles from the log's `turn/end` + `agent/status`; snapshot goldens are byte-unchanged.
2026-07-02 03:26:45 +08:00
expect(order).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end'])
const types = agent.session.events.map(e => e.type)
// turn/start opens the turn, THEN the queued user message is recorded inside
simplify(session): fold trace-only usage/error events into load-bearing events The session event vocabulary carried two standalone trace-only events that were not load-bearing as separate records. Fold their facts into nearby load-bearing events and delete the standalone variants. - Token usage now rides on `assistant/message` as an optional `usage` field — the assembled model output and its accounting travel together. The loop folds `assembler.usage` onto the append instead of emitting a separate `usage` event. - The max-tokens path is the no-data-loss host: a step cut off with usage but EMPTY content (e.g. only a dropped tool call) previously emitted a standalone `usage`; it now records an empty-content `assistant/message { content: [], usage }`. `deriveMessages()` skips empty-content assistant messages, so the usage host never injects a spurious content-less assistant turn into the provider transcript. A step with neither content nor usage appends nothing. - An operational error's step number now rides on `turn/end.reason` for `kind: 'error'` (`{ kind: 'error', step, message, code? }`) — the durable turn outcome ACP and resume already consume. `failTurn` sets the reason directly (no separate session `error` event). `agent/error` + logging are unchanged for live diagnostics. - No format-version bump: pre-release, no persisted data, so per the format policy there is nothing to migrate or reject (the RFC's "refresh the format version" criterion over-reached). `version` stays 1. - ACP fixtures + goldens re-recorded (keyless replay): dropped standalone usage/error lines, usage folded onto assistant/message, error step on turn/end.reason. RFC moved proposed -> implemented with an implementation note recording the two scope refinements.
2026-06-21 10:00:06 +08:00
// it (every event is turn-enclosed), then the assembled message (carrying the
// step's usage).
expect(types[0]).toBe('turn/start')
expect(types[1]).toBe('user/message')
expect(types).toContain('assistant/message')
simplify(session): fold trace-only usage/error events into load-bearing events The session event vocabulary carried two standalone trace-only events that were not load-bearing as separate records. Fold their facts into nearby load-bearing events and delete the standalone variants. - Token usage now rides on `assistant/message` as an optional `usage` field — the assembled model output and its accounting travel together. The loop folds `assembler.usage` onto the append instead of emitting a separate `usage` event. - The max-tokens path is the no-data-loss host: a step cut off with usage but EMPTY content (e.g. only a dropped tool call) previously emitted a standalone `usage`; it now records an empty-content `assistant/message { content: [], usage }`. `deriveMessages()` skips empty-content assistant messages, so the usage host never injects a spurious content-less assistant turn into the provider transcript. A step with neither content nor usage appends nothing. - An operational error's step number now rides on `turn/end.reason` for `kind: 'error'` (`{ kind: 'error', step, message, code? }`) — the durable turn outcome ACP and resume already consume. `failTurn` sets the reason directly (no separate session `error` event). `agent/error` + logging are unchanged for live diagnostics. - No format-version bump: pre-release, no persisted data, so per the format policy there is nothing to migrate or reject (the RFC's "refresh the format version" criterion over-reached). `version` stays 1. - ACP fixtures + goldens re-recorded (keyless replay): dropped standalone usage/error lines, usage folded onto assistant/message, error step on turn/end.reason. RFC moved proposed -> implemented with an implementation note recording the two scope refinements.
2026-06-21 10:00:06 +08:00
const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message')
expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data.usage).toEqual({ inputTokens: 10, outputTokens: 'hello there'.length })
expect(types.at(-1)).toBe('turn/end')
// derived history: user + assistant
const messages = agent.session.deriveMessages()
expect(messages.map(m => m.role)).toEqual(['user', 'assistant'])
expect(messages[1]!.content).toEqual([{ type: 'text', text: 'hello there' }])
})
it('round-trips tool calls: model requests tool → executes → result in next request', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', { text: 'ping' }, 'calling echo'),
textResponse('done'),
])
const ctx = await harness(adapter)
Document the codebase thoroughly and tighten type safety Docs: per-folder README.md for packages/ (family overview + one per package: service, events, API, extension points, TODOs), examples/, and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md symlinks) for packages/ and vendor/; module-level doc comments in every packages/*/src file; richer JSDoc on all exported API (event side effects, disposal contracts, error behavior). Root AGENTS.md gains a "Type Safety and Documentation" policy section: the codebase aims to be very type-safe and well documented; type gymnastics are acceptable in core packages when they improve plugin-author DX; verbose docs are fine as long as they stay strictly in sync with the code. Type safety: removed the upstream-inherited "noImplicitAny": false from tsconfig.base.json — packages/* now compile under full strict mode; vendor/loader and vendor/include set it locally (vendor/cordis already did). Eliminated every `: any` / `as any` from packages and examples (catch clauses use unknown + a CodedError narrowing type; event data access uses discriminated-union narrowing). Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL — SchemaSpec with per-property `required: true` booleans, type-level InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and defineTool() so first-party tools get typed execute(args) with zero casts (raw JSON Schema still accepted for MCP interop; chosen over schemastery because it targets JSON Schema generation directly). echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
ctx.tools.register(defineTool({
name: 'echo',
description: 'echo back',
Document the codebase thoroughly and tighten type safety Docs: per-folder README.md for packages/ (family overview + one per package: service, events, API, extension points, TODOs), examples/, and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md symlinks) for packages/ and vendor/; module-level doc comments in every packages/*/src file; richer JSDoc on all exported API (event side effects, disposal contracts, error behavior). Root AGENTS.md gains a "Type Safety and Documentation" policy section: the codebase aims to be very type-safe and well documented; type gymnastics are acceptable in core packages when they improve plugin-author DX; verbose docs are fine as long as they stay strictly in sync with the code. Type safety: removed the upstream-inherited "noImplicitAny": false from tsconfig.base.json — packages/* now compile under full strict mode; vendor/loader and vendor/include set it locally (vendor/cordis already did). Eliminated every `: any` / `as any` from packages and examples (catch clauses use unknown + a CodedError narrowing type; event data access uses discriminated-union narrowing). Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL — SchemaSpec with per-property `required: true` booleans, type-level InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and defineTool() so first-party tools get typed execute(args) with zero casts (raw JSON Schema still accepted for MCP interop; chosen over schemastery because it targets JSON Schema generation directly). echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
parameters: { text: { type: 'string' } },
async execute(args) {
return [{ type: 'text', text: `echo: ${args.text}` }]
},
Document the codebase thoroughly and tighten type safety Docs: per-folder README.md for packages/ (family overview + one per package: service, events, API, extension points, TODOs), examples/, and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md symlinks) for packages/ and vendor/; module-level doc comments in every packages/*/src file; richer JSDoc on all exported API (event side effects, disposal contracts, error behavior). Root AGENTS.md gains a "Type Safety and Documentation" policy section: the codebase aims to be very type-safe and well documented; type gymnastics are acceptable in core packages when they improve plugin-author DX; verbose docs are fine as long as they stay strictly in sync with the code. Type safety: removed the upstream-inherited "noImplicitAny": false from tsconfig.base.json — packages/* now compile under full strict mode; vendor/loader and vendor/include set it locally (vendor/cordis already did). Eliminated every `: any` / `as any` from packages and examples (catch clauses use unknown + a CodedError narrowing type; event data access uses discriminated-union narrowing). Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL — SchemaSpec with per-property `required: true` booleans, type-level InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and defineTool() so first-party tools get typed execute(args) with zero casts (raw JSON Schema still accepted for MCP interop; chosen over schemastery because it targets JSON Schema generation directly). echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
}))
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'use the tool')
await waitForIdle(ctx, agent)
// two model calls happened (tool-call step, then final step)
expect(adapter.requests).toHaveLength(2)
// the second request's derived history contains the tool result
const secondMessages = adapter.requests[1]!.messages
const toolResultMessage = secondMessages.find(m =>
m.content.some(b => b.type === 'tool-result'))
expect(toolResultMessage).toBeDefined()
const block = toolResultMessage!.content.find(b => b.type === 'tool-result')!
expect(block).toMatchObject({ toolCallId: 'c1', isError: false })
expect((block).content).toEqual([{ type: 'text', text: 'echo: ping' }])
// session log records call + result
const types = agent.session.events.map(e => e.type)
expect(types).toContain('tool/call')
expect(types).toContain('tool/result')
})
it('threads a tool-attached meta (execute object return) onto the tool/result event', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'writer', { path: 'a.txt' }, 'writing'),
textResponse('done'),
])
const ctx = await harness(adapter)
// A tool that returns the { content, meta } object form: the loop must
// persist `meta` on the tool/result event so a UI reproduces the card on replay.
ctx.tools.register(defineTool({
name: 'writer',
description: 'writes a file',
parameters: { path: { type: 'string' } },
async execute() {
return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] } }
},
}))
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'use the tool')
await waitForIdle(ctx, agent)
const toolResult = agent.session.events.find(e => e.type === 'tool/result')
expect(toolResult?.type === 'tool/result' && toolResult.data.meta)
.toEqual({ diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] })
})
it('renders harness identity, then the persona, then tool guidance — with {{variables}} resolved', async () => {
const adapter = new MockAdapter([textResponse('ok')])
// The persona is a TEMPLATE: {{model}} is the loop-registered variable
// projecting this agent's configured model, so the model knows its own name.
const ctx = await harness(adapter, 'You are a test agent on {{model}}.')
ctx.systemPrompt.section({ name: 'tool:noop', order: 100, text: 'Use the noop tool wisely.' })
Document the codebase thoroughly and tighten type safety Docs: per-folder README.md for packages/ (family overview + one per package: service, events, API, extension points, TODOs), examples/, and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md symlinks) for packages/ and vendor/; module-level doc comments in every packages/*/src file; richer JSDoc on all exported API (event side effects, disposal contracts, error behavior). Root AGENTS.md gains a "Type Safety and Documentation" policy section: the codebase aims to be very type-safe and well documented; type gymnastics are acceptable in core packages when they improve plugin-author DX; verbose docs are fine as long as they stay strictly in sync with the code. Type safety: removed the upstream-inherited "noImplicitAny": false from tsconfig.base.json — packages/* now compile under full strict mode; vendor/loader and vendor/include set it locally (vendor/cordis already did). Eliminated every `: any` / `as any` from packages and examples (catch clauses use unknown + a CodedError narrowing type; event data access uses discriminated-union narrowing). Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL — SchemaSpec with per-property `required: true` booleans, type-level InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and defineTool() so first-party tools get typed execute(args) with zero casts (raw JSON Schema still accepted for MCP interop; chosen over schemastery because it targets JSON Schema generation directly). echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
ctx.tools.register(defineTool({
name: 'noop',
description: 'does nothing',
Document the codebase thoroughly and tighten type safety Docs: per-folder README.md for packages/ (family overview + one per package: service, events, API, extension points, TODOs), examples/, and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md symlinks) for packages/ and vendor/; module-level doc comments in every packages/*/src file; richer JSDoc on all exported API (event side effects, disposal contracts, error behavior). Root AGENTS.md gains a "Type Safety and Documentation" policy section: the codebase aims to be very type-safe and well documented; type gymnastics are acceptable in core packages when they improve plugin-author DX; verbose docs are fine as long as they stay strictly in sync with the code. Type safety: removed the upstream-inherited "noImplicitAny": false from tsconfig.base.json — packages/* now compile under full strict mode; vendor/loader and vendor/include set it locally (vendor/cordis already did). Eliminated every `: any` / `as any` from packages and examples (catch clauses use unknown + a CodedError narrowing type; event data access uses discriminated-union narrowing). Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL — SchemaSpec with per-property `required: true` booleans, type-level InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and defineTool() so first-party tools get typed execute(args) with zero casts (raw JSON Schema still accepted for MCP interop; chosen over schemastery because it targets JSON Schema generation directly). echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
parameters: {},
async execute() {
return []
},
Document the codebase thoroughly and tighten type safety Docs: per-folder README.md for packages/ (family overview + one per package: service, events, API, extension points, TODOs), examples/, and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md symlinks) for packages/ and vendor/; module-level doc comments in every packages/*/src file; richer JSDoc on all exported API (event side effects, disposal contracts, error behavior). Root AGENTS.md gains a "Type Safety and Documentation" policy section: the codebase aims to be very type-safe and well documented; type gymnastics are acceptable in core packages when they improve plugin-author DX; verbose docs are fine as long as they stay strictly in sync with the code. Type safety: removed the upstream-inherited "noImplicitAny": false from tsconfig.base.json — packages/* now compile under full strict mode; vendor/loader and vendor/include set it locally (vendor/cordis already did). Eliminated every `: any` / `as any` from packages and examples (catch clauses use unknown + a CodedError narrowing type; event data access uses discriminated-union narrowing). Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL — SchemaSpec with per-property `required: true` booleans, type-level InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and defineTool() so first-party tools get typed execute(args) with zero casts (raw JSON Schema still accepted for MCP interop; chosen over schemastery because it targets JSON Schema generation directly). echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
}))
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'hi')
await waitForIdle(ctx, agent)
const request = adapter.requests[0]
expect(request!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a test agent on mock.\n\nUse the noop tool wisely.')
expect(request!.tools?.map(t => t.name)).toEqual(['noop'])
})
it('resolves {{cwd}} from the agent session workspace (factory create with meta.cwd)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter, 'Working in {{cwd}}.')
const handle = await ctx.agents.create({
sessionId: SessionId('s-cwd'),
meta: { cwd: '/work/space' },
2026-07-14 21:57:52 +08:00
agentOptions: { provider: 'mock', model: 'mock' },
})
2026-07-14 02:32:35 +08:00
const agent = handle.agent
send(agent, 'hi')
await waitForIdle(ctx, agent)
expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nWorking in /work/space.')
})
it('contains a strict-variable render failure: the turn errors, the loop keeps serving turns', async () => {
2026-07-12 03:36:43 +08:00
// A missing cwd variable must fail one turn without preventing a later valid turn.
const adapter = new MockAdapter([textResponse('ok after rescue')])
const ctx = await harness(adapter, 'In {{cwd}}.')
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'hi')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0) // the request was never sent
expect(errors.some(e => e.message.includes('no value for this assembly'))).toBe(true)
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error')
// The loop survived: a waterfall listener rescues {{cwd}} and the SAME
// agent completes a real model turn.
ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
assembly.variables['cwd'] = '/rescued'
return next()
})
send(agent, 'again')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nIn /rescued.')
const turnEnds = agent.session.events.filter(e => e.type === 'turn/end')
expect(turnEnds).toHaveLength(2)
expect(turnEnds[1]?.type === 'turn/end' && turnEnds[1].data.reason.kind).toBe('completed')
})
it('supports the model-via-agent/request path with a {{model}} persona: the supplier states it via the assemble waterfall', async () => {
// AgentOptions.model unset: the model arrives in the agent/request
// waterfall (the loop's documented fallback — see runStep's no-model
// error). {{model}} renders BEFORE that waterfall, so the SAME plugin
// states the fact early on system-prompt/assemble — the owner of a
// late-bound fact owns stating it wherever it is claimed.
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter, 'You run on {{model}}.')
ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
2026-07-14 21:57:52 +08:00
assembly.variables['provider'] = 'mock'
assembly.variables['model'] = 'mock'
return next()
})
loop: every request is built from the log — boundary snapshot, header events, config-only waterfall The loop is now transmission-stateless; a request is a pure function of (session log, this step's rendered assembly, current AgentOptions): - The reconstruction boundary is step/start: the messages snapshot is taken in the same synchronous frame immediately before the step/start append, so the request's messages are exactly the derivation over events[0..stepStartSeq) — an inject() from an agent/request listener (or any concurrent task) lands after the boundary and joins the NEXT request. This changes behavior for a synchronous step/start session/event listener that appends content (master derived after the append, so such a listener could reach the current request): agent/pre-step is the sanctioned seam for current-request content. - agent/request is re-typed to config-only: (agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig. The frozen seed comes from AgentOptions on a loop instance's first request (explicit options beat the logged baseline — fork overrides and resume reconfiguration stay correct) and from the log's folded header afterwards; listeners return a replacement to switch. Content shaping through the request is no longer expressible — model-visible content flows through the log channels. - recordRequestHeader appends whatever header event the request owes the log before dispatch: an 'initial'/'resume' snapshot anchoring each loop instance, a round-trip-verified delta on change, a 'fallback' snapshot when the encoding cannot express it. Session.requestHeader() is the log's incrementally-folded baseline. - Requests are deep-frozen before dispatch (deepFreeze exempts the AbortSignal — freezing one breaks AbortController.abort() outright); frozen + sessionId is the loop-built marker the dev invariant keys on. Ported from #162 and re-anchored on the log: the append-extension / frozen-end-to-end / compaction-resend / prompt-change property tests, plus new specs for the boundary semantics, resume anchoring, and the end-to-end theorem (every recorded request rebuilds byte-equal from the log alone). Live cache-hit e2e (request-cache.e2e.ts) verified against the real DeepSeek API. Snapshot goldens intentionally stale until the single re-record after the compact/summary envelope lands.
2026-07-06 03:07:34 +08:00
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => {
2026-07-14 21:57:52 +08:00
return { ...config, provider: 'mock', model: 'mock' }
})
const agent = ctx.agentLoop.create(SessionId('a-late-model'), {})
send(agent, 'hi')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(adapter.requests[0]!.model).toBe('mock')
expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou run on mock.')
})
it.each([
['BigInt', { n: 1n }],
['Map', new Map([['key', 'value']])],
['class instance', new (class ResultMeta { x = 1 })()],
])('normalizes non-JSON tool meta (%s) before the durable result commit', async (_kind, meta) => {
const adapter = new MockAdapter([
toolCallResponse('bad-meta-call', 'bad-meta', {}, 'calling'),
textResponse('recovered'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
name: 'bad-meta',
description: 'returns invalid durable metadata',
parameters: {},
execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }),
}))
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('bad-meta-agent'), { provider: 'mock', model: 'mock' })
send(agent, 'use the tool')
await waitForIdle(ctx, agent)
const result = agent.session.events.find(event => event.type === 'tool/result')
expect(result?.type).toBe('tool/result')
if (result?.type === 'tool/result') {
expect(result.data.callId).toBe('bad-meta-call')
expect(result.data.isError).toBe(true)
expect(result.data.meta).toBeUndefined()
expect(result.data.content).toEqual([{
type: 'text',
text: 'Error: tool result must be losslessly JSON-serializable',
}])
}
// The normalized failure was durably logged and fed back to the model; the
// turn continued normally instead of failing after an apparent success.
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('losslessly JSON-serializable')
})
it('omits the system field when a system-prompt/assemble veto empties the assembly', async () => {
// The documented escape valve: a deployment that must drop the harness
// openers short-circuits the assemble waterfall; the request then carries
// NO system field at all (not an empty string).
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
ctx.on('system-prompt/assemble', async () => ({ sections: [], tools: [], variables: {} }))
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a-no-system'), { provider: 'mock', model: 'mock' })
send(agent, 'hi')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect('system' in adapter.requests[0]!).toBe(false)
})
refactor(events): remove the agent/stream-chunk mirror of assistant/chunk The loop recorded every model token delta as a durable `assistant/chunk` session event AND emitted an identical live `agent/stream-chunk` Cordis event one line later. Same StreamChunk, same turn/step; the emit added only the live Agent handle, which the sole consumer discarded. This is the boundary-mirror duplication the event-domain work removed for turn/step boundaries, applied to the token stream — a follow-up the boundary RFC explicitly deferred. The premise is settled: chunk persistence is authoritative (the proposal to stop persisting chunks was rejected — replay/snapshots depend on it), so `assistant/chunk` on `session/event` is the load-bearing token stream and `agent/stream-chunk` is pure redundancy. - Remove the `agent/stream-chunk` declaration + emit; drop the now-unused StreamChunk import from dsh-agent's types. - Migrate `dsh-ui-stdio` (the only live consumer; ACP already reads assistant/chunk off session/event) to render assistant/chunk in its existing session/event listener. Consolidating to one listener also makes the inReasoning dim-SGR flag deterministic across chunk/boundary events (they no longer race across two listeners). - Repoint the agent-loop tests (cancel/loop) and ui-stdio tests to the session/event assistant/chunk feed. - New RFC (implemented/simplification/2026-07-02-remove-stream-chunk-mirror); amend the boundary RFC's retained-list entry to cross-link; update architecture, cookbook, event-domain-semantics, the ACP proposal, and the regenerated cordis catalog. Snapshot goldens unchanged (ACP never used the mirror), confirming no editor-facing transcript change.
2026-07-02 23:42:16 +08:00
it('records raw chunks for replay as assistant/chunk session events', async () => {
const adapter = new MockAdapter([textResponse('abc')])
const ctx = await harness(adapter)
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'hi')
await waitForIdle(ctx, agent)
const chunkEvents = agent.session.events.filter(e => e.type === 'assistant/chunk')
// textResponse('abc') = block-start + 3 deltas + block-end + usage + finish = 7
expect(chunkEvents).toHaveLength(7)
// replay: chunk events alone re-assemble to the recorded assistant message
const deltaText = chunkEvents
Document the codebase thoroughly and tighten type safety Docs: per-folder README.md for packages/ (family overview + one per package: service, events, API, extension points, TODOs), examples/, and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md symlinks) for packages/ and vendor/; module-level doc comments in every packages/*/src file; richer JSDoc on all exported API (event side effects, disposal contracts, error behavior). Root AGENTS.md gains a "Type Safety and Documentation" policy section: the codebase aims to be very type-safe and well documented; type gymnastics are acceptable in core packages when they improve plugin-author DX; verbose docs are fine as long as they stay strictly in sync with the code. Type safety: removed the upstream-inherited "noImplicitAny": false from tsconfig.base.json — packages/* now compile under full strict mode; vendor/loader and vendor/include set it locally (vendor/cordis already did). Eliminated every `: any` / `as any` from packages and examples (catch clauses use unknown + a CodedError narrowing type; event data access uses discriminated-union narrowing). Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL — SchemaSpec with per-property `required: true` booleans, type-level InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and defineTool() so first-party tools get typed execute(args) with zero casts (raw JSON Schema still accepted for MCP interop; chosen over schemastery because it targets JSON Schema generation directly). echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
.flatMap(e => e.type === 'assistant/chunk' ? [e.data.chunk] : [])
.filter((c: StreamChunk): c is Extract<StreamChunk, { type: 'text-delta' }> => c.type === 'text-delta')
.map(c => c.text)
.join('')
expect(deltaText).toBe('abc')
})
it('injects steering between steps and continues the turn', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'slow', {}),
textResponse('addressed the steering'),
])
const ctx = await harness(adapter)
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
Document the codebase thoroughly and tighten type safety Docs: per-folder README.md for packages/ (family overview + one per package: service, events, API, extension points, TODOs), examples/, and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md symlinks) for packages/ and vendor/; module-level doc comments in every packages/*/src file; richer JSDoc on all exported API (event side effects, disposal contracts, error behavior). Root AGENTS.md gains a "Type Safety and Documentation" policy section: the codebase aims to be very type-safe and well documented; type gymnastics are acceptable in core packages when they improve plugin-author DX; verbose docs are fine as long as they stay strictly in sync with the code. Type safety: removed the upstream-inherited "noImplicitAny": false from tsconfig.base.json — packages/* now compile under full strict mode; vendor/loader and vendor/include set it locally (vendor/cordis already did). Eliminated every `: any` / `as any` from packages and examples (catch clauses use unknown + a CodedError narrowing type; event data access uses discriminated-union narrowing). Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL — SchemaSpec with per-property `required: true` booleans, type-level InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and defineTool() so first-party tools get typed execute(args) with zero casts (raw JSON Schema still accepted for MCP interop; chosen over schemastery because it targets JSON Schema generation directly). echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
ctx.tools.register(defineTool({
name: 'slow',
description: '',
Document the codebase thoroughly and tighten type safety Docs: per-folder README.md for packages/ (family overview + one per package: service, events, API, extension points, TODOs), examples/, and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md symlinks) for packages/ and vendor/; module-level doc comments in every packages/*/src file; richer JSDoc on all exported API (event side effects, disposal contracts, error behavior). Root AGENTS.md gains a "Type Safety and Documentation" policy section: the codebase aims to be very type-safe and well documented; type gymnastics are acceptable in core packages when they improve plugin-author DX; verbose docs are fine as long as they stay strictly in sync with the code. Type safety: removed the upstream-inherited "noImplicitAny": false from tsconfig.base.json — packages/* now compile under full strict mode; vendor/loader and vendor/include set it locally (vendor/cordis already did). Eliminated every `: any` / `as any` from packages and examples (catch clauses use unknown + a CodedError narrowing type; event data access uses discriminated-union narrowing). Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL — SchemaSpec with per-property `required: true` booleans, type-level InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and defineTool() so first-party tools get typed execute(args) with zero casts (raw JSON Schema still accepted for MCP interop; chosen over schemastery because it targets JSON Schema generation directly). echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
parameters: {},
async execute() {
// steer while the turn is running (during tool execution)
agent.steer([{ type: 'text', text: 'change of plans' }])
return [{ type: 'text', text: 'tool done' }]
},
Document the codebase thoroughly and tighten type safety Docs: per-folder README.md for packages/ (family overview + one per package: service, events, API, extension points, TODOs), examples/, and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md symlinks) for packages/ and vendor/; module-level doc comments in every packages/*/src file; richer JSDoc on all exported API (event side effects, disposal contracts, error behavior). Root AGENTS.md gains a "Type Safety and Documentation" policy section: the codebase aims to be very type-safe and well documented; type gymnastics are acceptable in core packages when they improve plugin-author DX; verbose docs are fine as long as they stay strictly in sync with the code. Type safety: removed the upstream-inherited "noImplicitAny": false from tsconfig.base.json — packages/* now compile under full strict mode; vendor/loader and vendor/include set it locally (vendor/cordis already did). Eliminated every `: any` / `as any` from packages and examples (catch clauses use unknown + a CodedError narrowing type; event data access uses discriminated-union narrowing). Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL — SchemaSpec with per-property `required: true` booleans, type-level InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and defineTool() so first-party tools get typed execute(args) with zero casts (raw JSON Schema still accepted for MCP interop; chosen over schemastery because it targets JSON Schema generation directly). echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
}))
send(agent, 'start')
await waitForIdle(ctx, agent)
const types = agent.session.events.map(e => e.type)
expect(types).toContain('steering/message')
// steering recorded before the second step's request derived its history
const steeringSeq = agent.session.events.find(e => e.type === 'steering/message')!.seq
const secondStepStart = agent.session.events.filter(e => e.type === 'step/start')[1]
expect(secondStepStart).toBeDefined()
expect(steeringSeq).toBeLessThan(secondStepStart!.seq)
// the second model request saw the steering content
const secondRequest = adapter.requests[1]
const flat = JSON.stringify(secondRequest!.messages)
expect(flat).toContain('change of plans')
})
it('same-tick idle steering inherits one-send-one-turn FIFO behavior', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(ctx, agent)
agent.steer([{ type: 'text', text: 'first idle steer' }])
agent.steer([{ type: 'text', text: 'second idle steer' }])
await idle
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
expect(agent.session.events
.filter(event => event.type === 'user/message')
.map(event => event.data.content)).toEqual([
[{ type: 'text', text: 'first idle steer' }],
[{ type: 'text', text: 'second idle steer' }],
])
expect(adapter.requests).toHaveLength(2)
})
it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } })
// The idle inject records a self-contained turn (turn/start → context/message
// → turn/end) so the event stays turn-enclosed, but does NOT run the model.
await new Promise(r => setTimeout(r, 20))
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(0)
const injectedTurn = agent.session.events.filter(e => e.type === 'turn/start')
expect(injectedTurn).toHaveLength(1)
const it0 = injectedTurn[0]!
expect(it0.type === 'turn/start' && it0.data.trigger.kind).toBe('injection')
expect(agent.session.events.at(-1)!.type).toBe('turn/end') // turn-enclosed
send(agent, 'go')
await waitForIdle(ctx, agent)
const flat = JSON.stringify(adapter.requests[0]!.messages)
expect(flat).toContain('file changed: a.ts')
expect(flat).not.toContain('<context source=')
})
it('inject() persists structured context content verbatim with durable hidden meta', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('raw-context'), { provider: 'mock', model: 'mock' })
const text = '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>'
const meta = {
kind: 'workspace-instructions',
version: 1,
changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 'abc123' }],
}
agent.inject([{ type: 'text', text }], {
source: { kind: 'plugin', plugin: 'workspace-context' },
meta,
})
send(agent, 'go')
await waitForIdle(ctx, agent)
const contextEvent = agent.session.events.find(event => event.type === 'context/message')
expect(contextEvent?.type === 'context/message' && contextEvent.data).toMatchObject({ meta })
const requestText = JSON.stringify(adapter.requests[0]!.messages)
expect(requestText).toContain('Additional instructions from: pkg/AGENTS.md')
expect(requestText).not.toContain('<context source=')
})
it('defers inject() during tool execution until after the tool result', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'noticer', {}, 'calling'),
textResponse('done'),
])
const ctx = await harness(adapter)
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let visibleDuringTool = false
const meta = { kind: 'deferred-test', version: 1 }
ctx.tools.register(defineTool({
name: 'noticer',
description: 'injects a notice',
parameters: {},
async execute() {
await Promise.resolve()
const first = { type: 'text' as const, text: 'mid-turn notice' }
agent.inject([first], {
source: { kind: 'plugin', plugin: 'x' },
meta,
})
first.text = 'mutated after inject'
agent.inject([{ type: 'text', text: 'second notice' }], { source: { kind: 'plugin', plugin: 'x' } })
visibleDuringTool = agent.session.events.some(e => e.type === 'context/message')
return [{ type: 'text', text: 'ok' }]
},
}))
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(visibleDuringTool).toBe(false)
// The injection stays in the open turn, but its user-role context cannot
// split the assistant tool call from the provider's tool-result message.
const turnStarts = agent.session.events.filter(e => e.type === 'turn/start')
expect(turnStarts).toHaveLength(1)
const ts0 = turnStarts[0]!
expect(ts0.type === 'turn/start' && ts0.data.trigger.kind).toBe('message')
const result = agent.session.events.find(e => e.type === 'tool/result')!
const contexts = agent.session.events.filter(e => e.type === 'context/message')
expect(contexts).toHaveLength(2)
expect(result.seq).toBeLessThan(contexts[0]!.seq)
expect(contexts[0]?.type === 'context/message' && contexts[0].data).toMatchObject({
meta,
})
expect(contexts.flatMap(event => event.type === 'context/message' ? event.data.content : []))
.toEqual([
{ type: 'text', text: 'mid-turn notice' },
{ type: 'text', text: 'second notice' },
])
const secondRequest = adapter.requests[1]!.messages
const resultIndex = secondRequest.findIndex(message =>
message.content.some(block => block.type === 'tool-result'))
const contextIndexes = secondRequest.flatMap((message, index) =>
message.content.some(block => block.type === 'text'
&& (block.text.includes('mid-turn notice') || block.text.includes('second notice')))
? [index]
: [])
expect(resultIndex).toBeGreaterThanOrEqual(0)
expect(contextIndexes).toHaveLength(2)
expect(contextIndexes.every(index => index > resultIndex)).toBe(true)
})
it('rejects non-JSON context before it enters the active tool-batch FIFO', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'invalid-injector', {}, 'calling'),
textResponse('done'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('invalid-context'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
name: 'invalid-injector',
description: 'attempts an invalid context injection',
parameters: {},
async execute() {
expect(() => {
agent.inject([{ type: 'text', text: 'invalid' }], {
source: { kind: 'plugin', plugin: 'test' },
meta: { bigint: 1n } as never,
})
}).toThrow('agent context must be losslessly JSON-serializable')
return [{ type: 'text', text: 'rejected invalid context' }]
},
}))
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(agent.session.events.some(event => event.type === 'context/message')).toBe(false)
})
it('agent/turn-continuation can force-continue (/loop pattern) and force-stop', async () => {
// force-continue: model never calls tools, but a plugin forces 3 steps
const adapter = new MockAdapter([
textResponse('step 1'),
textResponse('step 2'),
textResponse('step 3'),
])
const ctx = await harness(adapter)
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let steps = 0
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => {
feat(events): interception seams — the typed-Decision surface for hooks Reshape the agent's interception surface so every seam returns a small, typed Decision union, and the set covers the hook points a CC/Codex bridge (and a native plugin) needs. "Native hooks" are not a package — a native hook is just a cordis plugin on these canonical events; the bridges (a later PR) only translate an external protocol onto the same surface. dsh-agent: - NEW agent/session-start(agent, source) emit (once before turn 1; SessionStartSource startup|resume|clear|compact) — a pure notification, seeds context via inject(). - NEW agent/prompt-submit waterfall → PromptDecision (allow, optionally rewriting the prompt or attaching additionalContext, or block). - RESHAPE agent/turn-continuation boolean → ContinuationDecision ({action:'stop'} | {action:'continue', reason?}; a continue reason is recorded as next-step steering). - New HookContext envelope (required source — inject() would mislabel a missing one). dsh-tools: split the single tools/execute waterfall into tools/pre-execute (PreToolDecision allow/deny/ask gate) and tools/post-execute (PostToolDecision accept/block, optionally replacing content or attaching additionalContext). Core dispatch sits between as plain code; the tool body keeps its inner try/catch so a thrown tool still reaches post-execute as an isError. ToolExecutionResult gains additionalContext (ferried to the loop's per-step buffer). Input rewrite is deliberately NOT offered (a proposed RFC designs it consistently). dsh-session: new `rejected` TurnEndReason — a turn whose whole prompt batch was blocked by prompt-submit. agent-loop firing points: session-start emitted at create (source threaded — startup for create/fork, resume for resume()); prompt-submit per drained message with the always-open-turn rule (a fully-blocked batch is a zero-step rejected turn); the continuation reshape; post-tool additionalContext buffered and appended after all tool/results (adjacency). ACP codec maps rejected→cancelled. A worked native-plugin example (interception.spec.ts) proves all four seams compose end-to-end through the real loop with NO hook/* events (those belong to the bridge lib). All existing tools/execute + turn-continuation tests migrated. The tool-subagent abort test now aborts after a microtask so it still exercises the live onAbort bridge (execute() awaits pre-execute before the body runs). RFCs: implemented/feature/2026-06-30-interception-seams.md (the reshape) + proposed/feature/2026-06-30-pre-tool-input-rewrite.md (the deferred rewrite design).
2026-06-30 17:11:18 +08:00
if (steps < 3) return { action: 'continue' as const }
return next()
})
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(steps).toBe(3)
expect(adapter.requests).toHaveLength(3)
})
it('agent/turn-continuation can veto continuation despite tool calls (budget-guard pattern)', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'x' })])
const ctx = await harness(adapter)
Document the codebase thoroughly and tighten type safety Docs: per-folder README.md for packages/ (family overview + one per package: service, events, API, extension points, TODOs), examples/, and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md symlinks) for packages/ and vendor/; module-level doc comments in every packages/*/src file; richer JSDoc on all exported API (event side effects, disposal contracts, error behavior). Root AGENTS.md gains a "Type Safety and Documentation" policy section: the codebase aims to be very type-safe and well documented; type gymnastics are acceptable in core packages when they improve plugin-author DX; verbose docs are fine as long as they stay strictly in sync with the code. Type safety: removed the upstream-inherited "noImplicitAny": false from tsconfig.base.json — packages/* now compile under full strict mode; vendor/loader and vendor/include set it locally (vendor/cordis already did). Eliminated every `: any` / `as any` from packages and examples (catch clauses use unknown + a CodedError narrowing type; event data access uses discriminated-union narrowing). Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL — SchemaSpec with per-property `required: true` booleans, type-level InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and defineTool() so first-party tools get typed execute(args) with zero casts (raw JSON Schema still accepted for MCP interop; chosen over schemastery because it targets JSON Schema generation directly). echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
ctx.tools.register(defineTool({
name: 'echo',
description: '',
Document the codebase thoroughly and tighten type safety Docs: per-folder README.md for packages/ (family overview + one per package: service, events, API, extension points, TODOs), examples/, and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md symlinks) for packages/ and vendor/; module-level doc comments in every packages/*/src file; richer JSDoc on all exported API (event side effects, disposal contracts, error behavior). Root AGENTS.md gains a "Type Safety and Documentation" policy section: the codebase aims to be very type-safe and well documented; type gymnastics are acceptable in core packages when they improve plugin-author DX; verbose docs are fine as long as they stay strictly in sync with the code. Type safety: removed the upstream-inherited "noImplicitAny": false from tsconfig.base.json — packages/* now compile under full strict mode; vendor/loader and vendor/include set it locally (vendor/cordis already did). Eliminated every `: any` / `as any` from packages and examples (catch clauses use unknown + a CodedError narrowing type; event data access uses discriminated-union narrowing). Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL — SchemaSpec with per-property `required: true` booleans, type-level InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and defineTool() so first-party tools get typed execute(args) with zero casts (raw JSON Schema still accepted for MCP interop; chosen over schemastery because it targets JSON Schema generation directly). echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
parameters: { text: { type: 'string' } },
async execute(args) {
return [{ type: 'text', text: String(args.text) }]
},
Document the codebase thoroughly and tighten type safety Docs: per-folder README.md for packages/ (family overview + one per package: service, events, API, extension points, TODOs), examples/, and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md symlinks) for packages/ and vendor/; module-level doc comments in every packages/*/src file; richer JSDoc on all exported API (event side effects, disposal contracts, error behavior). Root AGENTS.md gains a "Type Safety and Documentation" policy section: the codebase aims to be very type-safe and well documented; type gymnastics are acceptable in core packages when they improve plugin-author DX; verbose docs are fine as long as they stay strictly in sync with the code. Type safety: removed the upstream-inherited "noImplicitAny": false from tsconfig.base.json — packages/* now compile under full strict mode; vendor/loader and vendor/include set it locally (vendor/cordis already did). Eliminated every `: any` / `as any` from packages and examples (catch clauses use unknown + a CodedError narrowing type; event data access uses discriminated-union narrowing). Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL — SchemaSpec with per-property `required: true` booleans, type-level InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and defineTool() so first-party tools get typed execute(args) with zero casts (raw JSON Schema still accepted for MCP interop; chosen over schemastery because it targets JSON Schema generation directly). echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
}))
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
feat(events): interception seams — the typed-Decision surface for hooks Reshape the agent's interception surface so every seam returns a small, typed Decision union, and the set covers the hook points a CC/Codex bridge (and a native plugin) needs. "Native hooks" are not a package — a native hook is just a cordis plugin on these canonical events; the bridges (a later PR) only translate an external protocol onto the same surface. dsh-agent: - NEW agent/session-start(agent, source) emit (once before turn 1; SessionStartSource startup|resume|clear|compact) — a pure notification, seeds context via inject(). - NEW agent/prompt-submit waterfall → PromptDecision (allow, optionally rewriting the prompt or attaching additionalContext, or block). - RESHAPE agent/turn-continuation boolean → ContinuationDecision ({action:'stop'} | {action:'continue', reason?}; a continue reason is recorded as next-step steering). - New HookContext envelope (required source — inject() would mislabel a missing one). dsh-tools: split the single tools/execute waterfall into tools/pre-execute (PreToolDecision allow/deny/ask gate) and tools/post-execute (PostToolDecision accept/block, optionally replacing content or attaching additionalContext). Core dispatch sits between as plain code; the tool body keeps its inner try/catch so a thrown tool still reaches post-execute as an isError. ToolExecutionResult gains additionalContext (ferried to the loop's per-step buffer). Input rewrite is deliberately NOT offered (a proposed RFC designs it consistently). dsh-session: new `rejected` TurnEndReason — a turn whose whole prompt batch was blocked by prompt-submit. agent-loop firing points: session-start emitted at create (source threaded — startup for create/fork, resume for resume()); prompt-submit per drained message with the always-open-turn rule (a fully-blocked batch is a zero-step rejected turn); the continuation reshape; post-tool additionalContext buffered and appended after all tool/results (adjacency). ACP codec maps rejected→cancelled. A worked native-plugin example (interception.spec.ts) proves all four seams compose end-to-end through the real loop with NO hook/* events (those belong to the bridge lib). All existing tools/execute + turn-continuation tests migrated. The tool-subagent abort test now aborts after a microtask so it still exercises the live onAbort bridge (execute() awaits pre-execute before the body runs). RFCs: implemented/feature/2026-06-30-interception-seams.md (the reshape) + proposed/feature/2026-06-30-pre-tool-input-rewrite.md (the deferred rewrite design).
2026-06-30 17:11:18 +08:00
ctx.on('agent/turn-continuation', async () => ({ action: 'stop' }) as const)
send(agent, 'go')
await waitForIdle(ctx, agent)
// only one model call despite the tool call requesting a follow-up
expect(adapter.requests).toHaveLength(1)
// tool still executed before the decision
expect(agent.session.events.some(e => e.type === 'tool/result')).toBe(true)
})
loop: every request is built from the log — boundary snapshot, header events, config-only waterfall The loop is now transmission-stateless; a request is a pure function of (session log, this step's rendered assembly, current AgentOptions): - The reconstruction boundary is step/start: the messages snapshot is taken in the same synchronous frame immediately before the step/start append, so the request's messages are exactly the derivation over events[0..stepStartSeq) — an inject() from an agent/request listener (or any concurrent task) lands after the boundary and joins the NEXT request. This changes behavior for a synchronous step/start session/event listener that appends content (master derived after the append, so such a listener could reach the current request): agent/pre-step is the sanctioned seam for current-request content. - agent/request is re-typed to config-only: (agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig. The frozen seed comes from AgentOptions on a loop instance's first request (explicit options beat the logged baseline — fork overrides and resume reconfiguration stay correct) and from the log's folded header afterwards; listeners return a replacement to switch. Content shaping through the request is no longer expressible — model-visible content flows through the log channels. - recordRequestHeader appends whatever header event the request owes the log before dispatch: an 'initial'/'resume' snapshot anchoring each loop instance, a round-trip-verified delta on change, a 'fallback' snapshot when the encoding cannot express it. Session.requestHeader() is the log's incrementally-folded baseline. - Requests are deep-frozen before dispatch (deepFreeze exempts the AbortSignal — freezing one breaks AbortController.abort() outright); frozen + sessionId is the loop-built marker the dev invariant keys on. Ported from #162 and re-anchored on the log: the append-extension / frozen-end-to-end / compaction-resend / prompt-change property tests, plus new specs for the boundary semantics, resume anchoring, and the end-to-end theorem (every recorded request rebuilds byte-equal from the log alone). Live cache-hit e2e (request-cache.e2e.ts) verified against the real DeepSeek API. Snapshot goldens intentionally stale until the single re-record after the compact/summary envelope lands.
2026-07-06 03:07:34 +08:00
it('agent/request waterfall switches models by returning a replacement config; the switch is logged', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
loop: every request is built from the log — boundary snapshot, header events, config-only waterfall The loop is now transmission-stateless; a request is a pure function of (session log, this step's rendered assembly, current AgentOptions): - The reconstruction boundary is step/start: the messages snapshot is taken in the same synchronous frame immediately before the step/start append, so the request's messages are exactly the derivation over events[0..stepStartSeq) — an inject() from an agent/request listener (or any concurrent task) lands after the boundary and joins the NEXT request. This changes behavior for a synchronous step/start session/event listener that appends content (master derived after the append, so such a listener could reach the current request): agent/pre-step is the sanctioned seam for current-request content. - agent/request is re-typed to config-only: (agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig. The frozen seed comes from AgentOptions on a loop instance's first request (explicit options beat the logged baseline — fork overrides and resume reconfiguration stay correct) and from the log's folded header afterwards; listeners return a replacement to switch. Content shaping through the request is no longer expressible — model-visible content flows through the log channels. - recordRequestHeader appends whatever header event the request owes the log before dispatch: an 'initial'/'resume' snapshot anchoring each loop instance, a round-trip-verified delta on change, a 'fallback' snapshot when the encoding cannot express it. Session.requestHeader() is the log's incrementally-folded baseline. - Requests are deep-frozen before dispatch (deepFreeze exempts the AbortSignal — freezing one breaks AbortController.abort() outright); frozen + sessionId is the loop-built marker the dev invariant keys on. Ported from #162 and re-anchored on the log: the append-extension / frozen-end-to-end / compaction-resend / prompt-change property tests, plus new specs for the boundary semantics, resume anchoring, and the end-to-end theorem (every recorded request rebuilds byte-equal from the log alone). Live cache-hit e2e (request-cache.e2e.ts) verified against the real DeepSeek API. Snapshot goldens intentionally stale until the single re-record after the compact/summary envelope lands.
2026-07-06 03:07:34 +08:00
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => {
// The seed is frozen — config is not a mutable per-call knob; a switch
// is proposed by returning a replacement, and the loop logs it.
expect(Object.isFrozen(config)).toBe(true)
expect(() => { (config as { model: string }).model = 'other-model' }).toThrow(TypeError)
return { ...config, model: 'other-model' }
})
send(agent, 'hi')
await waitForIdle(ctx, agent)
expect(adapter.requests[0]!.model).toBe('other-model')
loop: every request is built from the log — boundary snapshot, header events, config-only waterfall The loop is now transmission-stateless; a request is a pure function of (session log, this step's rendered assembly, current AgentOptions): - The reconstruction boundary is step/start: the messages snapshot is taken in the same synchronous frame immediately before the step/start append, so the request's messages are exactly the derivation over events[0..stepStartSeq) — an inject() from an agent/request listener (or any concurrent task) lands after the boundary and joins the NEXT request. This changes behavior for a synchronous step/start session/event listener that appends content (master derived after the append, so such a listener could reach the current request): agent/pre-step is the sanctioned seam for current-request content. - agent/request is re-typed to config-only: (agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig. The frozen seed comes from AgentOptions on a loop instance's first request (explicit options beat the logged baseline — fork overrides and resume reconfiguration stay correct) and from the log's folded header afterwards; listeners return a replacement to switch. Content shaping through the request is no longer expressible — model-visible content flows through the log channels. - recordRequestHeader appends whatever header event the request owes the log before dispatch: an 'initial'/'resume' snapshot anchoring each loop instance, a round-trip-verified delta on change, a 'fallback' snapshot when the encoding cannot express it. Session.requestHeader() is the log's incrementally-folded baseline. - Requests are deep-frozen before dispatch (deepFreeze exempts the AbortSignal — freezing one breaks AbortController.abort() outright); frozen + sessionId is the loop-built marker the dev invariant keys on. Ported from #162 and re-anchored on the log: the append-extension / frozen-end-to-end / compaction-resend / prompt-change property tests, plus new specs for the boundary semantics, resume anchoring, and the end-to-end theorem (every recorded request rebuilds byte-equal from the log alone). Live cache-hit e2e (request-cache.e2e.ts) verified against the real DeepSeek API. Snapshot goldens intentionally stale until the single re-record after the compact/summary envelope lands.
2026-07-06 03:07:34 +08:00
// The header event records what the request ACTUALLY used — the switch is
// a reconstructable fact, not silent drift.
const headerEvent = agent.session.events.find(e => e.type === 'request/header')
expect(headerEvent?.type === 'request/header' && headerEvent.data.header.config.model).toBe('other-model')
})
fix(compact): decide step-alignment from surface tool-pairing, fire compaction pre-step (CBR-001) Codex round 1 CBR-001: a head-anchored compaction checkpoint was mis-classified by the log-position step-alignment scan, so a second auto-compaction over a checkpoint-headed surface silently failed. Root cause: `isStepAlignedStart/End` scanned the LOG by seq, but a `replace` op lands a checkpoint at a high log seq whose SURFACE position is the head — its log neighbours (the open step's assistant/message) are not its surface neighbours, so the forward scan wrongly reported mid-step. Fix, per the agreed direction: - Replace the two log-position predicates with one surface-anchored helper `isToolPairingBalanced(nodes, events, beforeSeq)` in `dsh-session` (renamed step-boundary.ts → tool-pairing.ts). A cut is balanced when no unanswered tool-call precedes it on the surface; a region is collapsible iff both edges are balanced cuts. The open-tail and free-node cases fall out of the same counter. It also throws on a corrupt surface (a tool/result with no matching call). - Move compaction off the in-step seam to a new "pre-step" seam fired after turn/start and before step/start, so a compaction's log-only compact/* records and its replacement node land cleanly OUTSIDE any step (the honest structure crash-safety relies on). Renamed the event agent/pre-request → agent/pre-step and switched its dispatch from parallel → serial (listeners mutate the surface as a side effect; serial isolates them so concurrent appends can't interleave). Extended the catalog generator to accept @mode serial. Regression coverage: a real-loop test driving an auto-compaction asserts the landed checkpoint is a balanced cut on both sides; unit tests pin the checkpoint case, the mid-step injection case, multi-call steps, and the corrupt-surface guard. Proven red on the old log-position logic.
2026-06-26 13:51:01 +08:00
it('agent/pre-step fires once per step before the step is opened', async () => {
refactor(compact): turn-agnostic retention + dedicated agent/pre-request seam Reform the compaction blueprint so a runaway turn survives and the design stops drifting across review rounds: - Drop in-flight-turn protection ("layer 2"). Retention is a uniform tail→head whole-unit walk; the only structural guard is step-alignment. A single turn that alone exceeds the window now compacts its own early closed steps instead of being retained verbatim (the failure mode that motivated this). - Move auto-compaction off the agent/request waterfall onto a new awaited agent/pre-request loop seam, fired before history derivation. Compaction mutates the surface; the loop derives once from the result — no double-derive, and a listener structurally cannot act on not-yet-derived messages. - Tighten compactIfNeeded to required (session, system, model, signal). - Enforce a single-pass convergence invariant in resolveConfig: reject configs where summarizationMaxTokens + retainTokens exceeds the threshold, so a compaction can never immediately re-trigger. - Document the crash vs recoverable failure taxonomy; core session repair stays compaction-agnostic (a log-only orphaned compact/start is inert). - Wire dsh-compact-basic into examples/coding-agent and add a with-key compaction e2e (compaction's first real-world exercise + runaway net). - Rewrite the RFC to encode the blueprint and move it to implemented/. The runaway-turn snapshot is a named deferred follow-up: dsh-llm-replay cannot yet serve the interleaved summarization model call.
2026-06-26 08:59:33 +08:00
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', {}, 'calling echo'),
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
name: 'echo', description: 'echo', parameters: {},
async execute() { return [{ type: 'text', text: 'echoed' }] },
}))
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
refactor(compact): turn-agnostic retention + dedicated agent/pre-request seam Reform the compaction blueprint so a runaway turn survives and the design stops drifting across review rounds: - Drop in-flight-turn protection ("layer 2"). Retention is a uniform tail→head whole-unit walk; the only structural guard is step-alignment. A single turn that alone exceeds the window now compacts its own early closed steps instead of being retained verbatim (the failure mode that motivated this). - Move auto-compaction off the agent/request waterfall onto a new awaited agent/pre-request loop seam, fired before history derivation. Compaction mutates the surface; the loop derives once from the result — no double-derive, and a listener structurally cannot act on not-yet-derived messages. - Tighten compactIfNeeded to required (session, system, model, signal). - Enforce a single-pass convergence invariant in resolveConfig: reject configs where summarizationMaxTokens + retainTokens exceeds the threshold, so a compaction can never immediately re-trigger. - Document the crash vs recoverable failure taxonomy; core session repair stays compaction-agnostic (a log-only orphaned compact/start is inert). - Wire dsh-compact-basic into examples/coding-agent and add a with-key compaction e2e (compaction's first real-world exercise + runaway net). - Rewrite the RFC to encode the blueprint and move it to implemented/. The runaway-turn snapshot is a named deferred follow-up: dsh-llm-replay cannot yet serve the interleaved summarization model call.
2026-06-26 08:59:33 +08:00
const fires: { turn: number; step: number; signal: AbortSignal }[] = []
ctx.on('agent/pre-step', (subject, turn, step, signal) => {
if (subject === agent) fires.push({ turn, step, signal })
refactor(compact): turn-agnostic retention + dedicated agent/pre-request seam Reform the compaction blueprint so a runaway turn survives and the design stops drifting across review rounds: - Drop in-flight-turn protection ("layer 2"). Retention is a uniform tail→head whole-unit walk; the only structural guard is step-alignment. A single turn that alone exceeds the window now compacts its own early closed steps instead of being retained verbatim (the failure mode that motivated this). - Move auto-compaction off the agent/request waterfall onto a new awaited agent/pre-request loop seam, fired before history derivation. Compaction mutates the surface; the loop derives once from the result — no double-derive, and a listener structurally cannot act on not-yet-derived messages. - Tighten compactIfNeeded to required (session, system, model, signal). - Enforce a single-pass convergence invariant in resolveConfig: reject configs where summarizationMaxTokens + retainTokens exceeds the threshold, so a compaction can never immediately re-trigger. - Document the crash vs recoverable failure taxonomy; core session repair stays compaction-agnostic (a log-only orphaned compact/start is inert). - Wire dsh-compact-basic into examples/coding-agent and add a with-key compaction e2e (compaction's first real-world exercise + runaway net). - Rewrite the RFC to encode the blueprint and move it to implemented/. The runaway-turn snapshot is a named deferred follow-up: dsh-llm-replay cannot yet serve the interleaved summarization model call.
2026-06-26 08:59:33 +08:00
})
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(fires.map(({ turn, step }) => ({ turn, step }))).toEqual([
{ turn: 1, step: 1 },
{ turn: 1, step: 2 },
refactor(compact): turn-agnostic retention + dedicated agent/pre-request seam Reform the compaction blueprint so a runaway turn survives and the design stops drifting across review rounds: - Drop in-flight-turn protection ("layer 2"). Retention is a uniform tail→head whole-unit walk; the only structural guard is step-alignment. A single turn that alone exceeds the window now compacts its own early closed steps instead of being retained verbatim (the failure mode that motivated this). - Move auto-compaction off the agent/request waterfall onto a new awaited agent/pre-request loop seam, fired before history derivation. Compaction mutates the surface; the loop derives once from the result — no double-derive, and a listener structurally cannot act on not-yet-derived messages. - Tighten compactIfNeeded to required (session, system, model, signal). - Enforce a single-pass convergence invariant in resolveConfig: reject configs where summarizationMaxTokens + retainTokens exceeds the threshold, so a compaction can never immediately re-trigger. - Document the crash vs recoverable failure taxonomy; core session repair stays compaction-agnostic (a log-only orphaned compact/start is inert). - Wire dsh-compact-basic into examples/coding-agent and add a with-key compaction e2e (compaction's first real-world exercise + runaway net). - Rewrite the RFC to encode the blueprint and move it to implemented/. The runaway-turn snapshot is a named deferred follow-up: dsh-llm-replay cannot yet serve the interleaved summarization model call.
2026-06-26 08:59:33 +08:00
])
expect(fires.every(({ signal }) => signal instanceof AbortSignal)).toBe(true)
refactor(compact): turn-agnostic retention + dedicated agent/pre-request seam Reform the compaction blueprint so a runaway turn survives and the design stops drifting across review rounds: - Drop in-flight-turn protection ("layer 2"). Retention is a uniform tail→head whole-unit walk; the only structural guard is step-alignment. A single turn that alone exceeds the window now compacts its own early closed steps instead of being retained verbatim (the failure mode that motivated this). - Move auto-compaction off the agent/request waterfall onto a new awaited agent/pre-request loop seam, fired before history derivation. Compaction mutates the surface; the loop derives once from the result — no double-derive, and a listener structurally cannot act on not-yet-derived messages. - Tighten compactIfNeeded to required (session, system, model, signal). - Enforce a single-pass convergence invariant in resolveConfig: reject configs where summarizationMaxTokens + retainTokens exceeds the threshold, so a compaction can never immediately re-trigger. - Document the crash vs recoverable failure taxonomy; core session repair stays compaction-agnostic (a log-only orphaned compact/start is inert). - Wire dsh-compact-basic into examples/coding-agent and add a with-key compaction e2e (compaction's first real-world exercise + runaway net). - Rewrite the RFC to encode the blueprint and move it to implemented/. The runaway-turn snapshot is a named deferred follow-up: dsh-llm-replay cannot yet serve the interleaved summarization model call.
2026-06-26 08:59:33 +08:00
})
fix(compact): decide step-alignment from surface tool-pairing, fire compaction pre-step (CBR-001) Codex round 1 CBR-001: a head-anchored compaction checkpoint was mis-classified by the log-position step-alignment scan, so a second auto-compaction over a checkpoint-headed surface silently failed. Root cause: `isStepAlignedStart/End` scanned the LOG by seq, but a `replace` op lands a checkpoint at a high log seq whose SURFACE position is the head — its log neighbours (the open step's assistant/message) are not its surface neighbours, so the forward scan wrongly reported mid-step. Fix, per the agreed direction: - Replace the two log-position predicates with one surface-anchored helper `isToolPairingBalanced(nodes, events, beforeSeq)` in `dsh-session` (renamed step-boundary.ts → tool-pairing.ts). A cut is balanced when no unanswered tool-call precedes it on the surface; a region is collapsible iff both edges are balanced cuts. The open-tail and free-node cases fall out of the same counter. It also throws on a corrupt surface (a tool/result with no matching call). - Move compaction off the in-step seam to a new "pre-step" seam fired after turn/start and before step/start, so a compaction's log-only compact/* records and its replacement node land cleanly OUTSIDE any step (the honest structure crash-safety relies on). Renamed the event agent/pre-request → agent/pre-step and switched its dispatch from parallel → serial (listeners mutate the surface as a side effect; serial isolates them so concurrent appends can't interleave). Extended the catalog generator to accept @mode serial. Regression coverage: a real-loop test driving an auto-compaction asserts the landed checkpoint is a balanced cut on both sides; unit tests pin the checkpoint case, the mid-step injection case, multi-call steps, and the corrupt-surface guard. Proven red on the old log-position logic.
2026-06-26 13:51:01 +08:00
it('agent/pre-step fires BEFORE the step it precedes opens (events land outside the step)', async () => {
// The append lands before step/start, yet derive happens afterwards and the
// same step's request must include it.
refactor(compact): turn-agnostic retention + dedicated agent/pre-request seam Reform the compaction blueprint so a runaway turn survives and the design stops drifting across review rounds: - Drop in-flight-turn protection ("layer 2"). Retention is a uniform tail→head whole-unit walk; the only structural guard is step-alignment. A single turn that alone exceeds the window now compacts its own early closed steps instead of being retained verbatim (the failure mode that motivated this). - Move auto-compaction off the agent/request waterfall onto a new awaited agent/pre-request loop seam, fired before history derivation. Compaction mutates the surface; the loop derives once from the result — no double-derive, and a listener structurally cannot act on not-yet-derived messages. - Tighten compactIfNeeded to required (session, system, model, signal). - Enforce a single-pass convergence invariant in resolveConfig: reject configs where summarizationMaxTokens + retainTokens exceeds the threshold, so a compaction can never immediately re-trigger. - Document the crash vs recoverable failure taxonomy; core session repair stays compaction-agnostic (a log-only orphaned compact/start is inert). - Wire dsh-compact-basic into examples/coding-agent and add a with-key compaction e2e (compaction's first real-world exercise + runaway net). - Rewrite the RFC to encode the blueprint and move it to implemented/. The runaway-turn snapshot is a named deferred follow-up: dsh-llm-replay cannot yet serve the interleaved summarization model call.
2026-06-26 08:59:33 +08:00
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
refactor(compact): turn-agnostic retention + dedicated agent/pre-request seam Reform the compaction blueprint so a runaway turn survives and the design stops drifting across review rounds: - Drop in-flight-turn protection ("layer 2"). Retention is a uniform tail→head whole-unit walk; the only structural guard is step-alignment. A single turn that alone exceeds the window now compacts its own early closed steps instead of being retained verbatim (the failure mode that motivated this). - Move auto-compaction off the agent/request waterfall onto a new awaited agent/pre-request loop seam, fired before history derivation. Compaction mutates the surface; the loop derives once from the result — no double-derive, and a listener structurally cannot act on not-yet-derived messages. - Tighten compactIfNeeded to required (session, system, model, signal). - Enforce a single-pass convergence invariant in resolveConfig: reject configs where summarizationMaxTokens + retainTokens exceeds the threshold, so a compaction can never immediately re-trigger. - Document the crash vs recoverable failure taxonomy; core session repair stays compaction-agnostic (a log-only orphaned compact/start is inert). - Wire dsh-compact-basic into examples/coding-agent and add a with-key compaction e2e (compaction's first real-world exercise + runaway net). - Rewrite the RFC to encode the blueprint and move it to implemented/. The runaway-turn snapshot is a named deferred follow-up: dsh-llm-replay cannot yet serve the interleaved summarization model call.
2026-06-26 08:59:33 +08:00
let injected = false
fix(compact): decide step-alignment from surface tool-pairing, fire compaction pre-step (CBR-001) Codex round 1 CBR-001: a head-anchored compaction checkpoint was mis-classified by the log-position step-alignment scan, so a second auto-compaction over a checkpoint-headed surface silently failed. Root cause: `isStepAlignedStart/End` scanned the LOG by seq, but a `replace` op lands a checkpoint at a high log seq whose SURFACE position is the head — its log neighbours (the open step's assistant/message) are not its surface neighbours, so the forward scan wrongly reported mid-step. Fix, per the agreed direction: - Replace the two log-position predicates with one surface-anchored helper `isToolPairingBalanced(nodes, events, beforeSeq)` in `dsh-session` (renamed step-boundary.ts → tool-pairing.ts). A cut is balanced when no unanswered tool-call precedes it on the surface; a region is collapsible iff both edges are balanced cuts. The open-tail and free-node cases fall out of the same counter. It also throws on a corrupt surface (a tool/result with no matching call). - Move compaction off the in-step seam to a new "pre-step" seam fired after turn/start and before step/start, so a compaction's log-only compact/* records and its replacement node land cleanly OUTSIDE any step (the honest structure crash-safety relies on). Renamed the event agent/pre-request → agent/pre-step and switched its dispatch from parallel → serial (listeners mutate the surface as a side effect; serial isolates them so concurrent appends can't interleave). Extended the catalog generator to accept @mode serial. Regression coverage: a real-loop test driving an auto-compaction asserts the landed checkpoint is a balanced cut on both sides; unit tests pin the checkpoint case, the mid-step injection case, multi-call steps, and the corrupt-surface guard. Proven red on the old log-position logic.
2026-06-26 13:51:01 +08:00
ctx.on('agent/pre-step', (subject) => {
refactor(compact): turn-agnostic retention + dedicated agent/pre-request seam Reform the compaction blueprint so a runaway turn survives and the design stops drifting across review rounds: - Drop in-flight-turn protection ("layer 2"). Retention is a uniform tail→head whole-unit walk; the only structural guard is step-alignment. A single turn that alone exceeds the window now compacts its own early closed steps instead of being retained verbatim (the failure mode that motivated this). - Move auto-compaction off the agent/request waterfall onto a new awaited agent/pre-request loop seam, fired before history derivation. Compaction mutates the surface; the loop derives once from the result — no double-derive, and a listener structurally cannot act on not-yet-derived messages. - Tighten compactIfNeeded to required (session, system, model, signal). - Enforce a single-pass convergence invariant in resolveConfig: reject configs where summarizationMaxTokens + retainTokens exceeds the threshold, so a compaction can never immediately re-trigger. - Document the crash vs recoverable failure taxonomy; core session repair stays compaction-agnostic (a log-only orphaned compact/start is inert). - Wire dsh-compact-basic into examples/coding-agent and add a with-key compaction e2e (compaction's first real-world exercise + runaway net). - Rewrite the RFC to encode the blueprint and move it to implemented/. The runaway-turn snapshot is a named deferred follow-up: dsh-llm-replay cannot yet serve the interleaved summarization model call.
2026-06-26 08:59:33 +08:00
if (subject === agent && !injected) {
injected = true
subject.session.append('context/message', {
fix(compact): decide step-alignment from surface tool-pairing, fire compaction pre-step (CBR-001) Codex round 1 CBR-001: a head-anchored compaction checkpoint was mis-classified by the log-position step-alignment scan, so a second auto-compaction over a checkpoint-headed surface silently failed. Root cause: `isStepAlignedStart/End` scanned the LOG by seq, but a `replace` op lands a checkpoint at a high log seq whose SURFACE position is the head — its log neighbours (the open step's assistant/message) are not its surface neighbours, so the forward scan wrongly reported mid-step. Fix, per the agreed direction: - Replace the two log-position predicates with one surface-anchored helper `isToolPairingBalanced(nodes, events, beforeSeq)` in `dsh-session` (renamed step-boundary.ts → tool-pairing.ts). A cut is balanced when no unanswered tool-call precedes it on the surface; a region is collapsible iff both edges are balanced cuts. The open-tail and free-node cases fall out of the same counter. It also throws on a corrupt surface (a tool/result with no matching call). - Move compaction off the in-step seam to a new "pre-step" seam fired after turn/start and before step/start, so a compaction's log-only compact/* records and its replacement node land cleanly OUTSIDE any step (the honest structure crash-safety relies on). Renamed the event agent/pre-request → agent/pre-step and switched its dispatch from parallel → serial (listeners mutate the surface as a side effect; serial isolates them so concurrent appends can't interleave). Extended the catalog generator to accept @mode serial. Regression coverage: a real-loop test driving an auto-compaction asserts the landed checkpoint is a balanced cut on both sides; unit tests pin the checkpoint case, the mid-step injection case, multi-call steps, and the corrupt-surface guard. Proven red on the old log-position logic.
2026-06-26 13:51:01 +08:00
content: [{ type: 'text', text: 'INJECTED-IN-PRE-STEP' }],
refactor(compact): turn-agnostic retention + dedicated agent/pre-request seam Reform the compaction blueprint so a runaway turn survives and the design stops drifting across review rounds: - Drop in-flight-turn protection ("layer 2"). Retention is a uniform tail→head whole-unit walk; the only structural guard is step-alignment. A single turn that alone exceeds the window now compacts its own early closed steps instead of being retained verbatim (the failure mode that motivated this). - Move auto-compaction off the agent/request waterfall onto a new awaited agent/pre-request loop seam, fired before history derivation. Compaction mutates the surface; the loop derives once from the result — no double-derive, and a listener structurally cannot act on not-yet-derived messages. - Tighten compactIfNeeded to required (session, system, model, signal). - Enforce a single-pass convergence invariant in resolveConfig: reject configs where summarizationMaxTokens + retainTokens exceeds the threshold, so a compaction can never immediately re-trigger. - Document the crash vs recoverable failure taxonomy; core session repair stays compaction-agnostic (a log-only orphaned compact/start is inert). - Wire dsh-compact-basic into examples/coding-agent and add a with-key compaction e2e (compaction's first real-world exercise + runaway net). - Rewrite the RFC to encode the blueprint and move it to implemented/. The runaway-turn snapshot is a named deferred follow-up: dsh-llm-replay cannot yet serve the interleaved summarization model call.
2026-06-26 08:59:33 +08:00
source: { kind: 'plugin', plugin: 'test' },
}, { surfaceOp: 'append' })
}
})
send(agent, 'go')
await waitForIdle(ctx, agent)
fix(compact): decide step-alignment from surface tool-pairing, fire compaction pre-step (CBR-001) Codex round 1 CBR-001: a head-anchored compaction checkpoint was mis-classified by the log-position step-alignment scan, so a second auto-compaction over a checkpoint-headed surface silently failed. Root cause: `isStepAlignedStart/End` scanned the LOG by seq, but a `replace` op lands a checkpoint at a high log seq whose SURFACE position is the head — its log neighbours (the open step's assistant/message) are not its surface neighbours, so the forward scan wrongly reported mid-step. Fix, per the agreed direction: - Replace the two log-position predicates with one surface-anchored helper `isToolPairingBalanced(nodes, events, beforeSeq)` in `dsh-session` (renamed step-boundary.ts → tool-pairing.ts). A cut is balanced when no unanswered tool-call precedes it on the surface; a region is collapsible iff both edges are balanced cuts. The open-tail and free-node cases fall out of the same counter. It also throws on a corrupt surface (a tool/result with no matching call). - Move compaction off the in-step seam to a new "pre-step" seam fired after turn/start and before step/start, so a compaction's log-only compact/* records and its replacement node land cleanly OUTSIDE any step (the honest structure crash-safety relies on). Renamed the event agent/pre-request → agent/pre-step and switched its dispatch from parallel → serial (listeners mutate the surface as a side effect; serial isolates them so concurrent appends can't interleave). Extended the catalog generator to accept @mode serial. Regression coverage: a real-loop test driving an auto-compaction asserts the landed checkpoint is a balanced cut on both sides; unit tests pin the checkpoint case, the mid-step injection case, multi-call steps, and the corrupt-surface guard. Proven red on the old log-position logic.
2026-06-26 13:51:01 +08:00
// The adapter's request includes the node injected during pre-step (derive
// reflects it).
refactor(compact): turn-agnostic retention + dedicated agent/pre-request seam Reform the compaction blueprint so a runaway turn survives and the design stops drifting across review rounds: - Drop in-flight-turn protection ("layer 2"). Retention is a uniform tail→head whole-unit walk; the only structural guard is step-alignment. A single turn that alone exceeds the window now compacts its own early closed steps instead of being retained verbatim (the failure mode that motivated this). - Move auto-compaction off the agent/request waterfall onto a new awaited agent/pre-request loop seam, fired before history derivation. Compaction mutates the surface; the loop derives once from the result — no double-derive, and a listener structurally cannot act on not-yet-derived messages. - Tighten compactIfNeeded to required (session, system, model, signal). - Enforce a single-pass convergence invariant in resolveConfig: reject configs where summarizationMaxTokens + retainTokens exceeds the threshold, so a compaction can never immediately re-trigger. - Document the crash vs recoverable failure taxonomy; core session repair stays compaction-agnostic (a log-only orphaned compact/start is inert). - Wire dsh-compact-basic into examples/coding-agent and add a with-key compaction e2e (compaction's first real-world exercise + runaway net). - Rewrite the RFC to encode the blueprint and move it to implemented/. The runaway-turn snapshot is a named deferred follow-up: dsh-llm-replay cannot yet serve the interleaved summarization model call.
2026-06-26 08:59:33 +08:00
const text = JSON.stringify(adapter.requests[0]!.messages)
fix(compact): decide step-alignment from surface tool-pairing, fire compaction pre-step (CBR-001) Codex round 1 CBR-001: a head-anchored compaction checkpoint was mis-classified by the log-position step-alignment scan, so a second auto-compaction over a checkpoint-headed surface silently failed. Root cause: `isStepAlignedStart/End` scanned the LOG by seq, but a `replace` op lands a checkpoint at a high log seq whose SURFACE position is the head — its log neighbours (the open step's assistant/message) are not its surface neighbours, so the forward scan wrongly reported mid-step. Fix, per the agreed direction: - Replace the two log-position predicates with one surface-anchored helper `isToolPairingBalanced(nodes, events, beforeSeq)` in `dsh-session` (renamed step-boundary.ts → tool-pairing.ts). A cut is balanced when no unanswered tool-call precedes it on the surface; a region is collapsible iff both edges are balanced cuts. The open-tail and free-node cases fall out of the same counter. It also throws on a corrupt surface (a tool/result with no matching call). - Move compaction off the in-step seam to a new "pre-step" seam fired after turn/start and before step/start, so a compaction's log-only compact/* records and its replacement node land cleanly OUTSIDE any step (the honest structure crash-safety relies on). Renamed the event agent/pre-request → agent/pre-step and switched its dispatch from parallel → serial (listeners mutate the surface as a side effect; serial isolates them so concurrent appends can't interleave). Extended the catalog generator to accept @mode serial. Regression coverage: a real-loop test driving an auto-compaction asserts the landed checkpoint is a balanced cut on both sides; unit tests pin the checkpoint case, the mid-step injection case, multi-call steps, and the corrupt-surface guard. Proven red on the old log-position logic.
2026-06-26 13:51:01 +08:00
expect(text).toContain('INJECTED-IN-PRE-STEP')
// And the injected event sits BEFORE the first step/start in the log —
// the seam fired outside the step.
const events = agent.session.events
const injectedSeq = events.find(e => e.type === 'context/message')!.seq
const firstStepStartSeq = events.find(e => e.type === 'step/start')!.seq
expect(injectedSeq).toBeLessThan(firstStepStartSeq)
})
it('a throwing agent/pre-step listener ends the turn (error), not the loop', async () => {
// Before step/start, a pre-step throw reaches the turn catch: no step needs
// closing, the turn records error, and the loop remains available.
fix(compact): decide step-alignment from surface tool-pairing, fire compaction pre-step (CBR-001) Codex round 1 CBR-001: a head-anchored compaction checkpoint was mis-classified by the log-position step-alignment scan, so a second auto-compaction over a checkpoint-headed surface silently failed. Root cause: `isStepAlignedStart/End` scanned the LOG by seq, but a `replace` op lands a checkpoint at a high log seq whose SURFACE position is the head — its log neighbours (the open step's assistant/message) are not its surface neighbours, so the forward scan wrongly reported mid-step. Fix, per the agreed direction: - Replace the two log-position predicates with one surface-anchored helper `isToolPairingBalanced(nodes, events, beforeSeq)` in `dsh-session` (renamed step-boundary.ts → tool-pairing.ts). A cut is balanced when no unanswered tool-call precedes it on the surface; a region is collapsible iff both edges are balanced cuts. The open-tail and free-node cases fall out of the same counter. It also throws on a corrupt surface (a tool/result with no matching call). - Move compaction off the in-step seam to a new "pre-step" seam fired after turn/start and before step/start, so a compaction's log-only compact/* records and its replacement node land cleanly OUTSIDE any step (the honest structure crash-safety relies on). Renamed the event agent/pre-request → agent/pre-step and switched its dispatch from parallel → serial (listeners mutate the surface as a side effect; serial isolates them so concurrent appends can't interleave). Extended the catalog generator to accept @mode serial. Regression coverage: a real-loop test driving an auto-compaction asserts the landed checkpoint is a balanced cut on both sides; unit tests pin the checkpoint case, the mid-step injection case, multi-call steps, and the corrupt-surface guard. Proven red on the old log-position logic.
2026-06-26 13:51:01 +08:00
const adapter = new MockAdapter([textResponse('second turn ok')])
const ctx = await harness(adapter)
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
fix(compact): decide step-alignment from surface tool-pairing, fire compaction pre-step (CBR-001) Codex round 1 CBR-001: a head-anchored compaction checkpoint was mis-classified by the log-position step-alignment scan, so a second auto-compaction over a checkpoint-headed surface silently failed. Root cause: `isStepAlignedStart/End` scanned the LOG by seq, but a `replace` op lands a checkpoint at a high log seq whose SURFACE position is the head — its log neighbours (the open step's assistant/message) are not its surface neighbours, so the forward scan wrongly reported mid-step. Fix, per the agreed direction: - Replace the two log-position predicates with one surface-anchored helper `isToolPairingBalanced(nodes, events, beforeSeq)` in `dsh-session` (renamed step-boundary.ts → tool-pairing.ts). A cut is balanced when no unanswered tool-call precedes it on the surface; a region is collapsible iff both edges are balanced cuts. The open-tail and free-node cases fall out of the same counter. It also throws on a corrupt surface (a tool/result with no matching call). - Move compaction off the in-step seam to a new "pre-step" seam fired after turn/start and before step/start, so a compaction's log-only compact/* records and its replacement node land cleanly OUTSIDE any step (the honest structure crash-safety relies on). Renamed the event agent/pre-request → agent/pre-step and switched its dispatch from parallel → serial (listeners mutate the surface as a side effect; serial isolates them so concurrent appends can't interleave). Extended the catalog generator to accept @mode serial. Regression coverage: a real-loop test driving an auto-compaction asserts the landed checkpoint is a balanced cut on both sides; unit tests pin the checkpoint case, the mid-step injection case, multi-call steps, and the corrupt-surface guard. Proven red on the old log-position logic.
2026-06-26 13:51:01 +08:00
let throwOnce = true
ctx.on('agent/pre-step', () => {
if (throwOnce) { throwOnce = false; throw new Error('boom in pre-step') }
})
const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
send(agent, 'first')
await waitForIdle(ctx, agent)
// The first turn failed at step 1 (no model call happened), surfaced via
// agent/error, with the durable failure on turn/end.reason.
expect(errors).toHaveLength(1)
expect(errors[0]!.message).toContain('boom in pre-step')
expect(adapter.requests.length).toBe(0)
const firstTurnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(firstTurnEnd?.type === 'turn/end' && firstTurnEnd.data.reason).toMatchObject({ kind: 'error', step: 1 })
// The step opened-and-closed count stays balanced even though it never ran.
const types = agent.session.events.map(e => e.type)
expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length)
// The loop survived: a second prompt runs a normal completed turn.
send(agent, 'second')
await waitForIdle(ctx, agent)
expect(adapter.requests.length).toBe(1)
const lastTurnEnd = agent.session.events.findLast(e => e.type === 'turn/end')
expect(lastTurnEnd?.type === 'turn/end' && lastTurnEnd.data.reason).toEqual({ kind: 'completed' })
refactor(compact): turn-agnostic retention + dedicated agent/pre-request seam Reform the compaction blueprint so a runaway turn survives and the design stops drifting across review rounds: - Drop in-flight-turn protection ("layer 2"). Retention is a uniform tail→head whole-unit walk; the only structural guard is step-alignment. A single turn that alone exceeds the window now compacts its own early closed steps instead of being retained verbatim (the failure mode that motivated this). - Move auto-compaction off the agent/request waterfall onto a new awaited agent/pre-request loop seam, fired before history derivation. Compaction mutates the surface; the loop derives once from the result — no double-derive, and a listener structurally cannot act on not-yet-derived messages. - Tighten compactIfNeeded to required (session, system, model, signal). - Enforce a single-pass convergence invariant in resolveConfig: reject configs where summarizationMaxTokens + retainTokens exceeds the threshold, so a compaction can never immediately re-trigger. - Document the crash vs recoverable failure taxonomy; core session repair stays compaction-agnostic (a log-only orphaned compact/start is inert). - Wire dsh-compact-basic into examples/coding-agent and add a with-key compaction e2e (compaction's first real-world exercise + runaway net). - Rewrite the RFC to encode the blueprint and move it to implemented/. The runaway-turn snapshot is a named deferred follow-up: dsh-llm-replay cannot yet serve the interleaved summarization model call.
2026-06-26 08:59:33 +08:00
})
simplify(agent): drop the unused public Agent.abort(), keep whenIdle() The public Agent handle exposed abort() (step-only) and cancel() (queue-aware). No production caller used abort() — ACP maps session/cancel to cancel(), and lifecycle owners tear down via AgentHandle.dispose(); the loop's own stop paths abort their per-step AbortController directly. So abort() is latent generality that keeps a private loop mechanic public. RFC-premise correction: the public-agent-stop-surface RFC proposed removing whenIdle() too. Implementation found whenIdle() load-bearing — a real quiescence primitive with a deliberate loop contract (settle-without-transition, the replacement-turn race) and ACP test consumers; its proposed replacement ("observe the running->idle transition") is exactly the async-state race AGENTS.md warns against. So only abort() is removed; whenIdle() stays. The RFC is amended on the way to implemented/ to record the narrowed scope, and the new AGENTS.md "RFCs are proposals, not golden truth" principle (PR1) gets its worked example. - Remove Agent.abort() from the interface + the ReactLoopAgent impl; the no-arg 'aborted' default goes with it (cancel() keeps its 'cancelled' default). - Migrate tests: empty-queue abort() -> cancel(reason); the two review-fixes tests whose subject is the in-flight step's AbortController drive that controller directly via the private currentAbort field (cancel() would clear the inbox and destroy the queued steering one of them proves survives a step abort). The no-arg-default test is dropped (cancel()'s default is already covered in cancel.spec.ts). - Resulting public stop surface: cancel() + whenIdle(). Update agent/agent-loop READMEs, architecture.md, core.md type-equiv, the extension cookbook, the lifecycle RFC (short note), and the proposed ACP RFC. Implements docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md
2026-06-21 05:50:39 +08:00
it('cancel() mid-stream ends the turn with reason aborted', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
Document the codebase thoroughly and tighten type safety Docs: per-folder README.md for packages/ (family overview + one per package: service, events, API, extension points, TODOs), examples/, and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md symlinks) for packages/ and vendor/; module-level doc comments in every packages/*/src file; richer JSDoc on all exported API (event side effects, disposal contracts, error behavior). Root AGENTS.md gains a "Type Safety and Documentation" policy section: the codebase aims to be very type-safe and well documented; type gymnastics are acceptable in core packages when they improve plugin-author DX; verbose docs are fine as long as they stay strictly in sync with the code. Type safety: removed the upstream-inherited "noImplicitAny": false from tsconfig.base.json — packages/* now compile under full strict mode; vendor/loader and vendor/include set it locally (vendor/cordis already did). Eliminated every `: any` / `as any` from packages and examples (catch clauses use unknown + a CodedError narrowing type; event data access uses discriminated-union narrowing). Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL — SchemaSpec with per-property `required: true` booleans, type-level InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and defineTool() so first-party tools get typed execute(args) with zero casts (raw JSON Schema still accepted for MCP interop; chosen over schemastery because it targets JSON Schema generation directly). echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
const reasons: TurnEndReason[] = []
refactor(events): remove the turn boundary mirror events Complete the boundary-mirror removal begun with the step mirrors: drop `agent/turn-start` and `agent/turn-end` from the agent event taxonomy. Turn and step boundaries are now read exclusively off the durable `session/event` feed (`turn/start`/`turn/end`/`step/start`/`step/end`) — there is no `agent/*` mirror for any boundary. - loop.ts: delete both turn emits; `closeTurn` loses its `emit` parameter and its now-unreachable idempotency guard (it is called exactly once per turn, on mutually exclusive normal/catch paths); `failTurn` loses the dead post-close branch that only a throwing turn-end LISTENER could reach. - ui-stdio: render turn boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map (the `turn/start` event carries only the turn number, and the session id is not reliably the agent id). ui-stdio is a disposable test REPL, so this migration retires the sole justification the event-domain-semantics RFC gave for KEEPING the turn mirrors. - Tests: reason/turn-number collectors and the boundary-ordering test now read `session/event`; the throwing-turn-boundary-LISTENER tests are deleted (that code path no longer exists). A new test covers the outer-catch disposed branch via a pre-step listener that disposes-then-throws (the surviving real path). - Docs: promote the "remove agent boundary mirror events" RFC to implemented (amended/narrowed — `agent/steering` is RETAINED, not a boundary mirror); update the event-domain-semantics + turn-enclosure RFCs, architecture.md, the cookbook, the ACP/agent/ui-stdio prose, and regenerate the cordis catalog. `agent/steering` and `agent/stream-chunk` are explicitly out of scope (not durable-boundary mirrors). ACP is unaffected — it already settles from the log's `turn/end` + `agent/status`; snapshot goldens are byte-unchanged.
2026-07-02 03:26:45 +08:00
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
simplify(agent): drop the unused public Agent.abort(), keep whenIdle() The public Agent handle exposed abort() (step-only) and cancel() (queue-aware). No production caller used abort() — ACP maps session/cancel to cancel(), and lifecycle owners tear down via AgentHandle.dispose(); the loop's own stop paths abort their per-step AbortController directly. So abort() is latent generality that keeps a private loop mechanic public. RFC-premise correction: the public-agent-stop-surface RFC proposed removing whenIdle() too. Implementation found whenIdle() load-bearing — a real quiescence primitive with a deliberate loop contract (settle-without-transition, the replacement-turn race) and ACP test consumers; its proposed replacement ("observe the running->idle transition") is exactly the async-state race AGENTS.md warns against. So only abort() is removed; whenIdle() stays. The RFC is amended on the way to implemented/ to record the narrowed scope, and the new AGENTS.md "RFCs are proposals, not golden truth" principle (PR1) gets its worked example. - Remove Agent.abort() from the interface + the ReactLoopAgent impl; the no-arg 'aborted' default goes with it (cancel() keeps its 'cancelled' default). - Migrate tests: empty-queue abort() -> cancel(reason); the two review-fixes tests whose subject is the in-flight step's AbortController drive that controller directly via the private currentAbort field (cancel() would clear the inbox and destroy the queued steering one of them proves survives a step abort). The no-arg-default test is dropped (cancel()'s default is already covered in cancel.spec.ts). - Resulting public stop surface: cancel() + whenIdle(). Update agent/agent-loop READMEs, architecture.md, core.md type-equiv, the extension cookbook, the lifecycle RFC (short note), and the proposed ACP RFC. Implements docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md
2026-06-21 05:50:39 +08:00
// wait until the stream is hanging, then cancel
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
simplify(agent): drop the unused public Agent.abort(), keep whenIdle() The public Agent handle exposed abort() (step-only) and cancel() (queue-aware). No production caller used abort() — ACP maps session/cancel to cancel(), and lifecycle owners tear down via AgentHandle.dispose(); the loop's own stop paths abort their per-step AbortController directly. So abort() is latent generality that keeps a private loop mechanic public. RFC-premise correction: the public-agent-stop-surface RFC proposed removing whenIdle() too. Implementation found whenIdle() load-bearing — a real quiescence primitive with a deliberate loop contract (settle-without-transition, the replacement-turn race) and ACP test consumers; its proposed replacement ("observe the running->idle transition") is exactly the async-state race AGENTS.md warns against. So only abort() is removed; whenIdle() stays. The RFC is amended on the way to implemented/ to record the narrowed scope, and the new AGENTS.md "RFCs are proposals, not golden truth" principle (PR1) gets its worked example. - Remove Agent.abort() from the interface + the ReactLoopAgent impl; the no-arg 'aborted' default goes with it (cancel() keeps its 'cancelled' default). - Migrate tests: empty-queue abort() -> cancel(reason); the two review-fixes tests whose subject is the in-flight step's AbortController drive that controller directly via the private currentAbort field (cancel() would clear the inbox and destroy the queued steering one of them proves survives a step abort). The no-arg-default test is dropped (cancel()'s default is already covered in cancel.spec.ts). - Resulting public stop surface: cancel() + whenIdle(). Update agent/agent-loop READMEs, architecture.md, core.md type-equiv, the extension cookbook, the lifecycle RFC (short note), and the proposed ACP RFC. Implements docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md
2026-06-21 05:50:39 +08:00
agent.cancel('user interrupt')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }])
})
it('surfaces max-tokens as the turn-end reason when the last step is cut off', async () => {
// A single step that ends with a max-tokens finish (no tool calls): the
// turn stops by default and ends max-tokens, not completed.
const adapter = new MockAdapter([maxTokensResponse('truncat')])
const ctx = await harness(adapter)
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const reasons: TurnEndReason[] = []
refactor(events): remove the turn boundary mirror events Complete the boundary-mirror removal begun with the step mirrors: drop `agent/turn-start` and `agent/turn-end` from the agent event taxonomy. Turn and step boundaries are now read exclusively off the durable `session/event` feed (`turn/start`/`turn/end`/`step/start`/`step/end`) — there is no `agent/*` mirror for any boundary. - loop.ts: delete both turn emits; `closeTurn` loses its `emit` parameter and its now-unreachable idempotency guard (it is called exactly once per turn, on mutually exclusive normal/catch paths); `failTurn` loses the dead post-close branch that only a throwing turn-end LISTENER could reach. - ui-stdio: render turn boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map (the `turn/start` event carries only the turn number, and the session id is not reliably the agent id). ui-stdio is a disposable test REPL, so this migration retires the sole justification the event-domain-semantics RFC gave for KEEPING the turn mirrors. - Tests: reason/turn-number collectors and the boundary-ordering test now read `session/event`; the throwing-turn-boundary-LISTENER tests are deleted (that code path no longer exists). A new test covers the outer-catch disposed branch via a pre-step listener that disposes-then-throws (the surviving real path). - Docs: promote the "remove agent boundary mirror events" RFC to implemented (amended/narrowed — `agent/steering` is RETAINED, not a boundary mirror); update the event-domain-semantics + turn-enclosure RFCs, architecture.md, the cookbook, the ACP/agent/ui-stdio prose, and regenerate the cordis catalog. `agent/steering` and `agent/stream-chunk` are explicitly out of scope (not durable-boundary mirrors). ACP is unaffected — it already settles from the log's `turn/end` + `agent/status`; snapshot goldens are byte-unchanged.
2026-07-02 03:26:45 +08:00
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(reasons).toEqual([{ kind: 'max-tokens' }])
// Assert the durable row, not only the live listener.
const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end')
expect(turnEnd!.data.reason).toEqual({ kind: 'max-tokens' })
})
it('a max-tokens step earlier in a turn still surfaces as max-tokens after a later completed step', async () => {
2026-07-12 03:36:43 +08:00
// Step 1 is cut off (max-tokens, no tool calls → would stop by default), so continuation
// must be FORCED to reach step 2 which finishes normally (stop).
const adapter = new MockAdapter([
maxTokensResponse('first half'),
textResponse('second half'),
])
const ctx = await harness(adapter)
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let steps = 0
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
// Force exactly one continuation (step 1 → step 2), then defer to default
// (step 2 is a plain stop with no tool calls → stops).
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => {
feat(events): interception seams — the typed-Decision surface for hooks Reshape the agent's interception surface so every seam returns a small, typed Decision union, and the set covers the hook points a CC/Codex bridge (and a native plugin) needs. "Native hooks" are not a package — a native hook is just a cordis plugin on these canonical events; the bridges (a later PR) only translate an external protocol onto the same surface. dsh-agent: - NEW agent/session-start(agent, source) emit (once before turn 1; SessionStartSource startup|resume|clear|compact) — a pure notification, seeds context via inject(). - NEW agent/prompt-submit waterfall → PromptDecision (allow, optionally rewriting the prompt or attaching additionalContext, or block). - RESHAPE agent/turn-continuation boolean → ContinuationDecision ({action:'stop'} | {action:'continue', reason?}; a continue reason is recorded as next-step steering). - New HookContext envelope (required source — inject() would mislabel a missing one). dsh-tools: split the single tools/execute waterfall into tools/pre-execute (PreToolDecision allow/deny/ask gate) and tools/post-execute (PostToolDecision accept/block, optionally replacing content or attaching additionalContext). Core dispatch sits between as plain code; the tool body keeps its inner try/catch so a thrown tool still reaches post-execute as an isError. ToolExecutionResult gains additionalContext (ferried to the loop's per-step buffer). Input rewrite is deliberately NOT offered (a proposed RFC designs it consistently). dsh-session: new `rejected` TurnEndReason — a turn whose whole prompt batch was blocked by prompt-submit. agent-loop firing points: session-start emitted at create (source threaded — startup for create/fork, resume for resume()); prompt-submit per drained message with the always-open-turn rule (a fully-blocked batch is a zero-step rejected turn); the continuation reshape; post-tool additionalContext buffered and appended after all tool/results (adjacency). ACP codec maps rejected→cancelled. A worked native-plugin example (interception.spec.ts) proves all four seams compose end-to-end through the real loop with NO hook/* events (those belong to the bridge lib). All existing tools/execute + turn-continuation tests migrated. The tool-subagent abort test now aborts after a microtask so it still exercises the live onAbort bridge (execute() awaits pre-execute before the body runs). RFCs: implemented/feature/2026-06-30-interception-seams.md (the reshape) + proposed/feature/2026-06-30-pre-tool-input-rewrite.md (the deferred rewrite design).
2026-06-30 17:11:18 +08:00
if (steps < 2) return { action: 'continue' as const }
return next()
})
const reasons: TurnEndReason[] = []
refactor(events): remove the turn boundary mirror events Complete the boundary-mirror removal begun with the step mirrors: drop `agent/turn-start` and `agent/turn-end` from the agent event taxonomy. Turn and step boundaries are now read exclusively off the durable `session/event` feed (`turn/start`/`turn/end`/`step/start`/`step/end`) — there is no `agent/*` mirror for any boundary. - loop.ts: delete both turn emits; `closeTurn` loses its `emit` parameter and its now-unreachable idempotency guard (it is called exactly once per turn, on mutually exclusive normal/catch paths); `failTurn` loses the dead post-close branch that only a throwing turn-end LISTENER could reach. - ui-stdio: render turn boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map (the `turn/start` event carries only the turn number, and the session id is not reliably the agent id). ui-stdio is a disposable test REPL, so this migration retires the sole justification the event-domain-semantics RFC gave for KEEPING the turn mirrors. - Tests: reason/turn-number collectors and the boundary-ordering test now read `session/event`; the throwing-turn-boundary-LISTENER tests are deleted (that code path no longer exists). A new test covers the outer-catch disposed branch via a pre-step listener that disposes-then-throws (the surviving real path). - Docs: promote the "remove agent boundary mirror events" RFC to implemented (amended/narrowed — `agent/steering` is RETAINED, not a boundary mirror); update the event-domain-semantics + turn-enclosure RFCs, architecture.md, the cookbook, the ACP/agent/ui-stdio prose, and regenerate the cordis catalog. `agent/steering` and `agent/stream-chunk` are explicitly out of scope (not durable-boundary mirrors). ACP is unaffected — it already settles from the log's `turn/end` + `agent/status`; snapshot goldens are byte-unchanged.
2026-07-02 03:26:45 +08:00
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(steps).toBe(2)
expect(adapter.requests).toHaveLength(2)
2026-06-19 00:37:12 +08:00
expect(adapter.requests[1]!.messages).toEqual([
{ role: 'user', content: [{ type: 'text', text: 'go' }] },
2026-07-14 21:57:52 +08:00
{ role: 'assistant', content: [{ type: 'text', text: 'first half' }], provenance: { provider: 'mock', model: 'mock' } },
2026-06-19 00:37:12 +08:00
])
expect(reasons).toEqual([{ kind: 'max-tokens' }])
})
it('a completed step after no max-tokens keeps the turn completed (max-tokens does not leak across turns)', async () => {
// Two consecutive turns: turn 1 is cut off (max-tokens), turn 2 is a clean
// stop. The per-turn reason must be independent — turn 2 ends completed.
const adapter = new MockAdapter([maxTokensResponse('cut'), textResponse('clean')])
const ctx = await harness(adapter)
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const reasons: TurnEndReason[] = []
refactor(events): remove the turn boundary mirror events Complete the boundary-mirror removal begun with the step mirrors: drop `agent/turn-start` and `agent/turn-end` from the agent event taxonomy. Turn and step boundaries are now read exclusively off the durable `session/event` feed (`turn/start`/`turn/end`/`step/start`/`step/end`) — there is no `agent/*` mirror for any boundary. - loop.ts: delete both turn emits; `closeTurn` loses its `emit` parameter and its now-unreachable idempotency guard (it is called exactly once per turn, on mutually exclusive normal/catch paths); `failTurn` loses the dead post-close branch that only a throwing turn-end LISTENER could reach. - ui-stdio: render turn boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map (the `turn/start` event carries only the turn number, and the session id is not reliably the agent id). ui-stdio is a disposable test REPL, so this migration retires the sole justification the event-domain-semantics RFC gave for KEEPING the turn mirrors. - Tests: reason/turn-number collectors and the boundary-ordering test now read `session/event`; the throwing-turn-boundary-LISTENER tests are deleted (that code path no longer exists). A new test covers the outer-catch disposed branch via a pre-step listener that disposes-then-throws (the surviving real path). - Docs: promote the "remove agent boundary mirror events" RFC to implemented (amended/narrowed — `agent/steering` is RETAINED, not a boundary mirror); update the event-domain-semantics + turn-enclosure RFCs, architecture.md, the cookbook, the ACP/agent/ui-stdio prose, and regenerate the cordis catalog. `agent/steering` and `agent/stream-chunk` are explicitly out of scope (not durable-boundary mirrors). ACP is unaffected — it already settles from the log's `turn/end` + `agent/status`; snapshot goldens are byte-unchanged.
2026-07-02 03:26:45 +08:00
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'first')
await waitForIdle(ctx, agent)
send(agent, 'second')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'max-tokens' }, { kind: 'completed' }])
})
it('does not dispatch tool calls from a max-tokens-truncated step', async () => {
const callId = CallId('c1')
const adapter = new MockAdapter([[
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: callId, name: 'echo', argumentsDelta: '{"text":"x"}' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'echo', arguments: '{"text":"x"}' } },
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'max-tokens' } },
]])
const ctx = await harness(adapter)
let executions = 0
ctx.tools.register(defineTool({
name: 'echo',
description: '',
parameters: { text: { type: 'string' } },
async execute() {
executions += 1
return [{ type: 'text', text: 'should not run' }]
},
}))
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const reasons: TurnEndReason[] = []
refactor(events): remove the turn boundary mirror events Complete the boundary-mirror removal begun with the step mirrors: drop `agent/turn-start` and `agent/turn-end` from the agent event taxonomy. Turn and step boundaries are now read exclusively off the durable `session/event` feed (`turn/start`/`turn/end`/`step/start`/`step/end`) — there is no `agent/*` mirror for any boundary. - loop.ts: delete both turn emits; `closeTurn` loses its `emit` parameter and its now-unreachable idempotency guard (it is called exactly once per turn, on mutually exclusive normal/catch paths); `failTurn` loses the dead post-close branch that only a throwing turn-end LISTENER could reach. - ui-stdio: render turn boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map (the `turn/start` event carries only the turn number, and the session id is not reliably the agent id). ui-stdio is a disposable test REPL, so this migration retires the sole justification the event-domain-semantics RFC gave for KEEPING the turn mirrors. - Tests: reason/turn-number collectors and the boundary-ordering test now read `session/event`; the throwing-turn-boundary-LISTENER tests are deleted (that code path no longer exists). A new test covers the outer-catch disposed branch via a pre-step listener that disposes-then-throws (the surviving real path). - Docs: promote the "remove agent boundary mirror events" RFC to implemented (amended/narrowed — `agent/steering` is RETAINED, not a boundary mirror); update the event-domain-semantics + turn-enclosure RFCs, architecture.md, the cookbook, the ACP/agent/ui-stdio prose, and regenerate the cordis catalog. `agent/steering` and `agent/stream-chunk` are explicitly out of scope (not durable-boundary mirrors). ACP is unaffected — it already settles from the log's `turn/end` + `agent/status`; snapshot goldens are byte-unchanged.
2026-07-02 03:26:45 +08:00
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(executions).toBe(0)
expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false)
2026-06-19 00:37:12 +08:00
expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
expect(reasons).toEqual([{ kind: 'max-tokens' }])
// Empty content still needs an assistant/message to carry usage; derivation
// skips that host so it does not create a spurious assistant turn.
simplify(session): fold trace-only usage/error events into load-bearing events The session event vocabulary carried two standalone trace-only events that were not load-bearing as separate records. Fold their facts into nearby load-bearing events and delete the standalone variants. - Token usage now rides on `assistant/message` as an optional `usage` field — the assembled model output and its accounting travel together. The loop folds `assembler.usage` onto the append instead of emitting a separate `usage` event. - The max-tokens path is the no-data-loss host: a step cut off with usage but EMPTY content (e.g. only a dropped tool call) previously emitted a standalone `usage`; it now records an empty-content `assistant/message { content: [], usage }`. `deriveMessages()` skips empty-content assistant messages, so the usage host never injects a spurious content-less assistant turn into the provider transcript. A step with neither content nor usage appends nothing. - An operational error's step number now rides on `turn/end.reason` for `kind: 'error'` (`{ kind: 'error', step, message, code? }`) — the durable turn outcome ACP and resume already consume. `failTurn` sets the reason directly (no separate session `error` event). `agent/error` + logging are unchanged for live diagnostics. - No format-version bump: pre-release, no persisted data, so per the format policy there is nothing to migrate or reject (the RFC's "refresh the format version" criterion over-reached). `version` stays 1. - ACP fixtures + goldens re-recorded (keyless replay): dropped standalone usage/error lines, usage folded onto assistant/message, error step on turn/end.reason. RFC moved proposed -> implemented with an implementation note recording the two scope refinements.
2026-06-21 10:00:06 +08:00
const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message')
expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data).toEqual({
2026-07-14 21:57:52 +08:00
turn: 1, step: 1, content: [], provenance: { provider: 'mock', model: 'mock' }, usage: { inputTokens: 10, outputTokens: 5 },
simplify(session): fold trace-only usage/error events into load-bearing events The session event vocabulary carried two standalone trace-only events that were not load-bearing as separate records. Fold their facts into nearby load-bearing events and delete the standalone variants. - Token usage now rides on `assistant/message` as an optional `usage` field — the assembled model output and its accounting travel together. The loop folds `assembler.usage` onto the append instead of emitting a separate `usage` event. - The max-tokens path is the no-data-loss host: a step cut off with usage but EMPTY content (e.g. only a dropped tool call) previously emitted a standalone `usage`; it now records an empty-content `assistant/message { content: [], usage }`. `deriveMessages()` skips empty-content assistant messages, so the usage host never injects a spurious content-less assistant turn into the provider transcript. A step with neither content nor usage appends nothing. - An operational error's step number now rides on `turn/end.reason` for `kind: 'error'` (`{ kind: 'error', step, message, code? }`) — the durable turn outcome ACP and resume already consume. `failTurn` sets the reason directly (no separate session `error` event). `agent/error` + logging are unchanged for live diagnostics. - No format-version bump: pre-release, no persisted data, so per the format policy there is nothing to migrate or reject (the RFC's "refresh the format version" criterion over-reached). `version` stays 1. - ACP fixtures + goldens re-recorded (keyless replay): dropped standalone usage/error lines, usage folded onto assistant/message, error step on turn/end.reason. RFC moved proposed -> implemented with an implementation note recording the two scope refinements.
2026-06-21 10:00:06 +08:00
})
})
it('appends an empty completion anchor for a max-tokens step with no usage', async () => {
// The truncated tool call is dropped from durable content, while the
// successful provider call still needs an exact replay anchor.
simplify(session): fold trace-only usage/error events into load-bearing events The session event vocabulary carried two standalone trace-only events that were not load-bearing as separate records. Fold their facts into nearby load-bearing events and delete the standalone variants. - Token usage now rides on `assistant/message` as an optional `usage` field — the assembled model output and its accounting travel together. The loop folds `assembler.usage` onto the append instead of emitting a separate `usage` event. - The max-tokens path is the no-data-loss host: a step cut off with usage but EMPTY content (e.g. only a dropped tool call) previously emitted a standalone `usage`; it now records an empty-content `assistant/message { content: [], usage }`. `deriveMessages()` skips empty-content assistant messages, so the usage host never injects a spurious content-less assistant turn into the provider transcript. A step with neither content nor usage appends nothing. - An operational error's step number now rides on `turn/end.reason` for `kind: 'error'` (`{ kind: 'error', step, message, code? }`) — the durable turn outcome ACP and resume already consume. `failTurn` sets the reason directly (no separate session `error` event). `agent/error` + logging are unchanged for live diagnostics. - No format-version bump: pre-release, no persisted data, so per the format policy there is nothing to migrate or reject (the RFC's "refresh the format version" criterion over-reached). `version` stays 1. - ACP fixtures + goldens re-recorded (keyless replay): dropped standalone usage/error lines, usage folded onto assistant/message, error step on turn/end.reason. RFC moved proposed -> implemented with an implementation note recording the two scope refinements.
2026-06-21 10:00:06 +08:00
const callId = CallId('c1')
const adapter = new MockAdapter([[
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: callId, name: 'echo', argumentsDelta: '{"text":"x"}' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'echo', arguments: '{"text":"x"}' } },
{ type: 'finish', reason: { kind: 'max-tokens' } },
]])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
name: 'echo',
description: '',
parameters: { text: { type: 'string' } },
async execute() { return [{ type: 'text', text: 'should not run' }] },
}))
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
simplify(session): fold trace-only usage/error events into load-bearing events The session event vocabulary carried two standalone trace-only events that were not load-bearing as separate records. Fold their facts into nearby load-bearing events and delete the standalone variants. - Token usage now rides on `assistant/message` as an optional `usage` field — the assembled model output and its accounting travel together. The loop folds `assembler.usage` onto the append instead of emitting a separate `usage` event. - The max-tokens path is the no-data-loss host: a step cut off with usage but EMPTY content (e.g. only a dropped tool call) previously emitted a standalone `usage`; it now records an empty-content `assistant/message { content: [], usage }`. `deriveMessages()` skips empty-content assistant messages, so the usage host never injects a spurious content-less assistant turn into the provider transcript. A step with neither content nor usage appends nothing. - An operational error's step number now rides on `turn/end.reason` for `kind: 'error'` (`{ kind: 'error', step, message, code? }`) — the durable turn outcome ACP and resume already consume. `failTurn` sets the reason directly (no separate session `error` event). `agent/error` + logging are unchanged for live diagnostics. - No format-version bump: pre-release, no persisted data, so per the format policy there is nothing to migrate or reject (the RFC's "refresh the format version" criterion over-reached). `version` stays 1. - ACP fixtures + goldens re-recorded (keyless replay): dropped standalone usage/error lines, usage folded onto assistant/message, error step on turn/end.reason. RFC moved proposed -> implemented with an implementation note recording the two scope refinements.
2026-06-21 10:00:06 +08:00
const reasons: TurnEndReason[] = []
refactor(events): remove the turn boundary mirror events Complete the boundary-mirror removal begun with the step mirrors: drop `agent/turn-start` and `agent/turn-end` from the agent event taxonomy. Turn and step boundaries are now read exclusively off the durable `session/event` feed (`turn/start`/`turn/end`/`step/start`/`step/end`) — there is no `agent/*` mirror for any boundary. - loop.ts: delete both turn emits; `closeTurn` loses its `emit` parameter and its now-unreachable idempotency guard (it is called exactly once per turn, on mutually exclusive normal/catch paths); `failTurn` loses the dead post-close branch that only a throwing turn-end LISTENER could reach. - ui-stdio: render turn boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map (the `turn/start` event carries only the turn number, and the session id is not reliably the agent id). ui-stdio is a disposable test REPL, so this migration retires the sole justification the event-domain-semantics RFC gave for KEEPING the turn mirrors. - Tests: reason/turn-number collectors and the boundary-ordering test now read `session/event`; the throwing-turn-boundary-LISTENER tests are deleted (that code path no longer exists). A new test covers the outer-catch disposed branch via a pre-step listener that disposes-then-throws (the surviving real path). - Docs: promote the "remove agent boundary mirror events" RFC to implemented (amended/narrowed — `agent/steering` is RETAINED, not a boundary mirror); update the event-domain-semantics + turn-enclosure RFCs, architecture.md, the cookbook, the ACP/agent/ui-stdio prose, and regenerate the cordis catalog. `agent/steering` and `agent/stream-chunk` are explicitly out of scope (not durable-boundary mirrors). ACP is unaffected — it already settles from the log's `turn/end` + `agent/status`; snapshot goldens are byte-unchanged.
2026-07-02 03:26:45 +08:00
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
simplify(session): fold trace-only usage/error events into load-bearing events The session event vocabulary carried two standalone trace-only events that were not load-bearing as separate records. Fold their facts into nearby load-bearing events and delete the standalone variants. - Token usage now rides on `assistant/message` as an optional `usage` field — the assembled model output and its accounting travel together. The loop folds `assembler.usage` onto the append instead of emitting a separate `usage` event. - The max-tokens path is the no-data-loss host: a step cut off with usage but EMPTY content (e.g. only a dropped tool call) previously emitted a standalone `usage`; it now records an empty-content `assistant/message { content: [], usage }`. `deriveMessages()` skips empty-content assistant messages, so the usage host never injects a spurious content-less assistant turn into the provider transcript. A step with neither content nor usage appends nothing. - An operational error's step number now rides on `turn/end.reason` for `kind: 'error'` (`{ kind: 'error', step, message, code? }`) — the durable turn outcome ACP and resume already consume. `failTurn` sets the reason directly (no separate session `error` event). `agent/error` + logging are unchanged for live diagnostics. - No format-version bump: pre-release, no persisted data, so per the format policy there is nothing to migrate or reject (the RFC's "refresh the format version" criterion over-reached). `version` stays 1. - ACP fixtures + goldens re-recorded (keyless replay): dropped standalone usage/error lines, usage folded onto assistant/message, error step on turn/end.reason. RFC moved proposed -> implemented with an implementation note recording the two scope refinements.
2026-06-21 10:00:06 +08:00
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'max-tokens' }])
const assistant = agent.session.events.find(e => e.type === 'assistant/message')!
expect(assistant.type === 'assistant/message' && assistant.data).toEqual({
turn: 1,
step: 1,
content: [],
provenance: { provider: 'mock', model: 'mock' },
})
expect(assistant.sourceEventSeqs?.length).toBeGreaterThan(0)
simplify(session): fold trace-only usage/error events into load-bearing events The session event vocabulary carried two standalone trace-only events that were not load-bearing as separate records. Fold their facts into nearby load-bearing events and delete the standalone variants. - Token usage now rides on `assistant/message` as an optional `usage` field — the assembled model output and its accounting travel together. The loop folds `assembler.usage` onto the append instead of emitting a separate `usage` event. - The max-tokens path is the no-data-loss host: a step cut off with usage but EMPTY content (e.g. only a dropped tool call) previously emitted a standalone `usage`; it now records an empty-content `assistant/message { content: [], usage }`. `deriveMessages()` skips empty-content assistant messages, so the usage host never injects a spurious content-less assistant turn into the provider transcript. A step with neither content nor usage appends nothing. - An operational error's step number now rides on `turn/end.reason` for `kind: 'error'` (`{ kind: 'error', step, message, code? }`) — the durable turn outcome ACP and resume already consume. `failTurn` sets the reason directly (no separate session `error` event). `agent/error` + logging are unchanged for live diagnostics. - No format-version bump: pre-release, no persisted data, so per the format policy there is nothing to migrate or reject (the RFC's "refresh the format version" criterion over-reached). `version` stays 1. - ACP fixtures + goldens re-recorded (keyless replay): dropped standalone usage/error lines, usage folded onto assistant/message, error step on turn/end.reason. RFC moved proposed -> implemented with an implementation note recording the two scope refinements.
2026-06-21 10:00:06 +08:00
expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
2026-06-19 00:37:12 +08:00
})
it('appends an empty completion anchor for a normal stop with no usage', async () => {
// A clean content-less call stays absent from derived messages but remains
// a durable successful-call boundary for replay consumers.
fix review findings: bump session format version + restore late turn-end warn Codex review of the trace-event fold found two merge-blockers. Blocker #1 — format version. Folding usage onto assistant/message and removing the standalone usage/error events changed the persisted SessionEventMap shape, which per the AGENTS.md "bump the version and reject — don't migrate" policy requires a backend to reject any non-current log. Centralize the version in an exported SESSION_FORMAT_VERSION constant (dsh-session), read by both write sites (Session constructor default, SessionStore.prepare header) and the coordinator's load-time assertVersion check. The constant is pinned at 0: while unreleased the on-disk format is unstable/pre-release, so breaking shape churn is absorbed at v0 (no monotonic bump until the first tagged release) and any non-0 log is rejected on load — no migration. Update every test/fixture/doc that stamps a currently-written header to the constant, bump the ACP snapshot fixture + golden headers to v0, and keep the version-rejection test meaningful by switching its bad value to a clearly non-current 99. AGENTS.md documents both the monotonic (SQLite SCHEMA_VERSION) and pinned-0 (session log) pre-release stances. Blocker #2 — restore the late turn-end warn. failTurn now sets the error reason only while the turn is still open; once turn/end is appended (a throwing agent/turn-end listener after closeTurn) the reason can no longer reach the durable log, so the late throw is logged via ctx.logger.warn instead of vanishing into a futile post-close assignment. A regression test asserts the warn fires. Also guard the normal-step assistant/message append with the same content-or-usage condition as the max-tokens branch (a content-less, usage-less step records no trace-only row), with a covering test.
2026-06-21 11:08:10 +08:00
const adapter = new MockAdapter([[{ type: 'finish', reason: { kind: 'stop' } }]])
const ctx = await harness(adapter)
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
fix review findings: bump session format version + restore late turn-end warn Codex review of the trace-event fold found two merge-blockers. Blocker #1 — format version. Folding usage onto assistant/message and removing the standalone usage/error events changed the persisted SessionEventMap shape, which per the AGENTS.md "bump the version and reject — don't migrate" policy requires a backend to reject any non-current log. Centralize the version in an exported SESSION_FORMAT_VERSION constant (dsh-session), read by both write sites (Session constructor default, SessionStore.prepare header) and the coordinator's load-time assertVersion check. The constant is pinned at 0: while unreleased the on-disk format is unstable/pre-release, so breaking shape churn is absorbed at v0 (no monotonic bump until the first tagged release) and any non-0 log is rejected on load — no migration. Update every test/fixture/doc that stamps a currently-written header to the constant, bump the ACP snapshot fixture + golden headers to v0, and keep the version-rejection test meaningful by switching its bad value to a clearly non-current 99. AGENTS.md documents both the monotonic (SQLite SCHEMA_VERSION) and pinned-0 (session log) pre-release stances. Blocker #2 — restore the late turn-end warn. failTurn now sets the error reason only while the turn is still open; once turn/end is appended (a throwing agent/turn-end listener after closeTurn) the reason can no longer reach the durable log, so the late throw is logged via ctx.logger.warn instead of vanishing into a futile post-close assignment. A regression test asserts the warn fires. Also guard the normal-step assistant/message append with the same content-or-usage condition as the max-tokens branch (a content-less, usage-less step records no trace-only row), with a covering test.
2026-06-21 11:08:10 +08:00
const reasons: TurnEndReason[] = []
refactor(events): remove the turn boundary mirror events Complete the boundary-mirror removal begun with the step mirrors: drop `agent/turn-start` and `agent/turn-end` from the agent event taxonomy. Turn and step boundaries are now read exclusively off the durable `session/event` feed (`turn/start`/`turn/end`/`step/start`/`step/end`) — there is no `agent/*` mirror for any boundary. - loop.ts: delete both turn emits; `closeTurn` loses its `emit` parameter and its now-unreachable idempotency guard (it is called exactly once per turn, on mutually exclusive normal/catch paths); `failTurn` loses the dead post-close branch that only a throwing turn-end LISTENER could reach. - ui-stdio: render turn boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map (the `turn/start` event carries only the turn number, and the session id is not reliably the agent id). ui-stdio is a disposable test REPL, so this migration retires the sole justification the event-domain-semantics RFC gave for KEEPING the turn mirrors. - Tests: reason/turn-number collectors and the boundary-ordering test now read `session/event`; the throwing-turn-boundary-LISTENER tests are deleted (that code path no longer exists). A new test covers the outer-catch disposed branch via a pre-step listener that disposes-then-throws (the surviving real path). - Docs: promote the "remove agent boundary mirror events" RFC to implemented (amended/narrowed — `agent/steering` is RETAINED, not a boundary mirror); update the event-domain-semantics + turn-enclosure RFCs, architecture.md, the cookbook, the ACP/agent/ui-stdio prose, and regenerate the cordis catalog. `agent/steering` and `agent/stream-chunk` are explicitly out of scope (not durable-boundary mirrors). ACP is unaffected — it already settles from the log's `turn/end` + `agent/status`; snapshot goldens are byte-unchanged.
2026-07-02 03:26:45 +08:00
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
fix review findings: bump session format version + restore late turn-end warn Codex review of the trace-event fold found two merge-blockers. Blocker #1 — format version. Folding usage onto assistant/message and removing the standalone usage/error events changed the persisted SessionEventMap shape, which per the AGENTS.md "bump the version and reject — don't migrate" policy requires a backend to reject any non-current log. Centralize the version in an exported SESSION_FORMAT_VERSION constant (dsh-session), read by both write sites (Session constructor default, SessionStore.prepare header) and the coordinator's load-time assertVersion check. The constant is pinned at 0: while unreleased the on-disk format is unstable/pre-release, so breaking shape churn is absorbed at v0 (no monotonic bump until the first tagged release) and any non-0 log is rejected on load — no migration. Update every test/fixture/doc that stamps a currently-written header to the constant, bump the ACP snapshot fixture + golden headers to v0, and keep the version-rejection test meaningful by switching its bad value to a clearly non-current 99. AGENTS.md documents both the monotonic (SQLite SCHEMA_VERSION) and pinned-0 (session log) pre-release stances. Blocker #2 — restore the late turn-end warn. failTurn now sets the error reason only while the turn is still open; once turn/end is appended (a throwing agent/turn-end listener after closeTurn) the reason can no longer reach the durable log, so the late throw is logged via ctx.logger.warn instead of vanishing into a futile post-close assignment. A regression test asserts the warn fires. Also guard the normal-step assistant/message append with the same content-or-usage condition as the max-tokens branch (a content-less, usage-less step records no trace-only row), with a covering test.
2026-06-21 11:08:10 +08:00
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'completed' }])
const assistant = agent.session.events.find(e => e.type === 'assistant/message')!
expect(assistant.type === 'assistant/message' && assistant.data).toEqual({
turn: 1,
step: 1,
content: [],
provenance: { provider: 'mock', model: 'mock' },
})
expect(assistant.sourceEventSeqs?.length).toBe(1)
fix review findings: bump session format version + restore late turn-end warn Codex review of the trace-event fold found two merge-blockers. Blocker #1 — format version. Folding usage onto assistant/message and removing the standalone usage/error events changed the persisted SessionEventMap shape, which per the AGENTS.md "bump the version and reject — don't migrate" policy requires a backend to reject any non-current log. Centralize the version in an exported SESSION_FORMAT_VERSION constant (dsh-session), read by both write sites (Session constructor default, SessionStore.prepare header) and the coordinator's load-time assertVersion check. The constant is pinned at 0: while unreleased the on-disk format is unstable/pre-release, so breaking shape churn is absorbed at v0 (no monotonic bump until the first tagged release) and any non-0 log is rejected on load — no migration. Update every test/fixture/doc that stamps a currently-written header to the constant, bump the ACP snapshot fixture + golden headers to v0, and keep the version-rejection test meaningful by switching its bad value to a clearly non-current 99. AGENTS.md documents both the monotonic (SQLite SCHEMA_VERSION) and pinned-0 (session log) pre-release stances. Blocker #2 — restore the late turn-end warn. failTurn now sets the error reason only while the turn is still open; once turn/end is appended (a throwing agent/turn-end listener after closeTurn) the reason can no longer reach the durable log, so the late throw is logged via ctx.logger.warn instead of vanishing into a futile post-close assignment. A regression test asserts the warn fires. Also guard the normal-step assistant/message append with the same content-or-usage condition as the max-tokens branch (a content-less, usage-less step records no trace-only row), with a covering test.
2026-06-21 11:08:10 +08:00
expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
})
2026-06-19 00:37:12 +08:00
it('keeps safe max-tokens assistant content while dropping truncated tool calls', async () => {
const callId = CallId('c1')
const adapter = new MockAdapter([[
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: 'partial text' },
{ type: 'block-end', index: 0, block: { type: 'text', text: 'partial text' } },
{ type: 'block-start', index: 1, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 1, id: callId, name: 'echo', argumentsDelta: '{"text"' },
{ type: 'finish', reason: { kind: 'max-tokens' } },
]])
const ctx = await harness(adapter)
let stepResults = 0
ctx.on('agent/step-result', async (_agent, _turn, _step, message, next) => {
stepResults += 1
expect(message.content).toEqual([{ type: 'text', text: 'partial text' }])
return next()
})
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
2026-06-19 00:37:12 +08:00
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(stepResults).toBe(1)
expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false)
expect(agent.session.deriveMessages()).toEqual([
{ role: 'user', content: [{ type: 'text', text: 'go' }] },
2026-07-14 21:57:52 +08:00
{ role: 'assistant', content: [{ type: 'text', text: 'partial text' }], provenance: { provider: 'mock', model: 'mock' } },
])
})
it('contains a step/end observer failure without changing continuation', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', { text: 'x' }),
textResponse('continued after tool call'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
name: 'echo',
description: '',
parameters: { text: { type: 'string' } },
async execute(args) {
return [{ type: 'text', text: String(args.text) }]
},
}))
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let threw = false
// Post-commit session observers cannot control the loop. The tool call still
// drives the second model request, and the turn completes normally.
ctx.on('session/event', (_session, event) => {
if (event.type === 'step/end' && !threw) { threw = true; throw new Error('bad step/end listener') }
})
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(2)
const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed')
})
it('keeps same-tick sends in separate turns and checkpoints before the next starts', async () => {
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const firstFlush = Promise.withResolvers<undefined>()
const releaseFirstFlush = Promise.withResolvers<undefined>()
let flushes = 0
ctx.on('session/flush', async (session) => {
if (session !== agent.session) return
flushes += 1
if (flushes === 1) {
firstFlush.resolve(undefined)
await releaseFirstFlush.promise
}
})
const turns: number[] = []
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'turn/start') turns.push(event.data.turn)
})
const idle = waitForIdle(ctx, agent)
send(agent, 'first message')
send(agent, 'second message')
await firstFlush.promise
expect(turns).toEqual([1])
expect(adapter.requests).toHaveLength(1)
releaseFirstFlush.resolve(undefined)
await idle
expect(turns).toEqual([1, 2])
expect(flushes).toBe(2)
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first answer')
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message')
})
it('holds a turn-end listener send behind the closing turn checkpoint', async () => {
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const firstFlush = Promise.withResolvers<undefined>()
const releaseFirstFlush = Promise.withResolvers<undefined>()
let flushes = 0
ctx.on('session/flush', async (session) => {
if (session !== agent.session) return
flushes += 1
if (flushes === 1) {
firstFlush.resolve(undefined)
await releaseFirstFlush.promise
}
})
const turns: number[] = []
const statuses: string[] = []
ctx.on('agent/status', (subject, status) => {
if (subject === agent) statuses.push(status)
})
ctx.on('session/event', (session, event) => {
if (session !== agent.session) return
if (event.type === 'turn/start') turns.push(event.data.turn)
if (event.type === 'turn/end' && event.data.turn === 1) send(agent, 'turn-end listener message')
})
const idle = waitForIdle(ctx, agent)
send(agent, 'first message')
await firstFlush.promise
expect(turns).toEqual([1])
expect(adapter.requests).toHaveLength(1)
releaseFirstFlush.resolve(undefined)
await idle
expect(turns).toEqual([1, 2])
expect(statuses).toEqual(['running', 'idle'])
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first answer')
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('turn-end listener message')
})
it('keeps a reentrant agent/queued send as the next independent turn', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let nested = false
ctx.on('agent/queued', (subject) => {
if (subject !== agent || nested) return
nested = true
send(agent, 'queued listener message')
})
const idle = waitForIdle(ctx, agent)
send(agent, 'outer message')
await idle
const turns = agent.session.events.filter(event => event.type === 'turn/start')
const messages = agent.session.events
.filter(event => event.type === 'user/message')
.map(event => event.data.content)
expect(turns).toHaveLength(2)
expect(messages).toEqual([
[{ type: 'text', text: 'outer message' }],
[{ type: 'text', text: 'queued listener message' }],
])
})
it('preserves independent turn sources across an adjacent microtask send', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(ctx, agent)
agent.send([{ type: 'text', text: 'user message' }])
await Promise.resolve()
agent.send(
[{ type: 'text', text: 'plugin message' }],
{ source: { kind: 'plugin', plugin: 'test' } },
)
await idle
const triggers = agent.session.events
.filter(event => event.type === 'turn/start')
.map(event => event.data.trigger)
const sources = agent.session.events
.filter(event => event.type === 'user/message')
.map(event => event.data.source)
expect(triggers).toEqual([
{ kind: 'message', source: { kind: 'user' } },
{ kind: 'message', source: { kind: 'plugin', plugin: 'test' } },
])
expect(sources).toEqual([
{ kind: 'user' },
{ kind: 'plugin', plugin: 'test' },
])
})
it('keeps a session-listener send after dequeue in the following turn', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const turns: number[] = []
refactor(events): remove the turn boundary mirror events Complete the boundary-mirror removal begun with the step mirrors: drop `agent/turn-start` and `agent/turn-end` from the agent event taxonomy. Turn and step boundaries are now read exclusively off the durable `session/event` feed (`turn/start`/`turn/end`/`step/start`/`step/end`) — there is no `agent/*` mirror for any boundary. - loop.ts: delete both turn emits; `closeTurn` loses its `emit` parameter and its now-unreachable idempotency guard (it is called exactly once per turn, on mutually exclusive normal/catch paths); `failTurn` loses the dead post-close branch that only a throwing turn-end LISTENER could reach. - ui-stdio: render turn boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map (the `turn/start` event carries only the turn number, and the session id is not reliably the agent id). ui-stdio is a disposable test REPL, so this migration retires the sole justification the event-domain-semantics RFC gave for KEEPING the turn mirrors. - Tests: reason/turn-number collectors and the boundary-ordering test now read `session/event`; the throwing-turn-boundary-LISTENER tests are deleted (that code path no longer exists). A new test covers the outer-catch disposed branch via a pre-step listener that disposes-then-throws (the surviving real path). - Docs: promote the "remove agent boundary mirror events" RFC to implemented (amended/narrowed — `agent/steering` is RETAINED, not a boundary mirror); update the event-domain-semantics + turn-enclosure RFCs, architecture.md, the cookbook, the ACP/agent/ui-stdio prose, and regenerate the cordis catalog. `agent/steering` and `agent/stream-chunk` are explicitly out of scope (not durable-boundary mirrors). ACP is unaffected — it already settles from the log's `turn/end` + `agent/status`; snapshot goldens are byte-unchanged.
2026-07-02 03:26:45 +08:00
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
// queue two messages while idle — first starts turn 1 immediately;
refactor(events): remove the agent/stream-chunk mirror of assistant/chunk The loop recorded every model token delta as a durable `assistant/chunk` session event AND emitted an identical live `agent/stream-chunk` Cordis event one line later. Same StreamChunk, same turn/step; the emit added only the live Agent handle, which the sole consumer discarded. This is the boundary-mirror duplication the event-domain work removed for turn/step boundaries, applied to the token stream — a follow-up the boundary RFC explicitly deferred. The premise is settled: chunk persistence is authoritative (the proposal to stop persisting chunks was rejected — replay/snapshots depend on it), so `assistant/chunk` on `session/event` is the load-bearing token stream and `agent/stream-chunk` is pure redundancy. - Remove the `agent/stream-chunk` declaration + emit; drop the now-unused StreamChunk import from dsh-agent's types. - Migrate `dsh-ui-stdio` (the only live consumer; ACP already reads assistant/chunk off session/event) to render assistant/chunk in its existing session/event listener. Consolidating to one listener also makes the inReasoning dim-SGR flag deterministic across chunk/boundary events (they no longer race across two listeners). - Repoint the agent-loop tests (cancel/loop) and ui-stdio tests to the session/event assistant/chunk feed. - New RFC (implemented/simplification/2026-07-02-remove-stream-chunk-mirror); amend the boundary RFC's retained-list entry to cross-link; update architecture, cookbook, event-domain-semantics, the ACP proposal, and the regenerated cordis catalog. Snapshot goldens unchanged (ACP never used the mirror), confirming no editor-facing transcript change.
2026-07-02 23:42:16 +08:00
// queue the second during turn 1 when the first assistant chunk streams
let queued = false
refactor(events): remove the agent/stream-chunk mirror of assistant/chunk The loop recorded every model token delta as a durable `assistant/chunk` session event AND emitted an identical live `agent/stream-chunk` Cordis event one line later. Same StreamChunk, same turn/step; the emit added only the live Agent handle, which the sole consumer discarded. This is the boundary-mirror duplication the event-domain work removed for turn/step boundaries, applied to the token stream — a follow-up the boundary RFC explicitly deferred. The premise is settled: chunk persistence is authoritative (the proposal to stop persisting chunks was rejected — replay/snapshots depend on it), so `assistant/chunk` on `session/event` is the load-bearing token stream and `agent/stream-chunk` is pure redundancy. - Remove the `agent/stream-chunk` declaration + emit; drop the now-unused StreamChunk import from dsh-agent's types. - Migrate `dsh-ui-stdio` (the only live consumer; ACP already reads assistant/chunk off session/event) to render assistant/chunk in its existing session/event listener. Consolidating to one listener also makes the inReasoning dim-SGR flag deterministic across chunk/boundary events (they no longer race across two listeners). - Repoint the agent-loop tests (cancel/loop) and ui-stdio tests to the session/event assistant/chunk feed. - New RFC (implemented/simplification/2026-07-02-remove-stream-chunk-mirror); amend the boundary RFC's retained-list entry to cross-link; update architecture, cookbook, event-domain-semantics, the ACP proposal, and the regenerated cordis catalog. Snapshot goldens unchanged (ACP never used the mirror), confirming no editor-facing transcript change.
2026-07-02 23:42:16 +08:00
ctx.on('session/event', (_s, event) => {
if (event.type === 'assistant/chunk' && !queued) {
queued = true
send(agent, 'second message')
}
})
send(agent, 'first message')
await waitForIdle(ctx, agent)
expect(turns).toEqual([1, 2])
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first')
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message')
})
it('keeps a model-adapter callback send in the following turn', async () => {
const agentRef: { current?: Agent } = {}
const adapter = new MockAdapter([
() => {
const agent = agentRef.current
if (agent === undefined) throw new Error('model callback ran before agent setup')
send(agent, 'model callback message')
return textResponse('first')
},
textResponse('second'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agentRef.current = agent
const idle = waitForIdle(ctx, agent)
send(agent, 'outer message')
await idle
const messages = agent.session.events
.filter(event => event.type === 'user/message')
.map(event => event.data.content)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
expect(messages).toEqual([
[{ type: 'text', text: 'outer message' }],
[{ type: 'text', text: 'model callback message' }],
])
})
it('awaits session/flush at turn end (persistence checkpoint)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let flushed = 0
let flushedBeforeIdle = false
ctx.on('session/flush', async (session) => {
await new Promise(r => setTimeout(r, 10))
flushed++
flushedBeforeIdle = agent.status !== 'idle'
void session
})
send(agent, 'hi')
await waitForIdle(ctx, agent)
expect(flushed).toBe(1)
expect(flushedBeforeIdle).toBe(true)
})
it('errors from the model surface as agent/error and end the turn', async () => {
const adapter = new MockAdapter([]) // script exhausted → throws
const ctx = await harness(adapter)
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const errors: Error[] = []
Document the codebase thoroughly and tighten type safety Docs: per-folder README.md for packages/ (family overview + one per package: service, events, API, extension points, TODOs), examples/, and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md symlinks) for packages/ and vendor/; module-level doc comments in every packages/*/src file; richer JSDoc on all exported API (event side effects, disposal contracts, error behavior). Root AGENTS.md gains a "Type Safety and Documentation" policy section: the codebase aims to be very type-safe and well documented; type gymnastics are acceptable in core packages when they improve plugin-author DX; verbose docs are fine as long as they stay strictly in sync with the code. Type safety: removed the upstream-inherited "noImplicitAny": false from tsconfig.base.json — packages/* now compile under full strict mode; vendor/loader and vendor/include set it locally (vendor/cordis already did). Eliminated every `: any` / `as any` from packages and examples (catch clauses use unknown + a CodedError narrowing type; event data access uses discriminated-union narrowing). Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL — SchemaSpec with per-property `required: true` booleans, type-level InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and defineTool() so first-party tools get typed execute(args) with zero casts (raw JSON Schema still accepted for MCP interop; chosen over schemastery because it targets JSON Schema generation directly). echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
const reasons: TurnEndReason[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
refactor(events): remove the turn boundary mirror events Complete the boundary-mirror removal begun with the step mirrors: drop `agent/turn-start` and `agent/turn-end` from the agent event taxonomy. Turn and step boundaries are now read exclusively off the durable `session/event` feed (`turn/start`/`turn/end`/`step/start`/`step/end`) — there is no `agent/*` mirror for any boundary. - loop.ts: delete both turn emits; `closeTurn` loses its `emit` parameter and its now-unreachable idempotency guard (it is called exactly once per turn, on mutually exclusive normal/catch paths); `failTurn` loses the dead post-close branch that only a throwing turn-end LISTENER could reach. - ui-stdio: render turn boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map (the `turn/start` event carries only the turn number, and the session id is not reliably the agent id). ui-stdio is a disposable test REPL, so this migration retires the sole justification the event-domain-semantics RFC gave for KEEPING the turn mirrors. - Tests: reason/turn-number collectors and the boundary-ordering test now read `session/event`; the throwing-turn-boundary-LISTENER tests are deleted (that code path no longer exists). A new test covers the outer-catch disposed branch via a pre-step listener that disposes-then-throws (the surviving real path). - Docs: promote the "remove agent boundary mirror events" RFC to implemented (amended/narrowed — `agent/steering` is RETAINED, not a boundary mirror); update the event-domain-semantics + turn-enclosure RFCs, architecture.md, the cookbook, the ACP/agent/ui-stdio prose, and regenerate the cordis catalog. `agent/steering` and `agent/stream-chunk` are explicitly out of scope (not durable-boundary mirrors). ACP is unaffected — it already settles from the log's `turn/end` + `agent/status`; snapshot goldens are byte-unchanged.
2026-07-02 03:26:45 +08:00
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'hi')
await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1)
expect(errors[0]!.message).toContain('script exhausted')
expect(reasons[0]).toMatchObject({ kind: 'error' })
simplify(session): fold trace-only usage/error events into load-bearing events The session event vocabulary carried two standalone trace-only events that were not load-bearing as separate records. Fold their facts into nearby load-bearing events and delete the standalone variants. - Token usage now rides on `assistant/message` as an optional `usage` field — the assembled model output and its accounting travel together. The loop folds `assembler.usage` onto the append instead of emitting a separate `usage` event. - The max-tokens path is the no-data-loss host: a step cut off with usage but EMPTY content (e.g. only a dropped tool call) previously emitted a standalone `usage`; it now records an empty-content `assistant/message { content: [], usage }`. `deriveMessages()` skips empty-content assistant messages, so the usage host never injects a spurious content-less assistant turn into the provider transcript. A step with neither content nor usage appends nothing. - An operational error's step number now rides on `turn/end.reason` for `kind: 'error'` (`{ kind: 'error', step, message, code? }`) — the durable turn outcome ACP and resume already consume. `failTurn` sets the reason directly (no separate session `error` event). `agent/error` + logging are unchanged for live diagnostics. - No format-version bump: pre-release, no persisted data, so per the format policy there is nothing to migrate or reject (the RFC's "refresh the format version" criterion over-reached). `version` stays 1. - ACP fixtures + goldens re-recorded (keyless replay): dropped standalone usage/error lines, usage folded onto assistant/message, error step on turn/end.reason. RFC moved proposed -> implemented with an implementation note recording the two scope refinements.
2026-06-21 10:00:06 +08:00
// The durable failure lives entirely on turn/end.reason (with the failing
// step), not a standalone error event.
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'error', step: 1 })
})
it('disposing the loop fiber mid-turn stops the loop (HMR safety)', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
2026-07-14 02:32:35 +08:00
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
expect(ctx.agents.get(SessionId('scoped'))).toBe(agent)
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
await fiber.dispose()
2026-07-14 02:32:35 +08:00
await driverDone(agent)
expect(agent.status).toBe('disposed')
expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined()
expect(() => { send(agent, 'too late') }).toThrow('disposed')
})
it('creates agents from config on startup', async () => {
const adapter = new MockAdapter([textResponse('from config')])
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, {
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock' }],
})
ctx.llm.registerAdapter(['mock'], adapter)
2026-07-14 02:32:35 +08:00
const agent = ctx.agents.list()[0]!
expect(agent).toBeDefined()
expect(agent.id).toBe(agent.session.id)
expect(agent.id).toMatch(/^config-agent-session-/)
expect(agent.options.model).toBe('mock')
// the agent is alive: send triggers a turn
send(agent, 'hi')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
})
2026-06-25 23:35:13 +08:00
it('attaches config agent cwd to the fresh session header', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, {
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock', cwd: '/work/project' }],
2026-06-25 23:35:13 +08:00
})
2026-07-14 02:32:35 +08:00
const agent = ctx.agents.list()[0]!
2026-06-25 23:35:13 +08:00
expect(agent.session.header.cwd).toBe('/work/project')
})
it('replays a session log into an identical derived history', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', { text: 'x' }),
textResponse('done'),
])
const ctx = await harness(adapter)
Document the codebase thoroughly and tighten type safety Docs: per-folder README.md for packages/ (family overview + one per package: service, events, API, extension points, TODOs), examples/, and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md symlinks) for packages/ and vendor/; module-level doc comments in every packages/*/src file; richer JSDoc on all exported API (event side effects, disposal contracts, error behavior). Root AGENTS.md gains a "Type Safety and Documentation" policy section: the codebase aims to be very type-safe and well documented; type gymnastics are acceptable in core packages when they improve plugin-author DX; verbose docs are fine as long as they stay strictly in sync with the code. Type safety: removed the upstream-inherited "noImplicitAny": false from tsconfig.base.json — packages/* now compile under full strict mode; vendor/loader and vendor/include set it locally (vendor/cordis already did). Eliminated every `: any` / `as any` from packages and examples (catch clauses use unknown + a CodedError narrowing type; event data access uses discriminated-union narrowing). Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL — SchemaSpec with per-property `required: true` booleans, type-level InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and defineTool() so first-party tools get typed execute(args) with zero casts (raw JSON Schema still accepted for MCP interop; chosen over schemastery because it targets JSON Schema generation directly). echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
ctx.tools.register(defineTool({
name: 'echo',
description: '',
Document the codebase thoroughly and tighten type safety Docs: per-folder README.md for packages/ (family overview + one per package: service, events, API, extension points, TODOs), examples/, and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md symlinks) for packages/ and vendor/; module-level doc comments in every packages/*/src file; richer JSDoc on all exported API (event side effects, disposal contracts, error behavior). Root AGENTS.md gains a "Type Safety and Documentation" policy section: the codebase aims to be very type-safe and well documented; type gymnastics are acceptable in core packages when they improve plugin-author DX; verbose docs are fine as long as they stay strictly in sync with the code. Type safety: removed the upstream-inherited "noImplicitAny": false from tsconfig.base.json — packages/* now compile under full strict mode; vendor/loader and vendor/include set it locally (vendor/cordis already did). Eliminated every `: any` / `as any` from packages and examples (catch clauses use unknown + a CodedError narrowing type; event data access uses discriminated-union narrowing). Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL — SchemaSpec with per-property `required: true` booleans, type-level InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and defineTool() so first-party tools get typed execute(args) with zero casts (raw JSON Schema still accepted for MCP interop; chosen over schemastery because it targets JSON Schema generation directly). echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
parameters: { text: { type: 'string' } },
async execute(args) {
return [{ type: 'text', text: String(args.text) }]
},
Document the codebase thoroughly and tighten type safety Docs: per-folder README.md for packages/ (family overview + one per package: service, events, API, extension points, TODOs), examples/, and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md symlinks) for packages/ and vendor/; module-level doc comments in every packages/*/src file; richer JSDoc on all exported API (event side effects, disposal contracts, error behavior). Root AGENTS.md gains a "Type Safety and Documentation" policy section: the codebase aims to be very type-safe and well documented; type gymnastics are acceptable in core packages when they improve plugin-author DX; verbose docs are fine as long as they stay strictly in sync with the code. Type safety: removed the upstream-inherited "noImplicitAny": false from tsconfig.base.json — packages/* now compile under full strict mode; vendor/loader and vendor/include set it locally (vendor/cordis already did). Eliminated every `: any` / `as any` from packages and examples (catch clauses use unknown + a CodedError narrowing type; event data access uses discriminated-union narrowing). Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL — SchemaSpec with per-property `required: true` booleans, type-level InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and defineTool() so first-party tools get typed execute(args) with zero casts (raw JSON Schema still accepted for MCP interop; chosen over schemastery because it targets JSON Schema generation directly). echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
}))
Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id # Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
2026-07-18 12:21:15 +08:00
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'run')
await waitForIdle(ctx, agent)
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes the two gaps in the "brand ids that cross package boundaries" policy and fixes the dependency direction so a capability package never pulls in an unrelated one. - Extract the `Branded<B>` primitive into a new standalone type-only package `@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps. dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session, dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a generic execution backend must not couple to the LLM or session vocabulary). - Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id, the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary that casts SessionId -> OwnerToken. - Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters at the config boundary and the inner create()/resume casts disappear (only the genuinely-new per-run session-id string is cast). - Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store Map keys and public params/exports (SessionStore, AgentRegistry + factory options, the ACP session-id surface + ToolPresenter CallId map, the persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps). - Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point the Branded type-equiv at dsh-brand, fix stale param types in the session/ agent/bash READMEs, regenerate the cordis catalog + module graph. Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
const replayed = ctx.sessions.create(SessionId('replayed'), { seed: [...agent.session.events] })
expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages())
// event-by-event identity of types
expect(replayed.events.map(e => e.type)).toEqual(
agent.session.events.map(e => e.type))
})
})