# @ycodium-ai/agent-fake

A minimal executor plugin. It starts no CLI and replays a fixed event script:

- `startSession` emits `session.started`
- `sendTurn` emits `turn.started`, one `content.delta`, then `turn.completed` (the host binds the turn by `turn.started`)
- `config.script` picks the script: `plain` (default), `approval-tool` (two approvals before the reply), `async-question` (on a thread's first turn, the reply plus one non-blocking `user-input.requested` with `responseMode: "message"`), `stream` (a Markdown answer in `config.streamChunks` deltas, default 200, one every `config.streamIntervalMs`, default 30; the UI probe `interaction-table.ts` measures streaming with it), `replay` (recorded events; see [Event replay](#event-replay)), `mcp-send` (real calls through the built-in MCP server the host handed to `startSession`, the same path a real agent takes: a turn whose input (after the host’s leading skill, memory and per-turn tool guidance blocks are skipped) is a JSON object is passed as the arguments of one `send_message` call, and a JSON array of `{ "tool": "…", "arguments": { … } }` is called step by step, for example `ycodium_plugin_define`, then `ycodium_plugin_run`, then a tool the mounted plugin contributes; the reply has one line per call with its result or error, and any other input gets the plain reply; see [Playbook mode](#playbook-mode) for per-thread scripts that ignore turn input), `visualize` (emits one in-progress builtin tool row, holds for `config.previewHoldMs` milliseconds so the input preview can paint, then one real `visualize_visualize` call on that same server; the tool row’s result is the MCP confirmation text, and the reply is “The figure is in the card above.”), or `lane` (the UI probe `lane1-journey.ts` script: the user message's `lane:stream|plain|approve|question|attach|hold` picks the turn; `hold` accepts a second `sendTurn` with the in-flight turn id as a steer and folds that text into the reply, and a steer whose target turn has ended is refused with `code: "provider-rejected"`; a playbook instance accepts steers too (see [Playbook mode](#playbook-mode)); `config.holdMs` is how long `hold` waits, default 15000)
- `interruptTurn` (or an aborted `AbortSignal`) ends the in-flight turn as `interrupted`
- `readThread` returns the bookkeeping the instance recorded
- `probe` reports two models, `fake` (default) and `fake-large`
- `usage` answers quota and token reads deterministically (no network, no disk): `readQuota` returns a session and a weekly window that creep up by one percent per read, `readTokenUsage` returns one record per day for the past two weeks (alternating a priced and an unpriced model), `redeemQuotaReset` spends one of two reset credits and zeroes the session window

The card also shows how a third-party executor gets its own settings form: `configSchema` marks `replyText` with `x-ycodium-form`, and the labels resolve in this package's `locales/en.json` and `locales/zh-CN.json`. `replyText` is what the plain script answers; empty means `fake turn complete`.

The instance config `usageScript` picks how quota reads answer: `available` (default), `balance` (the API-key shape: a remaining CNY balance, no windows), `rate-limited`, `custom-endpoint` or `signed-out`. It is not in the settings form; edit the instance config to change it. The window labels are locale keys (`usage.window.session`, `usage.window.weekly`, `usage.balance.account`), a worked example of how an agent plugin labels its quota rows.

Two uses:

1. **Template** for an `@ycodium-ai/agent-*` author: one card, `createInstance`, lease-scoped `host.process.spawn` (this fake never calls it), `ctx.log` instead of console.
2. **Stand-in** for host orchestration tests (W1 reactors / ingestion / TurnIntent). `driverKind` is `fake`.

This package is **not** in the default bundle and **not** a first-party add-grid recommended row. Load it only in tests or an explicit patch entry. Manifest permission is `{ kind: "executor" }`.

Develop with `vp run --filter @ycodium-ai/agent-fake typecheck`, `build`, and `test`.

## Event replay

Set `config.script: "replay"` to replay recorded `ExecutorEvent` output through the fake's
event emitter. `config.replayEventsPath` must be an absolute path on the executor's machine
to a JSON array or an adapter golden object with an `events` array. The file is read on each
turn; an unreadable file, invalid event envelope or malformed JSON fails that turn with a
`replay:` error. The plugin does not bundle recordings or depend on another executor plugin.

```json
{
  "script": "replay",
  "replayEventsPath": "/absolute/path/to/recorded-events.json",
  "replayStepMs": 50,
  "replayTaskHoldMs": 4000
}
```

`replayStepMs` sets the interval between retained events (default 30 ms).
`replayTaskHoldMs` adds a hold after every `task.started` (default 3000 ms), so observers can
read the working state before completion. Both are non-negative integers; zero disables
that delay. These settings are outside the settings form. Timing is configured, not recorded.

Replay keeps items, content, tasks, plans and other turn-local events. Lifecycle events
(`isExecutorLifecycleEvent` in the plugin API: session, thread and turn lifecycle, plus
`runtime.error`, which the host turns into a session error) are owned by the fake and never
replayed, so a recording cannot fail the turn behind the fake's back. See the allowlist in
[`replayScript.ts`](src/replayScript.ts). Events get the current thread and turn IDs and fresh
event metadata. Item, request and task IDs are fresh per replay, with tool/task links kept
consistent. Adapter golden `⟨undefined⟩` fields are omitted. Assistant text is also recorded
by `readThread`. Interrupting a replay cancels its delays and ends the turn as `interrupted`.

## Playbook mode

Replay recorded conversations or drive multi-thread acceptance tests with
`config.script: "mcp-send"` and `config.playbookPath` pointing to a JSON file on the executor's
machine. Use an absolute path. This option is outside the settings form and applies only to
`mcp-send`. Each turn runs the next batch, ignoring its input, including host wake-ups and follow-ups.

A playbook instance accepts a steer only where the playbook says so: while the batch the thread is
running ends with `awaitSteer`. Then a second `sendTurn` carrying the running turn's id returns that
turn, and when the current batch finishes the next batch runs inside the same turn, one steer at a
time in arrival order (each still subject to `expectInput`). A playbook derived from a recording has
`awaitSteer` exactly where the recorded agent took a steer, so a steer elsewhere would shift every
later batch by one; it is refused instead, and the host starts it as its own turn, as in the
recording. A steer the playbook has no batch for is logged as `playbook has no step for a steered
input` and noted in the reply. A steer the running batch does not await, or whose target turn has
already ended or is not the running turn, is refused with an error carrying
`code: "provider-rejected"`, so the host puts the message back in the queue. A plain
`mcp-send` instance without a playbook still refuses a second turn while one is running.

`{ "awaitSteer": { "timeoutMs": 60000, "continueOnTimeout": true } }` treats a steer that never
arrives as already folded into the turn: on timeout it runs the next batch inside the same turn
instead of ending it. Derived playbooks set it on member-machine sessions, where a steer can reach
the host before the member's turn starts and be merged into that turn's input. The next batch then
relies on its own `waitFor` steps; a steer that arrives later is refused like any other and starts
a turn with no matching batch.

This example assigns one script by thread ID and another by title:

```json
{
  "version": 1,
  "threads": [
    {
      "match": { "threadId": "^team-member:[^:]+:[^:]+:B$" },
      "turns": [
        [{ "id": "list", "tool": "work_list_items" }],
        [
          {
            "tool": "work_claim_item",
            "arguments": { "workItemId": "{{step:list.items.0.workItemId}}" }
          }
        ]
      ]
    },
    {
      "match": { "title": "Coordinator" },
      "turns": [
        [
          {
            "tool": "send_message",
            "arguments": { "threadId": "team-member:demo:run:B", "text": "Continue" }
          }
        ],
        [
          { "wait": { "tool": "my-plugin_hello", "timeoutMs": 30000 } },
          { "tool": "my-plugin_hello" }
        ]
      ]
    }
  ]
}
```

`version` must be `1`. Matches need a `threadId` regex (supply `^…$` for whole-ID matching)
or a case-sensitive `title` substring. Both conditions must hold if both exist; missing titles
cannot match. On its first turn, a thread claims the first unclaimed matching line in file order.
Claims, cursors and results survive `stopSession` within the instance. The file is read once
on first use, including failures: an unreadable or invalid file yields `playbook: <reason>`
every turn without tools. A new instance is required to reload it.

Each batch runs steps in order. Calls take `tool`, optional `id`, and object `arguments`
(default `{}`). String values at any depth support `{{thread}}` and `{{step:<id>.<path>}}`.
References can cross turns; dot paths use numeric array indexes, and omitting the path takes
the whole value. Results prefer `structuredContent`, then JSON parsed from `content[0].text`,
then that text. A whole-string placeholder preserves types; interpolation JSON-stringifies
non-strings. Unresolved references skip that call and reply
`mcp-send <tool> failed: unresolved placeholder <placeholder>`, then continue.

Calls, `wait` and `waitFor` accept an optional `server` alongside `tool` (for example,
`{ "server": "ycodium-home", "tool": "work_list_items" }`); it selects the named HTTP server
from `startSession.mcpServers`, defaults to the built-in server, and gives tool events the
`mcp__<server>__<tool>` prefix (`mcp__ycodium__<tool>` by default). Clients are reused within
the session and closed when it ends; events and replies use server names and redact server URLs.

A `wait` polls `tools/list` on that MCP server every 250 ms for the tool name or its
`mcp__<server>__` prefix, defaulting to a 30000 ms timeout. It replies
`mcp-send wait <tool>: ready` or `mcp-send wait <tool> failed: timeout`, then continues, with no
tool items. A `waitFor`, such as
`{ "waitFor": { "tool": "message_inbox_peek", "path": "items", "atLeast": 2 } }`, calls that tool
with its optional `arguments` (none by default; `{{thread}}` and `{{step:…}}` placeholders are
resolved as for calls, and an unresolvable one replies `mcp-send <tool> failed: <error>` without
polling) every 250 ms on one session until the value at the dot `path` of its result is
an array at least `atLeast` long, or a number at least `atLeast`; any other value found there (for
example one entry picked by a selector such as `items[title=<encodeURIComponent value>]`) counts
as 1, and a tool error counts as not yet. Dots inside a selector's brackets do not split the path.
An optional `id` keeps the result of the ready poll so later calls can reference it with
`{{step:<id>.<path>}}`; it is not added to the history that `{{step:*…}}` searches.
Point it only at read-only tools, since every poll is a real call. It also defaults to 30000 ms,
replies `mcp-send waitFor <tool> <path> >= <atLeast>: ready` or `… failed: timeout`, emits no tool
items and continues. Calls reply `mcp-send <tool>: <JSON result>` or `mcp-send <tool> failed: <error>` and
emit paired `item.started` / `item.completed` events carrying the prefixed tool name, resolved
input, raw result and status. Abort stops before the next step and ends the turn as `interrupted`.

A batch may start with `{ "expectInput": "<text>" }`: it runs only on a turn whose input contains
that text. Each turn runs the earliest batch that has not run yet and either matches the turn's
input or, without `expectInput`, is the earliest batch left; so batches keyed by their wake-up text
can run out of order, and a playbook without `expectInput` still runs batch by batch. A turn that
matches nothing falls back to the plain `mcp-send` handling below. Recorded replays key each batch
by the message that started that turn, so wake-ups arriving in a different order still reach the
right batch. `expectInput` is valid only as a batch's first step.

Threads with no matching line, or with all batches consumed, use the original `mcp-send`
input handling: JSON requests call tools and ordinary text gets `replyText`. Omitting
`playbookPath` keeps the existing scripts and output unchanged.

## License

UNLICENSED.
