From 91cea06d76b2e5e3e49d01c970e39adb0993a5e5 Mon Sep 17 00:00:00 2001 From: Kai Liu Date: Sun, 2 Aug 2026 19:49:07 +0800 Subject: [PATCH 1/2] feat: modernize coding CLI integrations Add protocol-aware session and live status handling, attachment input, ACP permission mediation, and updated coding CLI adapters. Remove Kiro and Gemini support and expand deterministic integration coverage. --- .agents/skills/codex-cli-reference/SKILL.md | 1 + .agents/skills/goose-cli-skill/SKILL.md | 3 + .agents/skills/kilo-cli-skill/SKILL.md | 8 +- .agents/skills/kimi-cli-skill/SKILL.md | 5 +- .agents/skills/kiro-cli-skill/SKILL.md | 39 - .../opencode-developer-researcher/SKILL.md | 4 + .agents/skills/qwen-code-skill/SKILL.md | 7 +- AGENTS.md | 11 +- README.md | 9 +- README.zh-CN.md | 7 +- bun.lock | 77 +- package.json | 7 +- packages/agents/adapter.ts | 22 +- packages/agents/capabilities.ts | 87 ++ packages/agents/claude/client.ts | 311 ++++- packages/agents/claude/session-state.ts | 250 +++- packages/agents/codebuddy/client.ts | 105 +- packages/agents/codex/app-events.ts | 544 +++++++++ packages/agents/codex/app-server.ts | 395 +++++++ packages/agents/codex/client.ts | 245 +++- packages/agents/codex/index.ts | 1 + packages/agents/crush/client.ts | 25 +- packages/agents/gemini/client.ts | 286 ----- packages/agents/gemini/index.ts | 12 - packages/agents/gemini/session-state.ts | 103 -- packages/agents/goose/client.ts | 101 +- packages/agents/index.ts | 5 + packages/agents/kilo/client.ts | 112 +- packages/agents/kimi/client.ts | 99 +- packages/agents/kiro/client.ts | 405 ------- packages/agents/kiro/index.ts | 12 - packages/agents/kiro/session-state.ts | 46 - packages/agents/opencode/client.ts | 73 +- packages/agents/opencode/events.ts | 288 +++++ packages/agents/opencode/index.ts | 3 + packages/agents/opencode/prompt-monitor.ts | 70 ++ packages/agents/opencode/server.ts | 178 ++- packages/agents/openhands/client.ts | 23 +- packages/agents/openhands/session-state.ts | 5 + packages/agents/pi/client.ts | 31 +- packages/agents/pi/session-state.ts | 63 + packages/agents/qwen/client.ts | 23 +- packages/agents/registry.ts | 7 +- packages/agents/runtime/acp-client.ts | 1045 +++++++++++++++++ packages/agents/runtime/base-client.ts | 4 +- packages/agents/runtime/protocol-drift.ts | 35 + packages/agents/session-state/shared.ts | 66 +- packages/agents/shared.ts | 9 +- packages/agents/test/acp-client.test.ts | 141 +++ packages/agents/test/agent-registry.test.ts | 14 - .../test/claude-ask-user-question.test.ts | 89 +- .../agents/test/claude-stream-status.test.ts | 175 ++- packages/agents/test/cli-command.test.ts | 78 +- packages/agents/test/codex-app-events.test.ts | 216 ++++ .../agents/test/gemini-stream-status.test.ts | 114 -- .../agents/test/goose-stream-status.test.ts | 2 +- .../agents/test/kilo-stream-status.test.ts | 2 +- packages/agents/test/kiro-client.test.ts | 30 - .../agents/test/kiro-stream-status.test.ts | 159 --- packages/agents/test/opencode-events.test.ts | 132 +++ .../test/opencode-prompt-monitor.test.ts | 54 + .../test/openhands-stream-status.test.ts | 9 + packages/agents/test/pi-stream-status.test.ts | 27 + packages/agents/test/protocol-drift.test.ts | 25 + .../agents/test/session-inspector.test.ts | 122 ++ packages/agents/types.ts | 19 +- packages/config/dashboard-config.ts | 12 - packages/config/index.ts | 1 - packages/config/local/inbox.test.ts | 31 + packages/config/local/inbox.ts | 120 +- packages/config/local/ode-schema.ts | 13 +- packages/config/local/ode-store.ts | 8 - packages/config/local/ode.ts | 8 - packages/config/local/redis.ts | 6 +- packages/config/local/sessions.ts | 27 +- packages/core/cli-handlers/task.ts | 2 +- packages/core/cron/scheduler.ts | 3 +- packages/core/kernel/pending-question.ts | 15 +- packages/core/kernel/recovery.ts | 18 +- packages/core/kernel/request-run.ts | 894 ++++---------- packages/core/kernel/runtime-facade.ts | 32 +- packages/core/kernel/session-bootstrap.ts | 12 + packages/core/kernel/stop-command.ts | 15 - packages/core/model/inbound-decision.ts | 3 +- packages/core/model/raw-inbound-event.ts | 2 + packages/core/onboarding.ts | 1 - packages/core/runtime/helpers.ts | 7 + packages/core/runtime/ode-run-events.ts | 91 ++ packages/core/runtime/session-event-buffer.ts | 156 +++ packages/core/tasks/scheduler.test.ts | 2 +- packages/core/tasks/scheduler.ts | 3 +- packages/core/test/adapter-contracts.test.ts | 3 + .../test/contracts/agent-adapter-contract.ts | 8 +- .../core/test/live-provider-smoke.e2e.test.ts | 3 +- packages/core/test/pending-question.test.ts | 74 -- packages/core/test/recovery.test.ts | 42 - packages/core/test/runtime-e2e.test.ts | 7 +- packages/core/test/runtime-helpers.test.ts | 9 + .../core/test/runtime-resilience-e2e.test.ts | 325 +---- .../core/test/session-event-buffer.test.ts | 71 ++ packages/core/test/status-message.test.ts | 2 +- packages/core/test/stop-command.test.ts | 67 -- packages/core/test/web-routes.test.ts | 29 + packages/core/types.ts | 72 +- packages/core/web/agent-check.ts | 6 - packages/core/web/local-settings/slack.ts | 2 - packages/core/web/routes/inbox.ts | 39 + packages/ims/discord/client.ts | 36 +- packages/ims/index.ts | 2 +- packages/ims/lark/client.ts | 79 +- packages/ims/shared/attachment-store.test.ts | 78 ++ packages/ims/shared/attachment-store.ts | 249 ++++ packages/ims/shared/inbound-policy.test.ts | 51 +- packages/ims/shared/inbound-policy.ts | 7 +- packages/ims/slack/api.test.ts | 69 -- packages/ims/slack/api.ts | 242 ---- packages/ims/slack/client.ts | 81 -- packages/ims/slack/formatter.test.ts | 14 + packages/ims/slack/formatter.ts | 13 +- packages/ims/slack/index.ts | 2 +- packages/ims/slack/message-router.ts | 47 +- packages/ims/slack/slack-inbound-adapter.ts | 1 + packages/live-status-harness/README.md | 14 +- .../reports/agent-live-status.md | 303 +---- .../live-status-harness/reports/claudecode.md | 4 +- .../live-status-harness/reports/gemini.md | 249 ---- packages/live-status-harness/reports/kiro.md | 50 - .../scripts/capture-stream.ts | 48 +- .../scripts/generate-report.ts | 3 - .../test/fixtures/claude-basic-run.json | 27 +- .../test/fixtures/claude-subagent-run.json | 130 ++ .../test/fixtures/codex-app-subagent-run.json | 189 +++ .../test/fixtures/kiro-basic-run.json | 63 - .../fixtures/opencode-child-sync-run.json | 156 +++ .../test/render-status.test.ts | 95 +- .../test/truncation-stability.test.ts | 1 - packages/shared/agent-protocol.ts | 236 ++++ packages/shared/agent-provider.ts | 16 - packages/utils/index.ts | 8 +- packages/utils/session-id.ts | 12 + packages/utils/session-inspector.ts | 112 +- packages/utils/status-stream.ts | 437 ------- packages/utils/status.test.ts | 25 +- packages/utils/status.ts | 37 +- packages/utils/test/status-stream.test.ts | 340 ------ .../web-ui/src/lib/local-setting/store.ts | 21 +- .../session-inspector/SessionDetail.svelte | 2 - .../src/routes/(settings)/agents/+page.svelte | 4 - .../(settings)/inbox/[threadId]/+page.svelte | 124 +- .../workspace/[workspaceName]/+page.svelte | 18 +- 150 files changed, 7764 insertions(+), 4847 deletions(-) delete mode 100644 .agents/skills/kiro-cli-skill/SKILL.md create mode 100644 packages/agents/capabilities.ts create mode 100644 packages/agents/codex/app-events.ts create mode 100644 packages/agents/codex/app-server.ts delete mode 100644 packages/agents/gemini/client.ts delete mode 100644 packages/agents/gemini/index.ts delete mode 100644 packages/agents/gemini/session-state.ts delete mode 100644 packages/agents/kiro/client.ts delete mode 100644 packages/agents/kiro/index.ts delete mode 100644 packages/agents/kiro/session-state.ts create mode 100644 packages/agents/opencode/events.ts create mode 100644 packages/agents/opencode/prompt-monitor.ts create mode 100644 packages/agents/runtime/acp-client.ts create mode 100644 packages/agents/runtime/protocol-drift.ts create mode 100644 packages/agents/test/acp-client.test.ts create mode 100644 packages/agents/test/codex-app-events.test.ts delete mode 100644 packages/agents/test/gemini-stream-status.test.ts delete mode 100644 packages/agents/test/kiro-client.test.ts delete mode 100644 packages/agents/test/kiro-stream-status.test.ts create mode 100644 packages/agents/test/opencode-events.test.ts create mode 100644 packages/agents/test/opencode-prompt-monitor.test.ts create mode 100644 packages/agents/test/protocol-drift.test.ts create mode 100644 packages/core/runtime/ode-run-events.ts create mode 100644 packages/core/runtime/session-event-buffer.ts create mode 100644 packages/core/test/session-event-buffer.test.ts delete mode 100644 packages/core/test/stop-command.test.ts create mode 100644 packages/ims/shared/attachment-store.test.ts create mode 100644 packages/ims/shared/attachment-store.ts delete mode 100644 packages/ims/slack/api.test.ts create mode 100644 packages/ims/slack/formatter.test.ts delete mode 100644 packages/live-status-harness/reports/gemini.md delete mode 100644 packages/live-status-harness/reports/kiro.md create mode 100644 packages/live-status-harness/test/fixtures/claude-subagent-run.json create mode 100644 packages/live-status-harness/test/fixtures/codex-app-subagent-run.json delete mode 100644 packages/live-status-harness/test/fixtures/kiro-basic-run.json create mode 100644 packages/live-status-harness/test/fixtures/opencode-child-sync-run.json create mode 100644 packages/shared/agent-protocol.ts delete mode 100644 packages/utils/status-stream.ts delete mode 100644 packages/utils/test/status-stream.test.ts diff --git a/.agents/skills/codex-cli-reference/SKILL.md b/.agents/skills/codex-cli-reference/SKILL.md index 8d6ff1ab..cdb5e43f 100644 --- a/.agents/skills/codex-cli-reference/SKILL.md +++ b/.agents/skills/codex-cli-reference/SKILL.md @@ -18,6 +18,7 @@ Use this when you need to: - `--sandbox, -s `: set command sandbox (`read-only`, `workspace-write`, `danger-full-access`). - `--ask-for-approval, -a `: control approval behavior (`untrusted`, `on-failure`, `on-request`, `never`). - `--full-auto`: shorthand for lower-friction automation (`on-request` + `workspace-write`). +- `--dangerously-bypass-approvals-and-sandbox`: current explicit unattended `exec` flag; prefer it over the hidden legacy `--yolo` alias when Ode already provides the external isolation boundary. - `-c, --config key=value`: one-off config override for the invocation. ## Common examples diff --git a/.agents/skills/goose-cli-skill/SKILL.md b/.agents/skills/goose-cli-skill/SKILL.md index 96ab13e2..c74ce89c 100644 --- a/.agents/skills/goose-cli-skill/SKILL.md +++ b/.agents/skills/goose-cli-skill/SKILL.md @@ -12,6 +12,7 @@ description: Reference guide for integrating and operating Goose CLI in Ode, foc Use this when adding or debugging Ode's `goose` provider, especially command construction, session resume behavior, and stream-event parsing for live status. ## Recommended invocation pattern +- Preferred Ode integration: `goose acp`, using ACP over stdio. - Non-interactive run: `goose run --output-format stream-json --name -t ` - Resume existing run session: `goose run --output-format stream-json --name --resume -t ` - Open Goose web UI session: `goose web --open` @@ -31,6 +32,8 @@ Use this when adding or debugging Ode's `goose` provider, especially command con - Use `stream-json` output so live status can consume incremental events. - Session IDs can be represented by a stable session name in Ode (`--name`) and resumed with `--resume`. - Goose stores sessions in local storage (SQLite-backed in recent versions), so CLI and Desktop/Web can share context. +- Prefer ACP for native session IDs, cancellation, attachments, and structured updates. Use `goose run --output-format stream-json` only as the compatibility fallback. +- Route ACP `session/request_permission` back to the originating user and wait for an explicit option; cancellation must resolve a pending permission as cancelled. ## Sources - https://block.github.io/goose/docs/guides/sessions/session-management diff --git a/.agents/skills/kilo-cli-skill/SKILL.md b/.agents/skills/kilo-cli-skill/SKILL.md index 8a0d249e..99d4ae25 100644 --- a/.agents/skills/kilo-cli-skill/SKILL.md +++ b/.agents/skills/kilo-cli-skill/SKILL.md @@ -12,14 +12,15 @@ description: Reference guide for integrating and operating Kilo CLI in Ode, focu Use this when implementing or debugging the `kilo` provider in Ode, especially CLI command construction, session behavior, and JSON output handling. ## Recommended invocation pattern for Ode -- Base command: `kilo run --auto --format json --session ""` +- Preferred structured transport: `kilo acp` from the target working directory. +- Compatibility fallback: `kilo run --format json --session ""` - Add `--agent ` when a plan/build agent is requested. - Add `--model ` when a model override is configured. - Run with `cwd` set to the target workspace path. ## Key CLI references - Start TUI: `kilo` (or `kilo [project]`) -- Non-interactive: `kilo run [message..]` with `--auto` and `--format json` +- Non-interactive: `kilo run [message..]` with `--format json`; `--auto` grants all permissions and should not be added on an unsandboxed developer machine. - Server mode: `kilo serve`, attach with `kilo attach ` - Auth: `kilo auth`, provider setup via `/connect` in the TUI - Models: `kilo models [provider]` @@ -37,7 +38,8 @@ Use this when implementing or debugging the `kilo` provider in Ode, especially C ## Integration notes for Ode - Kilo does not require channel-level model selection in Ode config. - Emit `session.status` and `message.part.updated` events for live status. -- Prefer CLI mode unless event fidelity requires server attach. +- Prefer ACP for session lifecycle, content blocks, and structured tool/plan/message updates. Keep `kilo run --format json` as the compatibility fallback when ACP setup fails. +- ACP `session/request_permission` must be surfaced to the originating user and answered explicitly; never select an allow option automatically. ## Source - https://kilo.ai/docs/code-with-ai/platforms/cli diff --git a/.agents/skills/kimi-cli-skill/SKILL.md b/.agents/skills/kimi-cli-skill/SKILL.md index b0be0f7f..44b7ca95 100644 --- a/.agents/skills/kimi-cli-skill/SKILL.md +++ b/.agents/skills/kimi-cli-skill/SKILL.md @@ -13,12 +13,15 @@ Use this when adding or debugging the `kimi` provider in Ode, especially command Ask clarifying questions if you need model/auth-specific setup beyond CLI invocation. ## Recommended invocation pattern +- Preferred Ode integration: `kimi acp`, using ACP `session/new`, `session/load`, `session/prompt`, `session/update`, and `session/cancel` over stdio. - New session: `kimi --output-format stream-json -p ` from the target working directory. - Resume session: `kimi --output-format stream-json --session -p `. -- Kimi Code 0.13.x does not support the older `--print` or `--work-dir` flags. +- Kimi Code 0.31.x continues to use prompt mode and does not support the older `--print` or `--work-dir` flags. - The CLI stores sessions in `~/.kimi-code/session_index.jsonl`; new session IDs use the `session_...` shape. ## Integration notes for Ode +- Prefer ACP for structured session lifecycle, plan/tool/message updates, and image/resource prompt blocks. Fall back to the JSONL prompt mode only when ACP initialization or session setup fails. +- ACP `session/request_permission` is bidirectional and must pause for the originating user; do not silently select `allow_once` or `allow_always`. - Treat prompt mode as the automation surface; add `--auto`/`--yolo` only when the target CLI version requires it for tool approval. - Parse stdout as JSONL message stream (`assistant` and optional `tool` messages). - Keep provider model input hidden in UI unless explicitly needed. diff --git a/.agents/skills/kiro-cli-skill/SKILL.md b/.agents/skills/kiro-cli-skill/SKILL.md deleted file mode 100644 index 45ed4819..00000000 --- a/.agents/skills/kiro-cli-skill/SKILL.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: kiro-cli-skill -description: Reference guide for integrating and operating Kiro CLI in Ode, focused on non-interactive chat sessions, trust flags, session management, and diagnostics. ---- -## What I do -- Summarize Kiro CLI command surface for agent integrations. -- Recommend automation-safe invocation patterns for `kiro-cli chat`. -- Document session lifecycle (`--resume`, session listing/deletion) and troubleshooting commands. -- Highlight auth, MCP, and logging commands relevant to debugging provider issues. - -## When to use me -Use this when implementing or debugging the `kiro` provider in Ode, especially CLI command construction, session behavior, and non-interactive scripting. - -## Recommended invocation pattern for Ode -- Base command: `kiro-cli chat --no-interactive --trust-all-tools [--resume] [--agent ] ` -- Use `--resume` for follow-up turns in the same project directory. -- Use `--no-interactive` in automation to print the first response to stdout and exit. -- Prefer explicit `kiro-cli` binary; optionally fall back to `kiro` alias if installed. - -## Key CLI references -- Global flags: `--verbose`, `--agent`, `--help`, `--version`, `--help-all`. -- Session management: `kiro-cli chat --resume`, `--resume-picker`, `--list-sessions`, `--delete-session `. -- Auth: `kiro-cli login`, `kiro-cli logout`, `kiro-cli whoami`. -- Health checks: `kiro-cli doctor`, `kiro-cli diagnostic`. -- Config management: `kiro-cli settings list`, `kiro-cli settings `, `kiro-cli settings --delete `. -- MCP tooling: `kiro-cli mcp add|remove|list|import|status`. - -## Integration notes for Ode -- Kiro does not require channel-level model selection in Ode config. -- Keep model picker hidden/disabled when provider is `kiro`. -- Preserve one Ode session per Slack thread, while Kiro conversation resume remains directory-scoped. -- For live status, emit normalized session/text update events even if the CLI output is plain text. - -## Logging and diagnostics -- Kiro logs are in `$XDG_RUNTIME_DIR/kiro-log` (Linux fallback: `/tmp/kiro-log/`). -- Set `KIRO_LOG_LEVEL` to `error|warn|info|debug|trace` for troubleshooting. - -## Source -- https://kiro.dev/docs/cli/reference/cli-commands/ diff --git a/.agents/skills/opencode-developer-researcher/SKILL.md b/.agents/skills/opencode-developer-researcher/SKILL.md index 8191ab18..b5660f0c 100644 --- a/.agents/skills/opencode-developer-researcher/SKILL.md +++ b/.agents/skills/opencode-developer-researcher/SKILL.md @@ -6,6 +6,10 @@ description: Research OpenCode server and SDK documentation for debugging or int - Review OpenCode server docs for connection, configuration, and debugging guidance. - Read SDK docs for integration patterns, APIs, and workflow updates. - Summarize relevant findings with links and practical troubleshooting steps. +- For Ode attachment input, map local images/resources/files to OpenCode SDK `FilePart` values with a `file://` URL, MIME type, and filename; keep text as `TextPart`. +- Consume `/global/event` as a mixed transport: ordinary events expose `payload.type` and `payload.properties`, while synchronized child-session updates can arrive as `payload.type = "sync"` with the real event nested in `payload.syncEvent.type` and `payload.syncEvent.data`. +- Treat sessions created with `parentID` as part of the root run. Task/subagent tool metadata can identify the child `sessionId`; normalize those events to the root run while preserving the source child session and title for status rendering. +- Use `/session/status` together with meaningful-event timestamps for idle detection. Do not infer completion from an empty status map alone, and do not time out while a question or permission interaction is pending. ## When to use me Use this when you need to diagnose issues communicating with OpenCode servers or implement SDK features. diff --git a/.agents/skills/qwen-code-skill/SKILL.md b/.agents/skills/qwen-code-skill/SKILL.md index 68754cef..d012e166 100644 --- a/.agents/skills/qwen-code-skill/SKILL.md +++ b/.agents/skills/qwen-code-skill/SKILL.md @@ -12,13 +12,18 @@ description: Reference guide for integrating and operating Qwen Code CLI in Ode, Use this when adding or debugging Ode's `qwen` provider, especially command construction, parsing streamed JSON events, and live status compatibility. ## Recommended invocation pattern -- Base command: `qwen --output-format stream-json --include-partial-messages --yolo -p ` +- Base command: `qwen --output-format stream-json --include-partial-messages --approval-mode auto --max-wall-time 10m --max-tool-calls 100 -p ` - Resume existing context: append `--resume ` (or `--continue` for latest project session) - Text-only one-shot output: omit `--output-format` and use default text mode ## Integration notes for Ode +- The locally validated Qwen Code 0.21.3 command surface does not expose an ACP entry point, so Ode must keep using `stream-json` for this version instead of advertising ACP support. - Qwen headless supports `text`, `json`, and `stream-json`; use `stream-json` for live status updates. - `--include-partial-messages` emits incremental events (for example `content_block_delta`) that map well to status rendering. +- Qwen Code 0.21.x still accepts `--include-partial-messages` and `--approval-mode` even when a bare `qwen --help` omits them from its abbreviated option list. +- Prefer `--approval-mode auto` for local headless automation: current Qwen uses a fail-closed classifier for risky operations while allowing low-risk work to proceed. Do not use `yolo` on an unsandboxed developer machine. +- Bound headless runs with `--max-wall-time` and `--max-tool-calls`; Qwen 0.21.x emits distinct structured budget failures. +- Use `--approval-mode plan` for read-only planning. - Session history is project-scoped under `~/.qwen/projects//chats`; restoring a session recovers history and tool context. - Keep channel model selection disabled for Qwen in Ode UI; provider logic does not require per-channel model overrides. diff --git a/AGENTS.md b/AGENTS.md index d11e7c5f..a516837f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ ODE is a project that connects many AI coding agents with IM message apps. When - Config: `packages/config/` (Zod env/config validation, local `ode.json`, channel settings) - Core orchestration: `packages/core/` (daemon, kernel, runtime, tasks, cron, Web/API server) - IM adapters: `packages/ims/` (`slack`, `discord`, `lark`, shared inbound/delivery/runtime helpers) -- Agent adapters: `packages/agents/` (`opencode`, `claude`, `codex`, `kimi`, `kiro`, `kilo`, `qwen`, `goose`, `gemini`, `pi`, `openhands`, `codebuddy`, `crush`) +- Agent adapters: `packages/agents/` (`opencode`, `claude`, `codex`, `kimi`, `kilo`, `qwen`, `goose`, `pi`, `openhands`, `codebuddy`, `crush`) - Shared utilities: `packages/shared/` and `packages/utils/` - Web UI: `packages/web-ui/` (settings, sessions, local config views) - Live status harness: `packages/live-status-harness/` @@ -20,6 +20,7 @@ ODE is a project that connects many AI coding agents with IM message apps. When - Sessions live under `~/.config/ode/sessions/`. - Channel details include agent provider, model when supported, working directory, base branch, and system message. - Bot replies and status updates should stay in the originating IM thread. +- Runtime status and final replies use ordinary Markdown text messages on Slack, Discord, and Lark; do not reintroduce Slack AI Card/streaming message formatting. - Status updates include phases, tool progress, elapsed time, and are preserved as an operation record. - Slack workspaces default to AI card status messages; use the workspace Status Messages setting to switch a Slack workspace back to legacy message updates. - SDK/CLI event loops handle permission or question flows where supported; OpenCode and Claude question replies are wired through the adapter. @@ -29,9 +30,11 @@ ODE is a project that connects many AI coding agents with IM message apps. When ## Supported Integrations - IM apps: Slack, Discord, Lark/Feishu. -- Agent providers: `opencode`, `claude`/`claudecode`, `codex`, `kimi`, `kiro`, `kilo`, `qwen`, `goose`, `gemini`, `pi`, `openhands`, `codebuddy`, `crush`. +- Agent providers: `opencode`, `claude`/`claudecode`, `codex`, `kimi`, `kilo`, `qwen`, `goose`, `pi`, `openhands`, `codebuddy`, `crush`. - Model selection is provider-specific. OpenCode, Codex, Kilo, Pi, OpenHands, CodeBuddy, and Crush expose configured model lists in the Web UI. - Coding agent credentials/configuration belong to each agent's own CLI/config files; Ode should call the CLI and should not become the secret/config owner for those tools. +- Structured transports are preferred where locally supported: OpenCode SDK, Claude Agent SDK streaming input, Codex App Server, and ACP for Kimi/Kilo/Goose. Keep a CLI fallback when protocol startup is unavailable. +- Inbound images/files are downloaded by the IM adapter into Ode's private attachment store and passed as `AgentInputPart`; never pass expiring IM URLs or IM authorization headers to agent providers. ## Commands - Install deps: `bun run setup` @@ -98,14 +101,14 @@ ODE is a project that connects many AI coding agents with IM message apps. When - Prefer `Bun.file` over `node:fs` for new file IO where practical, while respecting existing local style. ## Skills -- Available repo skills include `agent-browser`, `slack-developer-researcher`, `opencode-developer-researcher`, `codex-cli-reference`, `qwen-code-skill`, `goose-cli-skill`, `kimi-cli-skill`, `kiro-cli-skill`, and `kilo-cli-skill`. +- Available repo skills include `agent-browser`, `slack-developer-researcher`, `opencode-developer-researcher`, `codex-cli-reference`, `qwen-code-skill`, `goose-cli-skill`, `kimi-cli-skill`, and `kilo-cli-skill`. - Use `agent-browser` for browser automation tasks. - Use the matching CLI skill when changing or debugging an agent provider integration. - If you discover new Slack/OpenCode/CLI-agent updates during development, update the matching skill doc under `.agents/skills/` (mirrored via `.claude/skills/` when present). ## Agent Live Status Workflow - Use `packages/live-status-harness/fixed-prompt.md` as the baseline stream-capture prompt. -- Capture stream events with `bun run live-status:capture --provider `. +- Capture stream events with `bun run live-status:capture --provider `. - Store raw ordered events in Redis under the harness keyspace (`harness:live_status:*`). - Render status outputs from captured events with `bun run live-status:render --run-id `. - Generate combined reports with `bun run live-status:report`. diff --git a/README.md b/README.md index acd396fb..9f707f5d 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,11 @@ Ode is a agent tool that bridges your coding agents (OpenCode, Claude Code, Code * 🖇️ **Map coding sessions 1 - 1 to chat threads**, and use worktree to get isolated, parallel coding is so easy. * 👬 Anyone in the channel can join coding without any extra setup, **pay one account for all team members**. * 📝 **Message live message updates**, you don't wait for response without any information, you can monitor from real-time text updates. +* 📎 **Image and file input**, attach screenshots, documents, or source files in Slack, Discord, or Lark and Ode forwards structured content to the coding agent. * 🐙 **Per user git config**, who start the thread becomes corresponding git commit author. (Run @bot /setting) +Ode prefers each agent's structured integration surface: Codex App Server, Claude Agent SDK streaming input, OpenCode SDK, and ACP for Kimi, Kilo, and Goose. Other agents continue to use their supported streaming CLI format. Agent credentials remain owned by the local CLI; Ode does not store API keys. + ## Compare with OpenClaw * OpenClaw is greate, but Ode utilize **thread based** messaging to organize things better, making it easy to port sessions in coding agents directly to chat apps. Just work on one thing in one thread. @@ -56,11 +59,9 @@ Settings UI can be accessible via http://127.0.0.1:9293 or use `/setting` comman | CodeBuddy | CodeBuddy logo | [codebuddy.ai/docs/cli](https://www.codebuddy.ai/docs/cli/overview) | | Codex | Codex logo | [github.com/openai/codex](https://github.com/openai/codex) | | Crush | Crush logo | [github.com/charmbracelet/crush](https://github.com/charmbracelet/crush) | -| Gemini CLI | Gemini CLI logo | [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli) | | Goose CLI | Goose CLI logo | [block.github.io/goose](https://block.github.io/goose/) | | Kimi Code | Kimi Code logo | [moonshotai.github.io/kimi-cli](https://moonshotai.github.io/kimi-cli/) | | Kilo Code | Kilo Code logo | [kilo.ai/docs/code-with-ai/platforms/cli](https://kilo.ai/docs/code-with-ai/platforms/cli) | -| Kiro CLI | Kiro CLI logo | [kiro.dev/docs/cli/reference](https://kiro.dev/docs/cli/reference/cli-commands/) | | OpenCode | OpenCode logo | [opencode.ai](https://opencode.ai/) | | OpenHands | OpenHands logo | [docs.openhands.dev](https://docs.openhands.dev/) | | Pi | Pi logo | [github.com/earendil-works/pi](https://github.com/earendil-works/pi) | @@ -78,8 +79,8 @@ Settings UI can be accessible via http://127.0.0.1:9293 or use `/setting` comman 1. Invite the bot to a channel. 2. Run `@bot /setting`, select channel setting, choose your coding cli (opencode also can choose model) and working directory. -3. @ your bot with the prompt you want. -3. The bot will process your message with the coding agent. +3. @ your bot with the prompt you want, optionally with image or file attachments. +4. The bot will process your message with the coding agent. ## Worktrees diff --git a/README.zh-CN.md b/README.zh-CN.md index c932dbd2..1f72f000 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -12,8 +12,11 @@ Ode 是一个编程代理工具,可将你的编码Agent(OpenCode、Claude Co * 🖇️ **将编码会话与 Slack 线程 1:1 映射**,并结合 worktree 实现隔离开发,轻松并行协作。 * 👬 频道内任何人都可以直接参与编码,无需额外配置,**一个账号可供团队成员共享使用**。 * 📝 **消息实时更新**,不再盲等回复,你可以通过实时文本更新持续跟踪进度。 +* 📎 **图片和文件输入**,可直接在 Slack、Discord 或飞书消息中附加截图、文档和源码文件,并以结构化输入传给编码代理。 * 🐙 **按用户设置git信息**,由谁发起线程,就以谁作为对应提交作者。 (Run @bot /gh) +Ode 会优先使用各代理的结构化接口:Codex App Server、Claude Agent SDK Streaming Input、OpenCode SDK,以及 Kimi、Kilo、Goose 的 ACP。其他代理继续使用各自支持的流式 CLI 格式。编码代理凭据仍由本机 CLI 自己管理,Ode 不保存 API Key。 + ## 和OpenClaw的比较 * Ode专注于基于**线程**的消息列表,更适合编程或者需要管理不同任务的工作。一个线程只聚焦一件事。 @@ -54,11 +57,9 @@ ode | CodeBuddy | CodeBuddy logo | [codebuddy.ai/docs/cli](https://www.codebuddy.ai/docs/cli/overview) | | Codex | Codex logo | [github.com/openai/codex](https://github.com/openai/codex) | | Crush | Crush logo | [github.com/charmbracelet/crush](https://github.com/charmbracelet/crush) | -| Gemini CLI | Gemini CLI logo | [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli) | | Goose CLI | Goose CLI logo | [block.github.io/goose](https://block.github.io/goose/) | | Kimi Code | Kimi Code logo | [moonshotai.github.io/kimi-cli](https://moonshotai.github.io/kimi-cli/) | | Kilo Code | Kilo Code logo | [kilo.ai/docs/code-with-ai/platforms/cli](https://kilo.ai/docs/code-with-ai/platforms/cli) | -| Kiro CLI | Kiro CLI logo | [kiro.dev/docs/cli/reference](https://kiro.dev/docs/cli/reference/cli-commands/) | | OpenCode | OpenCode logo | [opencode.ai](https://opencode.ai/) | | OpenHands | OpenHands logo | [docs.openhands.dev](https://docs.openhands.dev/) | | Pi | Pi logo | [github.com/earendil-works/pi](https://github.com/earendil-works/pi) | @@ -76,7 +77,7 @@ ode 1. 邀请机器人进入一个频道。 2. 执行 `@bot /setting`,选择频道设置,选择你的编码 CLI(OpenCode 也可选择模型)以及工作目录。 -3. 使用 `@bot` 并附上你的提示词。 +3. 使用 `@bot` 并附上你的提示词,也可以同时添加图片或文件附件。 4. 机器人会调用编码代理处理你的消息。 ## Worktree diff --git a/bun.lock b/bun.lock index 7abd9f8c..f1ef5b3d 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,10 @@ "": { "name": "ode", "dependencies": { + "@agentclientprotocol/sdk": "^1.3.0", + "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@larksuiteoapi/node-sdk": "^1.71.1", - "@opencode-ai/sdk": "^1.18.2", + "@opencode-ai/sdk": "^1.18.11", "@sentry/bun": "^10.65.0", "@slack/bolt": "^4.7.3", "@slack/socket-mode": "^2.0.7", @@ -14,6 +16,7 @@ "bottleneck": "^2.19.5", "discord.js": "^14.27.0", "elysia": "^1.4.29", + "file-type": "22.0.1", "ioredis": "^5.11.1", "lru-cache": "^11.5.2", "pino": "^10.3.1", @@ -39,12 +42,36 @@ "ws": "8.21.1", }, "packages": { + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.3.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-i3h/efaeuMUFAO1HSfo97QZQnnvMd7wWBYtBsdL6UMZg3a78sk3Ffya5Xu7C7tYsXomXoDXJBAzQF2PcFKAhIQ=="], + + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.220", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA=="], + + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.220", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q=="], + + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.220", "", { "os": "darwin", "cpu": "x64" }, "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.220", "", { "os": "linux", "cpu": "x64" }, "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.220", "", { "os": "linux", "cpu": "x64" }, "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.220", "", { "os": "win32", "cpu": "arm64" }, "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.220", "", { "os": "win32", "cpu": "x64" }, "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.115.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ=="], + "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.15.0", "", { "dependencies": { "@types/estree": "^1.0.8", "astring": "^1.9.0", "esquery": "^1.7.0", "meriyah": "^6.1.4", "semifies": "^1.0.0", "source-map": "^0.6.0" }, "bin": { "code-transformer": "cli.js" } }, "sha512-XmXYVs8CzJ1Aj79noVbn2weUO/XWtRyURpGqx7aU7DOXlUQhR0WKOQNF0okh7PCeY37vxf7kU3v57OAkEPm3ww=="], "@apm-js-collab/code-transformer-bundler-plugins": ["@apm-js-collab/code-transformer-bundler-plugins@0.5.0", "", { "dependencies": { "@apm-js-collab/code-transformer": "^0.15.0", "es-module-lexer": "^2.1.0", "magic-string": "^0.30.21", "module-details-from-path": "^1.0.4" } }, "sha512-YxLBY5nGlurL7QeJLq6e5g0ouBpAp0pwgyA/5rHXEXwhiPLn9ZHbT+Y2LlP90GT872cSocfjWRYu/fnpuBudNQ=="], "@apm-js-collab/tracing-hooks": ["@apm-js-collab/tracing-hooks@0.10.1", "", { "dependencies": { "@apm-js-collab/code-transformer": "^0.15.0", "debug": "^4.4.1", "module-details-from-path": "^1.0.4" } }, "sha512-w2OWXR7FWrKqSziuE9+QclaZrStxO/8+OwbXM635s/zs0Eez1Qo3ivSPdB2WsaPY/iznKTytONPx/PitD7IXcA=="], + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + "@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="], "@discordjs/builders": ["@discordjs/builders@1.14.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ=="], @@ -59,13 +86,17 @@ "@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="], + "@hono/node-server": ["@hono/node-server@2.0.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg=="], + "@ioredis/commands": ["@ioredis/commands@1.10.0", "", {}, "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@larksuiteoapi/node-sdk": ["@larksuiteoapi/node-sdk@1.71.1", "", { "dependencies": { "axios": "^1.16.0", "lodash.identity": "^3.0.0", "lodash.merge": "^4.6.2", "lodash.pickby": "^4.6.0", "protobufjs": "^7.2.6", "qs": "^6.14.2", "ws": "^8.19.0" } }, "sha512-Z4cZmgWvwiE7tCHqGm+t7DKvbpNRRTH2HqVwcYiOMAwbjIytXSoQqWytsOVu3p+d0fIvrtyIPZok5HOV/VNxxw=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-40DIMMrl2W0TMFKtTnrYKed3ElXhqEkP0oLahQEd6Mm9mTdu/B2Bc9A19++IkKKFIxbxKFaUhik+vBUiNBrFtA=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.11", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-yDImmNv4PhxdMgtiHVNWQWEVwQlAm7Dr0y4XU7CT4dOIbzgO+VP+9I02lAP7Zva1FhGeyI7oKMI2tzB9RUsWaQ=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], @@ -119,6 +150,8 @@ "@slack/web-api": ["@slack/web-api@7.19.0", "", { "dependencies": { "@slack/logger": "^4.0.1", "@slack/types": "^2.21.0", "@types/node": ">=18", "@types/retry": "0.12.0", "axios": "^1.16.0", "eventemitter3": "^5.0.1", "form-data": "^4.0.4", "is-electron": "2.2.2", "is-stream": "^2", "p-queue": "^6", "p-retry": "^4", "retry": "^0.13.1" } }, "sha512-ItjyjEZml+LDH8CjcCLRLJHh7VZtevPKExrRN3l5KWyBliyDnGAeoO4Y+K+fFBmRpKLYVPgqWMX4THldv2HVtA=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], @@ -165,6 +198,10 @@ "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], @@ -203,6 +240,8 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], @@ -251,10 +290,16 @@ "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + "express-rate-limit": ["express-rate-limit@8.6.1", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA=="], + "fast-copy": ["fast-copy@4.0.2", "", {}, "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw=="], "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], @@ -263,6 +308,10 @@ "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + + "fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="], + "file-type": ["file-type@22.0.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], @@ -291,6 +340,8 @@ "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], + "hono": ["hono@4.12.33", "", {}, "sha512-+SwvkaiJtxsiPjhy9LivY/1m7UsNqCJetM1BrZl9A5DkQhlbHQDU730mMiDPWjnoCYOM8Chf3WrCJw27kNTPFQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], @@ -305,6 +356,8 @@ "ioredis": ["ioredis@5.11.1", "", { "dependencies": { "@ioredis/commands": "1.10.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "redis-parser": "3.0.0", "standard-as-callback": "2.1.0" } }, "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A=="], + "ip-address": ["ip-address@10.4.0", "", {}, "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ=="], + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "is-electron": ["is-electron@2.2.2", "", {}, "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg=="], @@ -315,8 +368,16 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jose": ["jose@6.2.7", "", {}, "sha512-hq1OB1bALKfydZNoViyg6hPVGV4i93ny9Op+n4zP5RSf7SCZEXa/TsG2O3IEr7+WlHRTPnpqDmHfMH6qXAD60w=="], + "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], @@ -377,6 +438,8 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], @@ -409,6 +472,8 @@ "pino-std-serializers": ["pino-std-serializers@7.0.0", "", {}, "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], "protobufjs": ["protobufjs@8.7.1", "", { "dependencies": { "long": "^5.3.2" } }, "sha512-agdGHrXNTv0IrYscJPDou/PlEJk1c/hBZ9o/B5NH2i/nSPtPqacNxzgwf1CebXxFMjMrZH5sqv9uQuw96aGt/A=="], @@ -433,6 +498,8 @@ "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], @@ -477,6 +544,8 @@ "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], + "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], @@ -489,6 +558,8 @@ "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -517,6 +588,8 @@ "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@discordjs/formatters/discord-api-types": ["discord-api-types@0.38.39", "", {}, "sha512-XRdDQvZvID1XvcFftjSmd4dcmMi/RL/jSy5sduBDAvCGFcNFHThdIQXCEBDZFe52lCNEzuIL0QJoKYAmRmxLUA=="], "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], diff --git a/package.json b/package.json index a0325841..524db977 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ode", - "version": "0.1.50", + "version": "0.2.0", "description": "Coding anywhere with your coding agents connected", "module": "packages/core/index.ts", "type": "module", @@ -29,8 +29,10 @@ "typescript": "^5.9.3" }, "dependencies": { + "@agentclientprotocol/sdk": "^1.3.0", + "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@larksuiteoapi/node-sdk": "^1.71.1", - "@opencode-ai/sdk": "^1.18.2", + "@opencode-ai/sdk": "^1.18.11", "@sentry/bun": "^10.65.0", "@slack/bolt": "^4.7.3", "@slack/socket-mode": "^2.0.7", @@ -38,6 +40,7 @@ "bottleneck": "^2.19.5", "discord.js": "^14.27.0", "elysia": "^1.4.29", + "file-type": "22.0.1", "ioredis": "^5.11.1", "lru-cache": "^11.5.2", "pino": "^10.3.1", diff --git a/packages/agents/adapter.ts b/packages/agents/adapter.ts index cfd20639..ecf0fb17 100644 --- a/packages/agents/adapter.ts +++ b/packages/agents/adapter.ts @@ -3,11 +3,14 @@ import { getChannelAgentProvider } from "@/config"; import type { QuestionInfo } from "@opencode-ai/sdk/v2"; import { getAgentProviderLabel } from "@/shared/agent-provider"; import { getAgentProvider, type AgentProviderId } from "./registry"; -import { getSessionClient } from "./opencode"; +import { getSessionClient, replyToOpenCodePermission } from "./opencode"; import { replyToQuestion as replyToClaudeQuestion } from "./claude"; +import { replyToQuestion as replyToCodexQuestion } from "./codex"; +import { replyToAcpQuestion } from "./runtime/acp-client"; import { buildStatusMessageByProvider, } from "@/utils/status"; +import { getAgentCapabilities, getAgentTransport } from "./capabilities"; /** * Session → provider index. Intentionally unbounded: this map is used by @@ -62,6 +65,12 @@ export function createAgentAdapter(options: AgentAdapterOptions = {}): AgentAdap const providerId = getProviderForSession(sessionId); return getAgentProviderLabel(providerId); }, + getTransportForSession(sessionId) { + return getAgentTransport(getProviderForSession(sessionId)); + }, + getCapabilitiesForSession(sessionId) { + return getAgentCapabilities(getProviderForSession(sessionId)); + }, async getOrCreateSession(channelId, threadId, cwd, env) { const providerId = resolveProviderForChannel(channelId); const provider = getAgentProvider(providerId); @@ -101,9 +110,20 @@ export function createAgentAdapter(options: AgentAdapterOptions = {}): AgentAdap await replyToClaudeQuestion({ sessionId, requestId, answers }); return; } + if (providerId === "codex") { + await replyToCodexQuestion({ requestId, answers }); + return; + } + if (providerId === "kimi" || providerId === "kilo" || providerId === "goose" || providerId === "codebuddy") { + await replyToAcpQuestion({ providerId, sessionId, requestId, answers }); + return; + } if (providerId !== "opencode") { throw new Error(`Question replies are not supported for agent: ${providerId}`); } + if (await replyToOpenCodePermission({ sessionId, requestId, answers })) { + return; + } const client = await getSessionClient(sessionId); const response = await client.question.reply({ requestID: requestId, diff --git a/packages/agents/capabilities.ts b/packages/agents/capabilities.ts new file mode 100644 index 00000000..48a08cd0 --- /dev/null +++ b/packages/agents/capabilities.ts @@ -0,0 +1,87 @@ +import type { AgentProviderId } from "@/shared/agent-provider"; +import { + LEGACY_AGENT_CAPABILITIES, + type AgentCapabilities, + type AgentTransport, +} from "@/shared/agent-protocol"; + +const fullSessions = { + create: true, + resume: true, + load: true, + list: true, + delete: true, + close: true, + fork: true, +}; + +const structuredEvents = { + message: true, + reasoningSummary: true, + plan: true, + tool: true, + command: true, + fileDiff: true, + usage: true, +}; + +const nativeCapabilities: AgentCapabilities = { + sessions: fullSessions, + input: { text: true, image: true, resource: true, fileRef: true }, + events: structuredEvents, + interaction: { approval: true, question: true, cancel: true }, +}; + +const acpCapabilities: AgentCapabilities = { + // ACP guarantees new/prompt/cancel/update. All other lifecycle methods are + // negotiated per connection and are therefore conservative here; the + // binding is refreshed with the actual handshake after the first turn. + sessions: { + create: true, + resume: false, + load: false, + list: false, + delete: false, + close: false, + fork: false, + }, + input: { text: true, image: true, resource: true, fileRef: true }, + events: structuredEvents, + interaction: { approval: true, question: false, cancel: true }, +}; + +export const AGENT_TRANSPORTS: Record = { + opencode: "server-sdk", + claudecode: "native-sdk", + codex: "native-app-server", + kimi: "acp", + kilo: "acp", + qwen: "cli-json", + goose: "acp", + pi: "cli-json", + openhands: "cli-json", + codebuddy: "acp", + crush: "cli-json", +}; + +export const AGENT_CAPABILITIES: Record = { + opencode: nativeCapabilities, + claudecode: nativeCapabilities, + codex: nativeCapabilities, + kimi: acpCapabilities, + kilo: acpCapabilities, + qwen: LEGACY_AGENT_CAPABILITIES, + goose: acpCapabilities, + pi: LEGACY_AGENT_CAPABILITIES, + openhands: LEGACY_AGENT_CAPABILITIES, + codebuddy: acpCapabilities, + crush: LEGACY_AGENT_CAPABILITIES, +}; + +export function getAgentTransport(providerId: AgentProviderId): AgentTransport { + return AGENT_TRANSPORTS[providerId]; +} + +export function getAgentCapabilities(providerId: AgentProviderId): AgentCapabilities { + return AGENT_CAPABILITIES[providerId]; +} diff --git a/packages/agents/claude/client.ts b/packages/agents/claude/client.ts index 05b1f19a..7d096fa7 100644 --- a/packages/agents/claude/client.ts +++ b/packages/agents/claude/client.ts @@ -10,7 +10,17 @@ import { type SessionEnvironment as RuntimeSessionEnvironment, } from "../runtime/base"; import { createCliThreadSessionManager } from "../runtime/cli-session"; +import { + query as queryClaude, + type CanUseTool, + type PermissionResult, + type Query, + type SDKMessage, + type SDKUserMessage, + type Settings, +} from "@anthropic-ai/claude-agent-sdk"; import type { + AgentInput, OpenCodeMessage, OpenCodeMessageContext, OpenCodeOptions, @@ -42,6 +52,7 @@ type PendingClaudeQuestion = { deferred: Deferred<{ status: "answered"; answers: string[] } | { status: "cancelled"; reason?: string }>; }; const pendingQuestions = new Map(); +const activeSdkQueries = new Map(); /** * FIFO-bounded cache of session ids that have not yet completed their first * turn. Evicting the oldest entry on overflow is safe: the flag only gates @@ -50,10 +61,12 @@ const pendingQuestions = new Map(); */ const NEW_SESSIONS_MAX_ENTRIES = 1000; const newSessions = new BoundedSet(NEW_SESSIONS_MAX_ENTRIES); +const unknownClaudeProtocolLabels = new BoundedSet(200); const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; type ClaudeJsonRecord = { type?: string; + subtype?: string; event?: { type?: string; index?: number; @@ -70,6 +83,64 @@ type ClaudeJsonRecord = { permission_denials?: Array<{ tool_name?: string }>; }; +const KNOWN_CLAUDE_RECORD_TYPES = new Set([ + "assistant", + "user", + "result", + "stream_event", + "system", + "auth_status", + "conversation_reset", + "prompt_suggestion", + "rate_limit_event", + "tool_progress", + "tool_use_summary", + "control_response", + "keep_alive", + "local", +]); + +const KNOWN_CLAUDE_SYSTEM_SUBTYPES = new Set([ + "init", + "api_retry", + "background_tasks_changed", + "commands_changed", + "compact_boundary", + "control_request_progress", + "elicitation_complete", + "files_persisted", + "hook_progress", + "hook_response", + "hook_started", + "informational", + "local_command_output", + "memory_recall", + "mirror_error", + "model_refusal_fallback", + "model_refusal_no_fallback", + "notification", + "permission_denied", + "plugin_install", + "session_state_changed", + "status", + "task_notification", + "task_progress", + "task_started", + "task_updated", + "thinking_tokens", + "worker_shutting_down", +]); + +export function getUnknownClaudeProtocolLabel(record: ClaudeJsonRecord): string | undefined { + const type = typeof record.type === "string" && record.type.trim() ? record.type.trim() : "unknown"; + if (!KNOWN_CLAUDE_RECORD_TYPES.has(type)) return `type:${type}`; + if (type !== "system") return undefined; + const subtype = typeof record.subtype === "string" && record.subtype.trim() + ? record.subtype.trim() + : "unknown"; + return KNOWN_CLAUDE_SYSTEM_SUBTYPES.has(subtype) ? undefined : `system:${subtype}`; +} + function deriveSessionTitleFromPrompt(message: string): string | undefined { const normalized = message.replace(/\s+/g, " ").trim(); if (!normalized) return undefined; @@ -241,6 +312,11 @@ function publishClaudeRecordAsSessionEvents( const rawType = typeof record.type === "string" && record.type.trim() ? record.type.trim() : "unknown"; + const unknownProtocolLabel = getUnknownClaudeProtocolLabel(record); + if (unknownProtocolLabel && !unknownClaudeProtocolLabels.has(unknownProtocolLabel)) { + unknownClaudeProtocolLabels.add(unknownProtocolLabel); + log.warn("Unknown Claude Agent SDK message", { label: unknownProtocolLabel }); + } runtime.publishSessionEvent(subscriptionSessionId, { type: `claude.raw.${rawType}`, properties: { @@ -248,6 +324,7 @@ function publishClaudeRecordAsSessionEvents( recordType: rawType, recordSessionId, streamEventType: typeof record.event?.type === "string" ? record.event.type : undefined, + protocolKnown: !unknownProtocolLabel, }, }); } @@ -522,10 +599,10 @@ async function runClaudeWithFallback( throw lastError ?? new Error("Claude CLI failed"); } -export async function sendMessage( +async function sendMessageViaCli( channelId: string, sessionId: string, - message: string, + input: AgentInput, workingPath: string, options?: OpenCodeOptions, context?: OpenCodeMessageContext @@ -552,7 +629,7 @@ export async function sendMessage( const agent = options?.agent; const forcedPermissionMode = resolveClaudePermissionMode(agent); - const parts = buildPromptParts(channelId, message, { ...options, agent }, context); + const parts = buildPromptParts(channelId, input, { ...options, agent }, context); const initialPrompt = buildPromptText(parts); const systemPrompt = buildSystemPrompt(context?.slack); @@ -564,7 +641,7 @@ export async function sendMessage( let prompt = initialPrompt; if (isNewSession) { - const fallbackTitle = deriveSessionTitleFromPrompt(message); + const fallbackTitle = deriveSessionTitleFromPrompt(initialPrompt); if (fallbackTitle) { runtime.publishSessionEvent(subscriptionSessionId, { type: "session.updated", @@ -738,6 +815,224 @@ export async function sendMessage( } } +export const CLAUDE_SDK_PERMISSION_TOOLS = [ + "Bash", "Glob", "Grep", "Read", "Edit", "Write", "WebFetch", "Task", + "TodoWrite", "NotebookEdit", "TaskOutput", "TaskStop", "ToolSearch", "Skill", + "AskUserQuestion", +]; + +// Keep the SDK's auto-allow layer empty. Every tool is routed through +// canUseTool, where Ode can distinguish immediate allowlist decisions from +// blocking user questions and can fail closed for newly introduced tools. +export const CLAUDE_SDK_ALLOWED_TOOLS: string[] = []; + +/** + * Force every Ode-supported tool through the SDK permission callback even + * when a user's Claude settings happen to allow it. Flag settings have the + * highest user-controlled precedence, so these `ask` rules remove the + * auto-allow path that would otherwise bypass `canUseTool`. + */ +export const CLAUDE_SDK_SETTINGS = { + permissions: { ask: CLAUDE_SDK_PERMISSION_TOOLS }, +} satisfies Settings; + +async function buildClaudeSdkUserMessage( + sessionId: string, + parts: ReturnType +): Promise { + const content: Array> = []; + const text = buildPromptText(parts); + if (text) content.push({ type: "text", text }); + for (const part of parts) { + if (part.type !== "image") continue; + const mime = part.mimeType.toLowerCase(); + if (!["image/jpeg", "image/png", "image/gif", "image/webp"].includes(mime)) continue; + const data = Buffer.from(await Bun.file(part.path).arrayBuffer()).toString("base64"); + content.push({ + type: "image", + source: { type: "base64", media_type: mime, data }, + }); + } + return { + type: "user", + message: { role: "user", content } as any, + parent_tool_use_id: null, + session_id: sessionId, + }; +} + +async function handleSdkAskUserQuestion( + sessionId: string, + input: Record, + requestId: string, + signal?: AbortSignal +): Promise> { + const questions = Array.isArray(input.questions) + ? input.questions.filter((question): question is Record => Boolean(question) && typeof question === "object") + : []; + if (questions.length === 0) return input; + const deferred = createDeferred< + { status: "answered"; answers: string[] } | { status: "cancelled"; reason?: string } + >(); + const abort = () => deferred.resolve({ status: "cancelled", reason: "Claude question aborted" }); + signal?.addEventListener("abort", abort, { once: true }); + pendingQuestions.set(sessionId, { requestId, questionCount: questions.length, deferred }); + runtime.publishSessionEvent(sessionId, { + type: "question.asked", + properties: { id: requestId, sessionID: sessionId, questions }, + }); + let outcome: { status: "answered"; answers: string[] } | { status: "cancelled"; reason?: string }; + try { + outcome = await deferred.promise; + } finally { + signal?.removeEventListener("abort", abort); + const pending = pendingQuestions.get(sessionId); + if (pending?.requestId === requestId) pendingQuestions.delete(sessionId); + } + if (outcome.status === "cancelled") throw new Error(outcome.reason ?? "Claude question cancelled"); + const answerMap: Record = {}; + questions.forEach((question, index) => { + const key = typeof question.question === "string" ? question.question : String(index); + answerMap[key] = outcome.answers[index] ?? ""; + }); + return { ...input, answers: answerMap }; +} + +export function createClaudeSdkCanUseTool(sessionId: string): CanUseTool { + return async (toolName, toolInput, control): Promise => { + if (toolName !== "AskUserQuestion" && CLAUDE_SDK_PERMISSION_TOOLS.includes(toolName)) { + return { behavior: "allow", updatedInput: toolInput }; + } + if (toolName !== "AskUserQuestion") { + return { + behavior: "deny", + message: `Ode has not approved the Claude tool ${toolName}.`, + }; + } + const questions = Array.isArray(toolInput.questions) + ? toolInput.questions.filter((question) => Boolean(question) && typeof question === "object") + : []; + if (questions.length === 0) { + return { + behavior: "deny", + message: "AskUserQuestion contained no valid questions.", + }; + } + const updatedInput = await handleSdkAskUserQuestion( + sessionId, + toolInput, + control.requestId || control.toolUseID, + control.signal + ); + return { + behavior: "allow", + updatedInput, + decisionClassification: "user_temporary", + }; + }; +} + +async function* singleClaudeMessage(message: SDKUserMessage): AsyncGenerator { + yield message; +} + +async function sendMessageViaSdk( + channelId: string, + sessionId: string, + input: AgentInput, + workingPath: string, + options?: OpenCodeOptions, + context?: OpenCodeMessageContext +): Promise { + const sessionKey = `${channelId}:${sessionId}`; + runtime.beginRequest(sessionKey); + try { + return await runtime.withSessionLock(sessionKey, async () => { + const agent = options?.agent; + const planMode = agent?.trim().toLowerCase() === "plan"; + const parts = buildPromptParts(channelId, input, { ...options, agent }, context); + const userMessage = await buildClaudeSdkUserMessage(sessionId, parts); + const isNewSession = newSessions.has(sessionId); + const env = { ...process.env, ...runtime.getSessionEnvironment(sessionId), PWD: workingPath }; + const systemPrompt = buildSystemPrompt(context?.slack); + const sdkQuery = queryClaude({ + prompt: singleClaudeMessage(userMessage), + options: { + cwd: workingPath, + env, + ...(isNewSession ? { sessionId } : { resume: sessionId }), + includePartialMessages: true, + includeHookEvents: true, + forwardSubagentText: true, + agentProgressSummaries: true, + settingSources: ["user", "project", "local"], + systemPrompt: { + type: "preset", + preset: "claude_code", + ...(systemPrompt ? { append: systemPrompt } : {}), + }, + tools: { type: "preset", preset: "claude_code" }, + allowedTools: CLAUDE_SDK_ALLOWED_TOOLS, + settings: CLAUDE_SDK_SETTINGS, + permissionMode: planMode ? "plan" : "default", + effort: options?.reasoningEffort, + canUseTool: createClaudeSdkCanUseTool(sessionId), + }, + }); + activeSdkQueries.set(sessionId, sdkQuery); + + let resultText = ""; + let observedSessionId = sessionId; + try { + for await (const message of sdkQuery) { + const sdkMessage = message as SDKMessage & { session_id?: string; result?: string; is_error?: boolean; error?: string }; + if (typeof sdkMessage.session_id === "string" && sdkMessage.session_id) { + observedSessionId = sdkMessage.session_id; + } + publishClaudeRecordAsSessionEvents( + sdkMessage as unknown as ClaudeJsonRecord, + sessionId, + observedSessionId + ); + if (sdkMessage.type === "result") { + if (sdkMessage.is_error) { + throw new Error(sdkMessage.error || sdkMessage.result || "Claude Agent SDK returned an error"); + } + if (typeof sdkMessage.result === "string") resultText = sdkMessage.result.trim(); + } + } + } finally { + activeSdkQueries.delete(sessionId); + } + + if (observedSessionId !== sessionId && context?.slack?.threadId) { + runtime.setSessionEnvironment(observedSessionId, runtime.getSessionEnvironment(sessionId)); + setThreadSessionId(channelId, context.slack.threadId, observedSessionId); + } + newSessions.delete(sessionId); + newSessions.delete(observedSessionId); + if (!resultText) throw new Error("Claude Agent SDK returned empty response"); + return [{ text: resultText, messageType: "assistant" }]; + }); + } finally { + runtime.endRequest(sessionKey); + } +} + +export async function sendMessage( + channelId: string, + sessionId: string, + input: AgentInput, + workingPath: string, + options?: OpenCodeOptions, + context?: OpenCodeMessageContext +): Promise { + if (process.env.ODE_CLAUDE_LEGACY_CLI === "1") { + return sendMessageViaCli(channelId, sessionId, input, workingPath, options, context); + } + return sendMessageViaSdk(channelId, sessionId, input, workingPath, options, context); +} + /** * Resolve a pending AskUserQuestion request with the user's answers. The * adapter routes Claude question replies here when the kernel collects all @@ -798,11 +1093,19 @@ export const subscribeToSession = runtime.subscribeToSession.bind(runtime); export async function abortSession(sessionId: string, _directory?: string): Promise { cancelPendingQuestion(sessionId, "Claude session aborted"); + await activeSdkQueries.get(sessionId)?.interrupt().catch(() => undefined); + activeSdkQueries.get(sessionId)?.close(); + activeSdkQueries.delete(sessionId); await runtime.abortSession(sessionId); } export async function cancelActiveRequest(channelId: string, sessionId: string): Promise { cancelPendingQuestion(sessionId, "Claude request cancelled"); + const sdkQuery = activeSdkQueries.get(sessionId); + if (sdkQuery) { + await sdkQuery.interrupt().catch(() => undefined); + return true; + } return runtime.cancelActiveRequest(channelId, sessionId); } diff --git a/packages/agents/claude/session-state.ts b/packages/agents/claude/session-state.ts index 12a85cff..914ae769 100644 --- a/packages/agents/claude/session-state.ts +++ b/packages/agents/claude/session-state.ts @@ -3,8 +3,10 @@ import { applyAnthropicStyleStreamEvent, applyAssistantBlocks, applyUserToolResults, + buildToolTitle, extractPrefixedRecord, extractSessionTitle, + updateTool, type StreamStateMaps, type StreamToolState, } from "@/agents/session-state/shared"; @@ -25,10 +27,39 @@ export type ClaudeRawRecord = { name?: string; input?: Record; tool_use_id?: string; - content?: string; + content?: unknown; is_error?: boolean; }>; }; + subtype?: string; + status?: string | null; + state?: string; + parent_tool_use_id?: string | null; + task_description?: string; + subagent_type?: string; + task_id?: string; + task_type?: string; + tool_use_id?: string; + tool_name?: string; + description?: string; + prompt?: string; + last_tool_name?: string; + summary?: string; + patch?: { + status?: string; + description?: string; + end_time?: number; + error?: string; + is_backgrounded?: boolean; + }; + usage?: Record; + attempt?: number; + max_retries?: number; + retry_delay_ms?: number; + elapsed_time_seconds?: number; + error_status?: number | null; + errors?: string[]; + total_cost_usd?: number; result?: string; is_error?: boolean; error?: string; @@ -72,6 +103,148 @@ function parseTodosFromClaudeToolInput( return todos; } +function normalizeClaudeToolName(name: string): string { + const normalized = name.trim().toLowerCase(); + return normalized === "agent" || normalized === "task" ? "subagent" : name; +} + +function findSubagentTool( + toolById: Map, + record: ClaudeRawRecord +): ClaudeInspectorToolState | undefined { + const directId = record.tool_use_id ?? record.parent_tool_use_id ?? undefined; + if (directId) { + const direct = toolById.get(directId); + if (direct) return direct; + } + if (!record.task_id) return undefined; + return [...toolById.values()].find((tool) => tool.metadata?.taskId === record.task_id); +} + +function upsertClaudeSubagent( + state: SessionMessageState, + toolById: Map, + record: ClaudeRawRecord, + status: "running" | "completed" | "error", + options: { title?: string; progress?: string; lastTool?: string; output?: string; error?: string } = {} +): ClaudeInspectorToolState { + const id = record.tool_use_id + ?? record.parent_tool_use_id + ?? (record.task_id ? `claude-task:${record.task_id}` : `claude-subagent:${Date.now()}`); + const existing = findSubagentTool(toolById, record) ?? toolById.get(id); + const title = options.title + ?? record.description + ?? record.task_description + ?? existing?.title + ?? record.subagent_type + ?? "subagent"; + const startedAtMs = typeof existing?.metadata?.startedAtMs === "number" + ? existing.metadata.startedAtMs + : Date.now(); + const tool: ClaudeInspectorToolState = { + id: existing?.id ?? id, + name: "subagent", + status, + title, + input: existing?.input ?? { description: title }, + output: options.output ?? existing?.output, + error: options.error ?? existing?.error, + metadata: { + ...(existing?.metadata ?? {}), + provider: "claude", + taskId: record.task_id ?? existing?.metadata?.taskId, + parentToolUseId: record.tool_use_id ?? record.parent_tool_use_id ?? existing?.metadata?.parentToolUseId, + subagentType: record.subagent_type ?? existing?.metadata?.subagentType, + startedAtMs, + lastTool: options.lastTool ?? record.last_tool_name ?? existing?.metadata?.lastTool, + progress: options.progress ?? existing?.metadata?.progress, + usage: record.usage ?? existing?.metadata?.usage, + background: record.patch?.is_backgrounded ?? existing?.metadata?.background, + }, + }; + toolById.set(tool.id, tool); + if (record.tool_use_id && record.tool_use_id !== tool.id) toolById.set(record.tool_use_id, tool); + if (record.parent_tool_use_id && record.parent_tool_use_id !== tool.id) toolById.set(record.parent_tool_use_id, tool); + updateTool(state, tool); + + const detail = options.progress ?? options.lastTool ?? record.last_tool_name; + if (status === "running") { + state.phaseStatus = detail ? `Subagent ${title}: ${detail}` : `Running subagent: ${title}`; + } else if (status === "error") { + state.phaseStatus = `Subagent failed: ${title}`; + } else { + state.phaseStatus = `Finished subagent: ${title}`; + } + return tool; +} + +function applyClaudeSystemRecord( + state: SessionMessageState, + record: ClaudeRawRecord, + toolById: Map +): boolean { + if (record.type !== "system") return false; + switch (record.subtype) { + case "task_started": + if (!record.patch?.is_backgrounded) { + upsertClaudeSubagent(state, toolById, record, "running", { title: record.description }); + } + return true; + case "task_progress": + upsertClaudeSubagent(state, toolById, record, "running", { + title: findSubagentTool(toolById, record)?.title, + progress: record.summary ?? record.description, + lastTool: record.last_tool_name, + }); + return true; + case "task_updated": { + const taskStatus = record.patch?.status; + if (taskStatus === "failed" || taskStatus === "killed") { + upsertClaudeSubagent(state, toolById, record, "error", { error: record.patch?.error }); + } else if (taskStatus === "completed") { + upsertClaudeSubagent(state, toolById, record, "completed"); + } else { + upsertClaudeSubagent(state, toolById, record, "running", { progress: record.patch?.description }); + } + return true; + } + case "task_notification": + upsertClaudeSubagent(state, toolById, record, record.status === "completed" ? "completed" : "error", { + output: record.summary, + error: record.status === "completed" ? undefined : record.summary, + }); + return true; + case "api_retry": { + const seconds = typeof record.retry_delay_ms === "number" ? Math.ceil(record.retry_delay_ms / 1000) : undefined; + const attempt = typeof record.attempt === "number" ? ` ${record.attempt}/${record.max_retries ?? "?"}` : ""; + state.phaseStatus = `Retrying Claude request${attempt}${seconds !== undefined ? ` in ${seconds}s` : ""}`; + return true; + } + case "session_state_changed": + if (record.state === "running") state.phaseStatus = "Working"; + if (record.state === "idle") state.phaseStatus = "Waiting"; + if (record.state === "requires_action") state.phaseStatus = "Waiting for user action"; + return true; + case "status": + if (record.status === "compacting") state.phaseStatus = "Compacting conversation context"; + else if (record.status === "requesting" && !state.tools.some((tool) => tool.name === "subagent" && tool.status === "running")) { + state.phaseStatus = "Requesting Claude response"; + } + return true; + case "compact_boundary": + state.phaseStatus = "Compacted conversation context"; + return true; + case "permission_denied": + state.phaseStatus = "Claude permission denied"; + return true; + case "worker_shutting_down": + state.phaseStatus = "Claude worker is shutting down"; + return true; + default: + return false; + } +} + export function extractClaudeRecord( type: string, eventData: Record, @@ -83,7 +256,8 @@ export function extractClaudeRecord( export function applyClaudeRecordToState( state: SessionMessageState, record: ClaudeRawRecord, - streamState: ClaudeStreamStateMaps + streamState: ClaudeStreamStateMaps, + receivedAtMs = Date.now() ): void { const { textByIndex, thinkingByIndex, toolByIndex, toolById } = streamState; const sessionTitle = extractSessionTitle(record); @@ -91,9 +265,44 @@ export function applyClaudeRecordToState( state.sessionTitle = sessionTitle; } + if (applyClaudeSystemRecord(state, record, toolById)) { + return; + } + + if (record.type === "tool_progress") { + const parentId = record.parent_tool_use_id ?? record.tool_use_id; + const parent = parentId ? toolById.get(parentId) : undefined; + if (parent?.name === "subagent" || record.parent_tool_use_id || record.task_id) { + upsertClaudeSubagent(state, toolById, record, "running", { + title: parent?.title, + lastTool: record.tool_name, + progress: record.tool_name + ? `${record.tool_name} (${Math.max(0, Math.round(record.elapsed_time_seconds ?? 0))}s)` + : undefined, + }); + } + return; + } + if (record.type === "assistant") { const blocks = record.message?.content ?? []; - for (const block of blocks) { + if (record.parent_tool_use_id) { + const lastToolBlock = blocks.find((block) => block?.type === "tool_use"); + const lastToolName = lastToolBlock?.name; + const lastToolTitle = lastToolName + ? buildToolTitle(lastToolName, lastToolBlock?.input) + : undefined; + upsertClaudeSubagent(state, toolById, record, "running", { + title: record.task_description, + lastTool: lastToolName, + progress: [lastToolName, lastToolTitle].filter(Boolean).join(" - ") || "working", + }); + return; + } + const normalizedBlocks = blocks.map((block) => block?.type === "tool_use" + ? { ...block, name: normalizeClaudeToolName(block.name ?? "tool") } + : block); + for (const block of normalizedBlocks) { if (block?.type !== "tool_use") continue; const toolName = typeof block.name === "string" ? block.name : ""; const input = block.input && typeof block.input === "object" && !Array.isArray(block.input) @@ -104,17 +313,32 @@ export function applyClaudeRecordToState( state.todos = parsedTodos; } } - applyAssistantBlocks(state, blocks, { toolById }, "claude-tool"); + applyAssistantBlocks(state, normalizedBlocks, { toolById }, "claude-tool", { startedAtMs: receivedAtMs }); return; } if (record.type === "user") { + if (record.parent_tool_use_id) { + upsertClaudeSubagent(state, toolById, record, "running", { + title: record.task_description, + progress: "processing tool result", + }); + return; + } applyUserToolResults(state, record.message?.content ?? [], { toolById }); return; } if (record.type === "result") { - state.phaseStatus = record.is_error ? "Claude reported an error" : "Finalizing response"; + const error = record.errors?.find((entry) => entry.trim()) ?? record.error; + state.phaseStatus = record.is_error + ? `Claude error: ${error ?? record.subtype ?? "execution failed"}` + : "Finalizing response"; + return; + } + + if (record.type === "rate_limit_event") { + state.phaseStatus = "Claude rate limit updated"; return; } @@ -134,12 +358,24 @@ export function applyClaudeRecordToState( } } - applyAnthropicStyleStreamEvent(state, record, { + const streamRecord = record.event?.content_block?.type === "tool_use" + ? { + ...record, + event: { + ...record.event, + content_block: { + ...record.event.content_block, + name: normalizeClaudeToolName(String(record.event.content_block.name ?? "tool")), + }, + }, + } + : record; + applyAnthropicStyleStreamEvent(state, streamRecord, { textByIndex, thinkingByIndex, toolByIndex, toolById, - }, "claude-tool"); + }, "claude-tool", { completeToolOnContentBlockStop: false, startedAtMs: receivedAtMs }); if (record.event?.type === "content_block_delta" && record.event.delta?.type === "input_json_delta") { const index = typeof record.event.index === "number" ? record.event.index : undefined; diff --git a/packages/agents/codebuddy/client.ts b/packages/agents/codebuddy/client.ts index de5c029a..14466958 100644 --- a/packages/agents/codebuddy/client.ts +++ b/packages/agents/codebuddy/client.ts @@ -1,4 +1,5 @@ -import { setThreadSessionId } from "@/config/local/sessions"; +import { setThreadSessionId, updateThreadSessionBinding } from "@/config/local/sessions"; +import { LEGACY_AGENT_CAPABILITIES } from "@/shared/agent-protocol"; import { BoundedSet, log } from "@/utils"; import { buildPromptParts, buildPromptText, buildSystemPrompt, buildSystemWrappedPrompt } from "../shared"; import { @@ -9,7 +10,15 @@ import { type SessionEnvironment as RuntimeSessionEnvironment, } from "../runtime/base"; import { createCliThreadSessionManager } from "../runtime/cli-session"; +import { inspectCliProtocol } from "../runtime/protocol-drift"; +import { + cancelAcpSession, + prependSystemPrompt, + sendMessageViaAcp, + stopAcpProvider, +} from "../runtime/acp-client"; import type { + AgentInput, OpenCodeMessage, OpenCodeMessageContext, OpenCodeOptions, @@ -46,6 +55,7 @@ export type CodeBuddyJsonRecord = { const runtime = new CliAgentRuntime("CodeBuddy"); const NEW_SESSIONS_MAX_ENTRIES = 1000; const newSessions = new BoundedSet(NEW_SESSIONS_MAX_ENTRIES); +const CODEBUDDY_RECORD_TYPES = ["system", "assistant", "user", "stream_event", "result"]; const DEFAULT_CODEBUDDY_MODEL = "gpt-5.1"; export const { createSession, getOrCreateSession } = createCliThreadSessionManager({ @@ -87,7 +97,7 @@ export function buildCodeBuddyCommandArgs(params: { "--model", resolveCodeBuddyModel(params.model), "--permission-mode", - params.agent?.trim().toLowerCase() === "plan" ? "plan" : "bypassPermissions", + params.agent?.trim().toLowerCase() === "plan" ? "plan" : "dontAsk", "--max-turns", "20", "--setting-sources", @@ -143,11 +153,20 @@ function publishCodeBuddyRecord(record: CodeBuddyJsonRecord, fallbackSessionId: ? record.session_id : fallbackSessionId; const rawType = typeof record.type === "string" && record.type.trim() ? record.type.trim() : "unknown"; + const streamEventType = typeof record.event?.type === "string" ? record.event.type : undefined; const payload = { type: `codebuddy.raw.${rawType}`, properties: { record, recordType: rawType, + streamEventType, + ...inspectCliProtocol({ + providerName: "CodeBuddy", + recordType: rawType, + streamEventType, + knownRecordTypes: CODEBUDDY_RECORD_TYPES, + anthropicStyleStream: true, + }), }, }; runtime.publishSessionEvent(sessionId, payload); @@ -156,10 +175,10 @@ function publishCodeBuddyRecord(record: CodeBuddyJsonRecord, fallbackSessionId: } } -export async function sendMessage( +async function sendMessageViaCli( channelId: string, sessionId: string, - message: string, + input: AgentInput, workingPath: string, options?: OpenCodeOptions, context?: OpenCodeMessageContext @@ -170,7 +189,7 @@ export async function sendMessage( try { return await runtime.withSessionLock(sessionKey, async () => { const agent = options?.agent; - const parts = buildPromptParts(channelId, message, { ...options, agent }, context); + const parts = buildPromptParts(channelId, input, { ...options, agent }, context); const prompt = buildPromptText(parts); const codeBuddyPrompt = buildSystemWrappedPrompt(buildSystemPrompt(context?.slack), prompt); const envOverrides = runtime.getSessionEnvironment(sessionId); @@ -210,7 +229,77 @@ export async function sendMessage( export const ensureSession = runtime.ensureSession.bind(runtime); export const subscribeToSession = runtime.subscribeToSession.bind(runtime); -export const abortSession = runtime.abortSession.bind(runtime); -export const cancelActiveRequest = runtime.cancelActiveRequest.bind(runtime); -export const stopServer = runtime.stopServer.bind(runtime); + +export async function sendMessage( + channelId: string, + sessionId: string, + input: AgentInput, + workingPath: string, + options?: OpenCodeOptions, + context?: OpenCodeMessageContext +): Promise { + const agent = options?.agent; + const promptParts = buildPromptParts(channelId, input, { ...options, agent }, context); + const systemPrompt = buildSystemPrompt(context?.slack); + const environment = runtime.getSessionEnvironment(sessionId); + + return sendMessageViaAcp({ + providerId: "codebuddy", + providerName: "CodeBuddy", + launch: { command: resolveCodeBuddyBinary(), args: ["--acp"] }, + channelId, + sessionId, + isNewSession: newSessions.has(sessionId), + workingPath, + environment, + parts: prependSystemPrompt(promptParts, systemPrompt), + options, + publisher: runtime, + onNativeSessionId: (nativeSessionId) => { + runtime.setSessionEnvironment(nativeSessionId, environment); + newSessions.delete(sessionId); + newSessions.delete(nativeSessionId); + if (nativeSessionId !== sessionId && context?.slack?.threadId) { + setThreadSessionId(channelId, context.slack.threadId, nativeSessionId); + } + }, + onNegotiated: ({ protocolVersion, capabilities }) => { + if (context?.slack?.threadId) { + updateThreadSessionBinding(channelId, context.slack.threadId, { + transport: "acp", + protocolVersion, + capabilities, + }); + } + }, + onFallback: () => { + if (context?.slack?.threadId) { + updateThreadSessionBinding(channelId, context.slack.threadId, { + transport: "cli-json", + protocolVersion: undefined, + capabilities: LEGACY_AGENT_CAPABILITIES, + }); + } + }, + fallback: () => sendMessageViaCli(channelId, sessionId, input, workingPath, options, context), + }); +} + +export async function abortSession(sessionId: string): Promise { + await cancelAcpSession("codebuddy", sessionId).catch(() => false); + await runtime.abortSession(sessionId); +} + +export async function cancelActiveRequest(channelId: string, sessionId: string): Promise { + const [acpCancelled, cliCancelled] = await Promise.all([ + cancelAcpSession("codebuddy", sessionId).catch(() => false), + runtime.cancelActiveRequest(channelId, sessionId), + ]); + return acpCancelled || cliCancelled; +} + +export function stopServer(): void { + stopAcpProvider("codebuddy"); + runtime.stopServer(); +} export const startServer = noopStartServer; diff --git a/packages/agents/codex/app-events.ts b/packages/agents/codex/app-events.ts new file mode 100644 index 00000000..af01112b --- /dev/null +++ b/packages/agents/codex/app-events.ts @@ -0,0 +1,544 @@ +type UnknownRecord = Record; + +export type CodexAppSessionEvent = { + type: string; + properties: Record; +}; + +type CodexSubagentState = { + threadId: string; + title?: string; + agentPath?: string; + startedAtMs: number; + lastTool?: string; + status: "running" | "completed" | "error"; +}; + +export type CodexAppEventState = { + rootThreadId?: string; + textSnapshots: Map; + reasoningSnapshots: Map; + planSnapshots: Map; + toolItems: Map; + toolOutputs: Map; + subagents: Map; +}; + +export function createCodexAppEventState(): CodexAppEventState { + return { + textSnapshots: new Map(), + reasoningSnapshots: new Map(), + planSnapshots: new Map(), + toolItems: new Map(), + toolOutputs: new Map(), + subagents: new Map(), + }; +} + +const KNOWN_NOTIFICATION_METHODS = new Set([ + "error", + "thread/started", + "thread/status/changed", + "thread/archived", + "thread/deleted", + "thread/unarchived", + "thread/closed", + "thread/name/updated", + "thread/goal/updated", + "thread/goal/cleared", + "thread/environment/connected", + "thread/environment/disconnected", + "thread/settings/updated", + "thread/tokenUsage/updated", + "thread/compacted", + "turn/started", + "turn/completed", + "turn/diff/updated", + "turn/plan/updated", + "turn/moderationMetadata", + "item/started", + "item/completed", + "item/agentMessage/delta", + "item/plan/delta", + "item/reasoning/summaryTextDelta", + "item/reasoning/summaryPartAdded", + "item/reasoning/textDelta", + "item/commandExecution/outputDelta", + "item/commandExecution/terminalInteraction", + "item/fileChange/outputDelta", + "item/fileChange/patchUpdated", + "item/mcpToolCall/progress", + "item/autoApprovalReview/started", + "item/autoApprovalReview/completed", + "serverRequest/resolved", + "rawResponseItem/completed", + "rawResponse/completed", + "hook/started", + "hook/completed", + "model/rerouted", + "model/verification", + "model/safetyBuffering/updated", + "warning", + "guardianWarning", + "deprecationNotice", + "configWarning", + "mcpServer/oauthLogin/completed", + "mcpServer/startupStatus/updated", + "account/updated", + "account/rateLimits/updated", + "account/login/completed", + "app/list/updated", + "remoteControl/status/changed", + "externalAgentConfig/import/progress", + "externalAgentConfig/import/completed", + "skills/changed", + "fs/changed", + "fuzzyFileSearch/sessionUpdated", + "fuzzyFileSearch/sessionCompleted", + "command/exec/outputDelta", + "process/outputDelta", + "process/exited", + "thread/realtime/started", + "thread/realtime/itemAdded", + "thread/realtime/transcript/delta", + "thread/realtime/transcript/done", + "thread/realtime/outputAudio/delta", + "thread/realtime/sdp", + "thread/realtime/error", + "thread/realtime/closed", + "windows/worldWritableWarning", + "windowsSandbox/setupCompleted", + "ode/question/requested", + "ode/serverRequest/declined", + "ode/serverRequest/failed", +]); + +function asNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function compact(value: unknown, limit = 120): string | undefined { + const text = asNonEmptyString(value); + if (!text) return undefined; + const singleLine = text.replace(/\s+/g, " "); + return singleLine.length > limit ? `${singleLine.slice(0, limit - 3)}...` : singleLine; +} + +function itemLabel(item: UnknownRecord): string { + switch (item.type) { + case "commandExecution": return "bash"; + case "fileChange": return "editing files"; + case "mcpToolCall": return asNonEmptyString(item.tool) ? `MCP ${item.tool}` : "MCP tool"; + case "dynamicToolCall": return asNonEmptyString(item.tool) ?? "dynamic tool"; + case "webSearch": return "web search"; + case "imageView": return "viewing image"; + case "imageGeneration": return "generating image"; + default: return asNonEmptyString(item.type) ?? "working"; + } +} + +function extractNotificationThreadId(notification: UnknownRecord): string | undefined { + const params = notification.params ?? {}; + return asNonEmptyString(params.threadId) + ?? asNonEmptyString(params.thread?.id) + ?? asNonEmptyString(params.turn?.threadId); +} + +function subagentTitle(state: CodexAppEventState, threadId: string): string { + const subagent = state.subagents.get(threadId); + return subagent?.title ?? subagent?.agentPath?.split("/").filter(Boolean).at(-1) ?? "subagent"; +} + +function subagentEvent( + state: CodexAppEventState, + threadId: string, + status: "running" | "completed" | "error", + options: { title?: string; lastTool?: string; output?: string; error?: string } = {} +): CodexAppSessionEvent { + const existing = state.subagents.get(threadId); + const startedAtMs = existing?.startedAtMs ?? Date.now(); + const title = options.title ?? existing?.title ?? subagentTitle(state, threadId); + const lastTool = options.lastTool ?? existing?.lastTool; + state.subagents.set(threadId, { + threadId, + title, + agentPath: existing?.agentPath, + startedAtMs, + lastTool, + status, + }); + return { + type: "message.part.updated", + properties: { + part: { + id: `codex-subagent:${threadId}`, + type: "tool", + tool: "subagent", + state: { + status, + title, + input: { description: title }, + output: options.output, + error: options.error, + metadata: { + provider: "codex", + sourceThreadId: threadId, + childSession: true, + startedAtMs, + lastTool, + }, + }, + }, + }, + }; +} + +function normalizeTokenUsage(tokenUsage: UnknownRecord | undefined): UnknownRecord | undefined { + if (!tokenUsage) return undefined; + const total = tokenUsage.total ?? {}; + return { + input_tokens: total.inputTokens, + output_tokens: total.outputTokens, + reasoning_tokens: total.reasoningOutputTokens, + cached_tokens: { read: total.cachedInputTokens, write: total.cacheWriteInputTokens }, + total_tokens: total.totalTokens, + }; +} + +function toolPart(item: UnknownRecord, statusOverride?: string): CodexAppSessionEvent { + const failed = item.status === "failed" || item.status === "declined"; + const completed = statusOverride === "completed" || item.status === "completed"; + const status = statusOverride ?? (failed ? "error" : completed ? "completed" : "running"); + const tool = item.type === "commandExecution" + ? "bash" + : item.type === "collabAgentToolCall" + ? "collaboration" + : item.type; + const input = item.command + ? { command: item.command, cwd: item.cwd } + : item.type === "collabAgentToolCall" + ? { + action: item.tool, + prompt: item.prompt, + receiverThreadIds: item.receiverThreadIds, + agentsStates: item.agentsStates, + } + : item.arguments; + const title = item.type === "collabAgentToolCall" + ? compact(item.prompt) ?? compact(item.tool) + : undefined; + return { + type: "message.part.updated", + properties: { + part: { + id: item.id, + type: "tool", + tool, + state: { + status, + title, + input, + output: item.aggregatedOutput ?? item.result, + error: item.error, + metadata: { + provider: "codex", + senderThreadId: item.senderThreadId, + receiverThreadIds: item.receiverThreadIds, + agentsStates: item.agentsStates, + }, + }, + }, + }, + }; +} + +function normalizeChildNotification( + state: CodexAppEventState, + method: string, + params: UnknownRecord, + childThreadId: string +): CodexAppSessionEvent[] { + const item = params.item as UnknownRecord | undefined; + if ((method === "item/started" || method === "item/completed") && item) { + if (item.type === "agentMessage") return []; + if (item.type === "commandExecution" || item.type === "fileChange" || item.type === "mcpToolCall" + || item.type === "dynamicToolCall" || item.type === "webSearch" || item.type === "imageView" + || item.type === "imageGeneration") { + return [subagentEvent(state, childThreadId, "running", { lastTool: itemLabel(item) })]; + } + } + if (method === "item/commandExecution/outputDelta" || method === "item/mcpToolCall/progress" + || method === "item/fileChange/patchUpdated") { + const label = method.includes("commandExecution") + ? "bash" + : method.includes("mcpToolCall") + ? "MCP tool" + : "editing files"; + return [subagentEvent(state, childThreadId, "running", { lastTool: label })]; + } + if (method === "turn/completed") { + const turn = params.turn ?? {}; + const failed = turn.status === "failed"; + return [subagentEvent(state, childThreadId, failed ? "error" : "completed", { + error: failed ? compact(turn.error?.message) ?? "Subagent failed" : undefined, + })]; + } + if (method === "error") { + const message = compact(params.error?.message) ?? "Subagent error"; + return [subagentEvent(state, childThreadId, params.willRetry ? "running" : "error", { + error: params.willRetry ? undefined : message, + lastTool: params.willRetry ? `retrying: ${message}` : undefined, + })]; + } + if (method === "thread/status/changed" && params.status?.type === "systemError") { + return [subagentEvent(state, childThreadId, "error", { error: "Subagent system error" })]; + } + return []; +} + +export function normalizeCodexAppNotification( + state: CodexAppEventState, + notification: UnknownRecord +): CodexAppSessionEvent[] { + const method = asNonEmptyString(notification.method) ?? "unknown"; + const params = notification.params ?? {}; + const sourceThreadId = extractNotificationThreadId(notification); + const rootThreadId = state.rootThreadId; + + if (method === "ode/serverRequest/declined") { + const requestMethod = asNonEmptyString(params.requestMethod) ?? "server request"; + return [{ + type: "session.status", + properties: { status: `Codex request declined: ${requestMethod}` }, + }]; + } + if (method === "ode/serverRequest/failed") { + const requestMethod = asNonEmptyString(params.requestMethod) ?? "unknown request"; + const message = compact(params.message) ?? "unsupported client capability"; + return [{ + type: "session.status", + properties: { + status: params.protocolKnown === false + ? `Codex integration update required: ${requestMethod}` + : `Codex client capability unavailable: ${message}`, + }, + }]; + } + + if (sourceThreadId && rootThreadId && sourceThreadId !== rootThreadId) { + return normalizeChildNotification(state, method, params, sourceThreadId); + } + + if (method === "ode/question/requested") { + const questions = Array.isArray(params.questions) ? params.questions : []; + return [{ + type: "question.asked", + properties: { + id: params.requestId, + questions: questions.map((question: UnknownRecord) => ({ + question: question.question ?? question.header ?? "Question", + options: Array.isArray(question.options) + ? question.options.map((option: UnknownRecord) => ({ + label: option.label ?? option.description ?? String(option), + })) + : [], + multiple: false, + custom: question.isOther !== false, + })), + }, + }]; + } + + if (method === "turn/started") { + return [{ type: "session.status", properties: { status: { type: "busy" } } }]; + } + if (method === "turn/completed") { + const failed = params.turn?.status === "failed"; + const error = compact(params.turn?.error?.message); + return [{ + type: "session.status", + properties: { status: failed ? `Codex error: ${error ?? "turn failed"}` : { type: "idle" } }, + }]; + } + if (method === "thread/status/changed") { + const status = params.status ?? {}; + if (status.type === "active") { + const flag = Array.isArray(status.activeFlags) ? status.activeFlags[0] : undefined; + return [{ + type: "session.status", + properties: { status: { type: "busy", message: flag === "waitingOnApproval" ? "Waiting for approval" : undefined } }, + }]; + } + if (status.type === "idle") { + return [{ type: "session.status", properties: { status: { type: "idle" } } }]; + } + if (status.type === "systemError") { + return [{ type: "session.status", properties: { status: "Codex system error" } }]; + } + return []; + } + if (method === "thread/tokenUsage/updated") { + const usage = normalizeTokenUsage(params.tokenUsage); + return usage + ? [{ type: "message.updated", properties: { info: { tokenUsage: usage } } }] + : []; + } + if (method === "error") { + const message = compact(params.error?.message) ?? "Codex error"; + return [{ + type: "session.status", + properties: { status: params.willRetry ? { type: "retry", message } : `Codex error: ${message}` }, + }]; + } + if (method === "item/agentMessage/delta" && typeof params.delta === "string") { + const key = params.itemId ?? "agent"; + const text = `${state.textSnapshots.get(key) ?? ""}${params.delta}`; + state.textSnapshots.set(key, text); + return [{ + type: "message.part.updated", + properties: { part: { id: key, type: "text", text } }, + }]; + } + if ((method === "item/reasoning/summaryTextDelta" || method === "item/reasoning/textDelta") + && typeof params.delta === "string") { + const key = params.itemId ?? "reasoning"; + const text = `${state.reasoningSnapshots.get(key) ?? ""}${params.delta}`; + state.reasoningSnapshots.set(key, text); + return [{ + type: "message.part.updated", + properties: { part: { id: key, type: "reasoning", text } }, + }]; + } + if (method === "item/plan/delta" && typeof params.delta === "string") { + const key = params.itemId ?? "plan"; + const text = `${state.planSnapshots.get(key) ?? ""}${params.delta}`; + state.planSnapshots.set(key, text); + return [{ type: "session.status", properties: { status: `Planning: ${compact(text, 90) ?? "updating plan"}` } }]; + } + if (method === "turn/plan/updated") { + const items = Array.isArray(params.plan) ? params.plan : Array.isArray(params.items) ? params.items : []; + return [{ type: "todo.updated", properties: { items } }]; + } + if (method === "turn/diff/updated" && typeof params.diff === "string") { + return [{ + type: "message.part.updated", + properties: { + part: { + id: `codex-diff:${params.turnId ?? "turn"}`, + type: "tool", + tool: "edit", + state: { status: "running", title: "Updating files", output: params.diff }, + }, + }, + }]; + } + if (method === "thread/compacted") { + return [{ type: "session.status", properties: { status: "Compacted conversation context" } }]; + } + if (method === "model/rerouted") { + return [{ + type: "session.status", + properties: { status: `Model rerouted: ${params.fromModel ?? "default"} → ${params.toModel ?? "fallback"}` }, + }]; + } + if (method === "warning" || method === "configWarning" || method === "guardianWarning" || method === "deprecationNotice") { + const message = compact(params.message ?? params.summary ?? params.details) ?? "Codex warning"; + return [{ type: "session.status", properties: { status: `Warning: ${message}` } }]; + } + if (method === "item/commandExecution/outputDelta" && typeof params.delta === "string") { + const item = state.toolItems.get(params.itemId) ?? { id: params.itemId, type: "commandExecution" }; + const output = `${state.toolOutputs.get(params.itemId) ?? ""}${params.delta}`; + state.toolOutputs.set(params.itemId, output); + return [toolPart({ ...item, aggregatedOutput: output }, "running")]; + } + if (method === "item/mcpToolCall/progress") { + const item = state.toolItems.get(params.itemId) ?? { id: params.itemId, type: "mcpToolCall" }; + return [toolPart({ ...item, result: compact(params.message) }, "running")]; + } + if (method === "item/fileChange/patchUpdated") { + const item = state.toolItems.get(params.itemId) ?? { id: params.itemId, type: "fileChange" }; + return [toolPart({ ...item, result: JSON.stringify(params.changes ?? []) }, "running")]; + } + if (method === "item/started" || method === "item/completed") { + const item = params.item as UnknownRecord | undefined; + if (!item || typeof item.id !== "string") return []; + state.toolItems.set(item.id, item); + if (item.type === "agentMessage" && typeof item.text === "string" && item.text.trim()) { + return [{ + type: "message.part.updated", + properties: { part: { id: item.id, type: "text", text: item.text } }, + }]; + } + if (item.type === "reasoning") { + const text = Array.isArray(item.summary) ? item.summary.join("\n") : ""; + return text + ? [{ type: "message.part.updated", properties: { part: { id: item.id, type: "reasoning", text } } }] + : []; + } + if (item.type === "subAgentActivity" && typeof item.agentThreadId === "string") { + const title = item.agentPath?.split("/").filter(Boolean).at(-1) ?? "subagent"; + const existing = state.subagents.get(item.agentThreadId); + state.subagents.set(item.agentThreadId, { + threadId: item.agentThreadId, + title, + agentPath: item.agentPath, + startedAtMs: existing?.startedAtMs ?? Date.now(), + lastTool: existing?.lastTool, + status: existing?.status ?? "running", + }); + if (item.kind === "interrupted") { + return [subagentEvent(state, item.agentThreadId, "error", { title, error: "Subagent interrupted" })]; + } + return [subagentEvent(state, item.agentThreadId, "running", { title })]; + } + if (item.type === "collabAgentToolCall") { + const receiverThreadIds = Array.isArray(item.receiverThreadIds) + ? item.receiverThreadIds.filter((threadId: unknown): threadId is string => typeof threadId === "string") + : []; + const targetThreadIds = receiverThreadIds.length > 0 + ? receiverThreadIds + : [...state.subagents.values()] + .filter((subagent) => subagent.status === "running") + .map((subagent) => subagent.threadId); + if (item.tool === "wait") { + return method === "item/started" + ? targetThreadIds.map((threadId) => subagentEvent(state, threadId, "running", { lastTool: "waiting for result" })) + : []; + } + if (item.tool === "closeAgent") { + return targetThreadIds.map((threadId) => subagentEvent(state, threadId, "completed")); + } + if (item.tool === "spawnAgent" || item.tool === "resumeAgent" || item.tool === "sendInput") { + return targetThreadIds.map((threadId) => subagentEvent(state, threadId, "running", { + title: compact(item.prompt) ?? state.subagents.get(threadId)?.title, + lastTool: item.tool === "spawnAgent" ? "starting" : item.tool === "resumeAgent" ? "resuming" : "received input", + })); + } + return []; + } + const toolTypes = new Set([ + "commandExecution", "fileChange", "mcpToolCall", "dynamicToolCall", + "webSearch", "imageView", "imageGeneration", "sleep", + ]); + return toolTypes.has(item.type) + ? [toolPart(item, method === "item/completed" ? undefined : "running")] + : []; + } + return []; +} + +export function isKnownCodexAppNotificationMethod(method: string): boolean { + return KNOWN_NOTIFICATION_METHODS.has(method); +} + +export function getCodexAppNotificationContext( + state: CodexAppEventState, + notification: UnknownRecord +): { rootThreadId?: string; sourceThreadId?: string; childThread: boolean } { + const sourceThreadId = extractNotificationThreadId(notification); + return { + rootThreadId: state.rootThreadId, + sourceThreadId, + childThread: Boolean(sourceThreadId && state.rootThreadId && sourceThreadId !== state.rootThreadId), + }; +} diff --git a/packages/agents/codex/app-server.ts b/packages/agents/codex/app-server.ts new file mode 100644 index 00000000..4c1869d2 --- /dev/null +++ b/packages/agents/codex/app-server.ts @@ -0,0 +1,395 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { log } from "@/utils"; + +type JsonRpcId = number | string; +type JsonRecord = Record; + +type PendingCall = { + resolve: (value: any) => void; + reject: (error: Error) => void; +}; + +type PendingTurn = { + resolve: (turn: JsonRecord) => void; + reject: (error: Error) => void; +}; + +const pendingQuestions = new Map(); + +export const CODEX_SERVER_REQUEST_METHODS = new Set([ + "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + "item/tool/requestUserInput", + "mcpServer/elicitation/request", + "item/permissions/requestApproval", + "item/tool/call", + "account/chatgptAuthTokens/refresh", + "attestation/generate", + "currentTime/read", + "applyPatchApproval", + "execCommandApproval", +]); + +export function isKnownCodexServerRequestMethod(method: string): boolean { + return CODEX_SERVER_REQUEST_METHODS.has(method); +} + +export class CodexAppServerUnavailableError extends Error {} + +export type CodexServerRequestFallback = + | { kind: "handled-elsewhere" } + | { kind: "result"; result: JsonRecord } + | { kind: "error"; error: { code: number; message: string } }; + +/** + * Return a protocol-valid, least-privilege response for app-server requests + * that Ode cannot interactively fulfill yet. This keeps the turn moving (or + * fails the individual capability clearly) instead of replying "method not + * found", which app-server interprets as a broken client implementation. + */ +export function getCodexServerRequestFallback( + method: string, + nowMs = Date.now() +): CodexServerRequestFallback { + if (method === "item/tool/requestUserInput") { + return { kind: "handled-elsewhere" }; + } + if (method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval") { + return { kind: "result", result: { decision: "decline" } }; + } + if (method === "execCommandApproval" || method === "applyPatchApproval") { + return { + kind: "result", + result: { decision: { denied: { rejection: "Ode did not receive explicit user approval." } } }, + }; + } + if (method === "mcpServer/elicitation/request") { + return { kind: "result", result: { action: "decline", content: null, _meta: null } }; + } + if (method === "item/permissions/requestApproval") { + return { kind: "result", result: { permissions: {}, scope: "turn" } }; + } + if (method === "item/tool/call") { + return { + kind: "result", + result: { + success: false, + contentItems: [{ + type: "inputText", + text: "Ode does not expose this client-side dynamic tool.", + }], + }, + }; + } + if (method === "currentTime/read") { + return { + kind: "result", + result: { currentTimeAt: Math.floor(nowMs / 1000) }, + }; + } + if (method === "account/chatgptAuthTokens/refresh") { + return { + kind: "error", + error: { code: -32001, message: "Ode cannot refresh Codex account tokens; re-authenticate with Codex CLI." }, + }; + } + if (method === "attestation/generate") { + return { + kind: "error", + error: { code: -32002, message: "Client attestation is disabled for the Ode app-server connection." }, + }; + } + return { + kind: "error", + error: { code: -32601, message: `Ode does not support server request ${method}` }, + }; +} + +export class CodexAppServerConnection { + private readonly child: ChildProcessWithoutNullStreams; + private readonly calls = new Map(); + private readonly turns = new Map(); + private nextId = 1; + private stdoutBuffer = ""; + private initialized = false; + private closed = false; + private activeTurn: { threadId: string; turnId: string } | null = null; + private readonly connectionId = randomUUID(); + + constructor( + private readonly cwd: string, + env: Record, + private readonly onNotification: (notification: JsonRecord) => void + ) { + this.child = spawn("codex", ["app-server", "--stdio"], { + cwd, + env: { ...process.env, ...env, PWD: cwd }, + stdio: ["pipe", "pipe", "pipe"], + }); + this.child.stdout.on("data", (chunk) => this.handleStdout(String(chunk))); + this.child.stderr.on("data", (chunk) => { + const text = String(chunk).trim(); + if (text) log.debug("Codex app-server stderr", { text: text.slice(0, 2000) }); + }); + this.child.on("error", (error) => this.failAll(error)); + this.child.on("close", (code, signal) => { + this.closed = true; + this.failAll(new Error(`Codex app-server exited (${code ?? signal ?? "unknown"})`)); + }); + } + + async initialize(): Promise { + if (this.initialized) return; + try { + await this.request("initialize", { + clientInfo: { name: "ode", title: "Ode", version: "0.2.0" }, + capabilities: { experimentalApi: true, requestAttestation: false }, + }); + this.notify("initialized"); + this.initialized = true; + } catch (error) { + throw new CodexAppServerUnavailableError(`Codex app-server initialize failed: ${String(error)}`); + } + } + + async startThread(params: { + cwd: string; + model?: string; + systemPrompt?: string; + planMode?: boolean; + }): Promise { + const response = await this.request("thread/start", { + cwd: params.cwd, + model: params.model ?? null, + approvalPolicy: "never", + sandbox: params.planMode ? "read-only" : "danger-full-access", + developerInstructions: params.systemPrompt || null, + experimentalRawEvents: false, + }).catch((error) => { + throw new CodexAppServerUnavailableError(`Codex thread/start failed: ${String(error)}`); + }); + const threadId = response?.thread?.id; + if (typeof threadId !== "string" || !threadId) { + throw new CodexAppServerUnavailableError("Codex thread/start returned no thread id"); + } + return threadId; + } + + async resumeThread(params: { + threadId: string; + cwd: string; + model?: string; + systemPrompt?: string; + planMode?: boolean; + }): Promise { + const response = await this.request("thread/resume", { + threadId: params.threadId, + cwd: params.cwd, + model: params.model ?? null, + approvalPolicy: "never", + sandbox: params.planMode ? "read-only" : "danger-full-access", + developerInstructions: params.systemPrompt || null, + excludeTurns: true, + }).catch((error) => { + throw new CodexAppServerUnavailableError(`Codex thread/resume failed: ${String(error)}`); + }); + return typeof response?.thread?.id === "string" ? response.thread.id : params.threadId; + } + + async runTurn(params: { + threadId: string; + input: JsonRecord[]; + cwd: string; + model?: string; + effort?: string; + planMode?: boolean; + }): Promise { + const response = await this.request("turn/start", { + threadId: params.threadId, + input: params.input, + cwd: params.cwd, + approvalPolicy: "never", + model: params.model ?? null, + effort: params.effort ?? null, + }); + const turnId = response?.turn?.id; + if (typeof turnId !== "string" || !turnId) throw new Error("Codex turn/start returned no turn id"); + this.activeTurn = { threadId: params.threadId, turnId }; + return await new Promise((resolve, reject) => { + this.turns.set(turnId, { resolve, reject }); + }).finally(() => { + if (this.activeTurn?.turnId === turnId) this.activeTurn = null; + }); + } + + async interrupt(): Promise { + const active = this.activeTurn; + if (!active) return; + await this.request("turn/interrupt", active).catch(() => undefined); + } + + respond(id: JsonRpcId, result: unknown): void { + this.write({ id, result }); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.clearPendingQuestions(); + this.child.kill("SIGTERM"); + } + + private request(method: string, params?: unknown): Promise { + if (this.closed) return Promise.reject(new Error("Codex app-server connection is closed")); + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.calls.set(id, { resolve, reject }); + this.write({ method, id, params }); + }); + } + + private notify(method: string, params?: unknown): void { + this.write(params === undefined ? { method } : { method, params }); + } + + private write(payload: unknown): void { + this.child.stdin.write(`${JSON.stringify(payload)}\n`); + } + + private handleStdout(chunk: string): void { + this.stdoutBuffer += chunk; + while (true) { + const newline = this.stdoutBuffer.indexOf("\n"); + if (newline < 0) break; + const line = this.stdoutBuffer.slice(0, newline).trim(); + this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1); + if (!line) continue; + try { + this.handleMessage(JSON.parse(line) as JsonRecord); + } catch (error) { + log.warn("Invalid Codex app-server JSON", { error: String(error), line: line.slice(0, 500) }); + } + } + } + + private handleMessage(message: JsonRecord): void { + if (message.id !== undefined && (message.result !== undefined || message.error !== undefined) && !message.method) { + const pending = this.calls.get(message.id); + if (!pending) return; + this.calls.delete(message.id); + if (message.error) pending.reject(new Error(message.error.message ?? JSON.stringify(message.error))); + else pending.resolve(message.result); + return; + } + + if (message.method && message.id !== undefined) { + this.handleServerRequest(message); + return; + } + + if (!message.method) return; + if (message.method === "serverRequest/resolved") { + this.clearResolvedQuestion(message.params?.requestId); + } + this.onNotification(message); + if (message.method === "turn/completed") { + const turn = message.params?.turn; + const turnId = turn?.id; + const pending = typeof turnId === "string" ? this.turns.get(turnId) : undefined; + if (pending) { + this.turns.delete(turnId); + if (turn?.status === "failed") { + pending.reject(new Error(turn?.error?.message ?? "Codex turn failed")); + } else { + pending.resolve(turn); + } + } + } + } + + private handleServerRequest(message: JsonRecord): void { + const method = String(message.method); + if (method === "item/tool/requestUserInput") { + const requestId = `codex-app:${this.connectionId}:${message.id}`; + const questions = Array.isArray(message.params?.questions) ? message.params.questions : []; + pendingQuestions.set(requestId, { connection: this, rpcId: message.id, questions }); + this.onNotification({ + method: "ode/question/requested", + params: { requestId, ...message.params }, + }); + return; + } + const fallback = getCodexServerRequestFallback(method); + if (fallback.kind === "result") { + if (method !== "currentTime/read") { + log.warn("Codex app-server request used safe fallback", { method }); + this.onNotification({ + method: "ode/serverRequest/declined", + params: { + requestMethod: method, + threadId: message.params?.threadId ?? this.activeTurn?.threadId, + turnId: message.params?.turnId ?? this.activeTurn?.turnId, + message: `Ode safely declined ${method}; explicit user approval or a dedicated client capability is required.`, + }, + }); + } + this.respond(message.id, fallback.result); + return; + } + if (fallback.kind === "error") { + log.warn("Codex app-server request cannot be fulfilled", { method }); + this.onNotification({ + method: "ode/serverRequest/failed", + params: { + requestMethod: method, + threadId: message.params?.threadId ?? this.activeTurn?.threadId, + turnId: message.params?.turnId ?? this.activeTurn?.turnId, + protocolKnown: isKnownCodexServerRequestMethod(method), + message: fallback.error.message, + }, + }); + this.write({ id: message.id, error: fallback.error }); + } + } + + private clearResolvedQuestion(rpcId: JsonRpcId | undefined): void { + if (rpcId === undefined) return; + for (const [requestId, pending] of pendingQuestions) { + if (pending.connection === this && pending.rpcId === rpcId) { + pendingQuestions.delete(requestId); + } + } + } + + private clearPendingQuestions(): void { + for (const [requestId, pending] of pendingQuestions) { + if (pending.connection === this) pendingQuestions.delete(requestId); + } + } + + private failAll(error: Error): void { + for (const pending of this.calls.values()) pending.reject(error); + for (const pending of this.turns.values()) pending.reject(error); + this.calls.clear(); + this.turns.clear(); + this.clearPendingQuestions(); + } +} + +export function replyToCodexAppServerQuestion(requestId: string, answers: Array>): boolean { + const pending = pendingQuestions.get(requestId); + if (!pending) return false; + pendingQuestions.delete(requestId); + const response: Record = {}; + pending.questions.forEach((question, index) => { + const id = typeof question.id === "string" ? question.id : String(index); + response[id] = { answers: answers[index] ?? [] }; + }); + pending.connection.respond(pending.rpcId, { answers: response }); + return true; +} diff --git a/packages/agents/codex/client.ts b/packages/agents/codex/client.ts index d2074a82..c73c2203 100644 --- a/packages/agents/codex/client.ts +++ b/packages/agents/codex/client.ts @@ -9,7 +9,21 @@ import { type SessionEnvironment as RuntimeSessionEnvironment, } from "../runtime/base"; import { createCliThreadSessionManager } from "../runtime/cli-session"; +import { + CodexAppServerConnection, + CodexAppServerUnavailableError, + replyToCodexAppServerQuestion, +} from "./app-server"; +import { + createCodexAppEventState, + getCodexAppNotificationContext, + isKnownCodexAppNotificationMethod, + normalizeCodexAppNotification, + type CodexAppEventState, + type CodexAppSessionEvent, +} from "./app-events"; import type { + AgentInput, OpenCodeMessage, OpenCodeMessageContext, OpenCodeOptions, @@ -18,6 +32,14 @@ import type { const runtime = new CliAgentRuntime("Codex"); const NEW_SESSIONS_MAX_ENTRIES = 1000; const newSessions = new BoundedSet(NEW_SESSIONS_MAX_ENTRIES); +type CodexAppConnectionEntry = { + connection: CodexAppServerConnection; + aliases: Set; + threadId?: string; + eventState: CodexAppEventState; + unknownMethods: Set; +}; +const appConnections = new Map(); export const { createSession, getOrCreateSession } = createCliThreadSessionManager({ providerId: "codex", providerName: "Codex", @@ -91,7 +113,7 @@ export function buildCodexCommandArgs(params: { if (params.planMode) { args.push("--sandbox", "read-only"); } else { - args.push("--yolo"); + args.push("--dangerously-bypass-approvals-and-sandbox"); } if (params.model) { args.push("--model", params.model); @@ -121,6 +143,72 @@ function publishCodexEvent(sessionId: string, event: CodexJsonEvent): void { }); } +function publishCodexAppEvent(entry: CodexAppConnectionEntry, notification: Record): void { + const method = typeof notification.method === "string" ? notification.method : "unknown"; + const context = getCodexAppNotificationContext(entry.eventState, notification); + const normalizedEvents = normalizeCodexAppNotification(entry.eventState, notification); + const publish = (sessionId: string, event: Record): void => { + runtime.publishSessionEvent(sessionId, event); + }; + + if (!isKnownCodexAppNotificationMethod(method) && !entry.unknownMethods.has(method)) { + entry.unknownMethods.add(method); + log.warn("Unknown Codex app-server notification", { + method, + rootThreadId: entry.threadId, + sourceThreadId: context.sourceThreadId, + }); + } + + for (const sessionId of entry.aliases) { + publish(sessionId, { + type: `codex.app.${method.replaceAll("/", ".")}`, + properties: { + notification, + odeContext: context, + protocolKnown: isKnownCodexAppNotificationMethod(method), + }, + }); + for (const event of normalizedEvents) { + publish(sessionId, scopeCodexAppEvent(event, sessionId)); + } + } +} + +function scopeCodexAppEvent(event: CodexAppSessionEvent, sessionId: string): CodexAppSessionEvent { + if (event.type === "question.asked") { + return { ...event, properties: { ...event.properties, sessionID: sessionId } }; + } + if (event.type !== "message.part.updated") return event; + const part = event.properties.part; + if (!part || typeof part !== "object" || Array.isArray(part)) return event; + return { + ...event, + properties: { + ...event.properties, + part: { ...part, sessionID: sessionId }, + }, + }; +} + +function getOrCreateCodexAppConnection(params: { + sessionId: string; + cwd: string; + env: Record; +}): CodexAppConnectionEntry { + const existing = appConnections.get(params.sessionId); + if (existing) return existing; + const entry = {} as CodexAppConnectionEntry; + entry.aliases = new Set([params.sessionId]); + entry.eventState = createCodexAppEventState(); + entry.unknownMethods = new Set(); + entry.connection = new CodexAppServerConnection(params.cwd, params.env, (notification) => { + publishCodexAppEvent(entry, notification); + }); + appConnections.set(params.sessionId, entry); + return entry; +} + function parseCodexResponse(output: string): { text: string; threadId?: string; @@ -165,10 +253,10 @@ function parseCodexResponse(output: string): { return { text, threadId }; } -export async function sendMessage( +async function sendMessageViaCli( channelId: string, sessionId: string, - message: string, + input: AgentInput, workingPath: string, options?: OpenCodeOptions, context?: OpenCodeMessageContext @@ -181,7 +269,7 @@ export async function sendMessage( return await runtime.withSessionLock(sessionKey, async () => { const agent = options?.agent; const planMode = agent?.trim().toLowerCase() === "plan"; - const parts = buildPromptParts(channelId, message, { ...options, agent }, context); + const parts = buildPromptParts(channelId, input, { ...options, agent }, context); const prompt = buildPromptText(parts); const systemPrompt = buildSystemPrompt(context?.slack); const codexPrompt = buildSystemWrappedPrompt(systemPrompt, prompt); @@ -242,14 +330,157 @@ export async function sendMessage( } } +function buildCodexAppInput(parts: ReturnType, systemPrompt: string): Record[] { + const text = buildSystemWrappedPrompt(systemPrompt, buildPromptText(parts)); + const result: Record[] = [{ type: "text", text, text_elements: [] }]; + for (const part of parts) { + if (part.type === "image") { + result.push({ type: "localImage", path: part.path }); + } + } + return result; +} + +async function sendMessageViaAppServer( + channelId: string, + sessionId: string, + input: AgentInput, + workingPath: string, + options?: OpenCodeOptions, + context?: OpenCodeMessageContext +): Promise { + const sessionKey = `${channelId}:${sessionId}`; + runtime.beginRequest(sessionKey); + try { + return await runtime.withSessionLock(sessionKey, async () => { + await syncCodexModelsFromCache(); + const envOverrides = runtime.getSessionEnvironment(sessionId); + const entry = getOrCreateCodexAppConnection({ sessionId, cwd: workingPath, env: envOverrides }); + await entry.connection.initialize(); + + const agent = options?.agent; + const planMode = agent?.trim().toLowerCase() === "plan"; + const model = getCodexModel(options); + const systemPrompt = buildSystemPrompt(context?.slack); + const isNewSession = newSessions.has(sessionId); + let nativeThreadId: string; + if (entry.threadId) { + nativeThreadId = entry.threadId; + } else if (isNewSession) { + nativeThreadId = await entry.connection.startThread({ + cwd: workingPath, + model, + systemPrompt, + planMode, + }); + } else { + nativeThreadId = await entry.connection.resumeThread({ + threadId: sessionId, + cwd: workingPath, + model, + systemPrompt, + planMode, + }); + } + entry.threadId = nativeThreadId; + entry.eventState.rootThreadId = nativeThreadId; + entry.aliases.add(nativeThreadId); + appConnections.set(nativeThreadId, entry); + runtime.setSessionEnvironment(nativeThreadId, envOverrides); + + if (nativeThreadId !== sessionId && context?.slack?.threadId) { + setThreadSessionId(channelId, context.slack.threadId, nativeThreadId); + } + newSessions.delete(sessionId); + newSessions.delete(nativeThreadId); + + const parts = buildPromptParts(channelId, input, { ...options, agent }, context); + const turn = await entry.connection.runTurn({ + threadId: nativeThreadId, + input: buildCodexAppInput(parts, ""), + cwd: workingPath, + model, + effort: options?.reasoningEffort, + planMode, + }); + const messages = Array.isArray(turn.items) + ? turn.items + .filter((item: Record) => item?.type === "agentMessage" && typeof item.text === "string") + .map((item: Record) => String(item.text).trim()) + .filter(Boolean) + : []; + const text = messages.join("\n\n").trim(); + if (!text) throw new Error("Codex app-server returned no assistant message"); + return [{ text, messageType: "assistant" }]; + }); + } finally { + runtime.endRequest(sessionKey); + } +} + +export async function sendMessage( + channelId: string, + sessionId: string, + input: AgentInput, + workingPath: string, + options?: OpenCodeOptions, + context?: OpenCodeMessageContext +): Promise { + try { + return await sendMessageViaAppServer( + channelId, + sessionId, + input, + workingPath, + options, + context + ); + } catch (error) { + if (!(error instanceof CodexAppServerUnavailableError)) throw error; + log.warn("Codex app-server unavailable; falling back to codex exec", { + sessionId, + error: error.message, + }); + return sendMessageViaCli(channelId, sessionId, input, workingPath, options, context); + } +} + export const ensureSession = runtime.ensureSession.bind(runtime); export const subscribeToSession = runtime.subscribeToSession.bind(runtime); -export const abortSession = runtime.abortSession.bind(runtime); +export async function abortSession(sessionId: string): Promise { + await appConnections.get(sessionId)?.connection.interrupt(); + await runtime.abortSession(sessionId); +} -export const cancelActiveRequest = runtime.cancelActiveRequest.bind(runtime); +export async function cancelActiveRequest( + channelId: string, + sessionId: string, + directory?: string +): Promise { + const app = appConnections.get(sessionId); + if (app) { + await app.connection.interrupt(); + return true; + } + return runtime.cancelActiveRequest(channelId, sessionId); +} -export const stopServer = runtime.stopServer.bind(runtime); +export async function stopServer(): Promise { + const connections = new Set(Array.from(appConnections.values()).map((entry) => entry.connection)); + for (const connection of connections) connection.close(); + appConnections.clear(); + await runtime.stopServer(); +} export const startServer = syncCodexModelsFromCache; + +export async function replyToQuestion(params: { + requestId: string; + answers: Array>; +}): Promise { + if (!replyToCodexAppServerQuestion(params.requestId, params.answers)) { + throw new Error(`Unknown Codex question request: ${params.requestId}`); + } +} diff --git a/packages/agents/codex/index.ts b/packages/agents/codex/index.ts index 5cbc7c96..53a80fa5 100644 --- a/packages/agents/codex/index.ts +++ b/packages/agents/codex/index.ts @@ -8,5 +8,6 @@ export { subscribeToSession, startServer, stopServer, + replyToQuestion, type SessionEnvironment, } from "./client"; diff --git a/packages/agents/crush/client.ts b/packages/agents/crush/client.ts index e4e0854d..886ef12c 100644 --- a/packages/agents/crush/client.ts +++ b/packages/agents/crush/client.ts @@ -12,7 +12,9 @@ import { type SessionEnvironment as RuntimeSessionEnvironment, } from "../runtime/base"; import { createCliThreadSessionManager } from "../runtime/cli-session"; +import { inspectCliProtocol } from "../runtime/protocol-drift"; import type { + AgentInput, OpenCodeMessage, OpenCodeMessageContext, OpenCodeOptions, @@ -40,7 +42,7 @@ export type CrushRawRecord = { const runtime = new CliAgentRuntime("Crush"); const NEW_SESSIONS_MAX_ENTRIES = 1000; const newSessions = new BoundedSet(NEW_SESSIONS_MAX_ENTRIES); -const DEFAULT_CRUSH_MODEL = "chainbot/gpt-5.1"; +const CRUSH_RECORD_TYPES = ["start", "progress", "log", "message", "text"]; export const { createSession, getOrCreateSession } = createCliThreadSessionManager({ providerId: "crush", @@ -54,8 +56,8 @@ function resolveCrushBinary(): string { return "crush"; } -function resolveCrushModel(model?: OpenCodeOptions["model"]): string { - if (!model?.modelID) return DEFAULT_CRUSH_MODEL; +function resolveCrushModel(model?: OpenCodeOptions["model"]): string | undefined { + if (!model?.modelID) return undefined; const providerID = model.providerID?.trim(); if (providerID && providerID !== "crush") return `${providerID}/${model.modelID}`; if (model.modelID.includes("/")) return model.modelID; @@ -71,9 +73,9 @@ export function buildCrushCommandArgs(params: { const args = [ "run", "--verbose", - "--model", - resolveCrushModel(params.model), ]; + const model = resolveCrushModel(params.model); + if (model) args.push("--model", model); if (!params.isNewSession) { args.push("--session", params.sessionId); } @@ -282,6 +284,11 @@ function publishCrushRecord(record: CrushRawRecord, sessionId: string): void { properties: { record, recordType: rawType, + ...inspectCliProtocol({ + providerName: "Crush", + recordType: rawType, + knownRecordTypes: CRUSH_RECORD_TYPES, + }), }, }); } @@ -289,7 +296,7 @@ function publishCrushRecord(record: CrushRawRecord, sessionId: string): void { export async function sendMessage( channelId: string, sessionId: string, - message: string, + input: AgentInput, workingPath: string, options?: OpenCodeOptions, context?: OpenCodeMessageContext @@ -301,7 +308,7 @@ export async function sendMessage( return await runtime.withSessionLock(sessionKey, async () => { const agent = options?.agent; const isNewSession = newSessions.has(sessionId); - const parts = buildPromptParts(channelId, message, { ...options, agent }, context); + const parts = buildPromptParts(channelId, input, { ...options, agent }, context); const prompt = buildPromptText(parts); const crushPrompt = buildSystemWrappedPrompt(buildSystemPrompt(context?.slack), prompt); const envOverrides = runtime.getSessionEnvironment(sessionId); @@ -320,7 +327,7 @@ export async function sendMessage( record: { type: "start", model, - prompt: compactSingleLine(message), + prompt: compactSingleLine(prompt), } satisfies CrushRawRecord, recordType: "start", }, @@ -329,7 +336,7 @@ export async function sendMessage( publishCrushRecord({ type: "progress", model, - prompt: compactSingleLine(message), + prompt: compactSingleLine(prompt), elapsedMs: Date.now() - startedAtMs, }, sessionId); }, 15_000); diff --git a/packages/agents/gemini/client.ts b/packages/agents/gemini/client.ts deleted file mode 100644 index b157c0c0..00000000 --- a/packages/agents/gemini/client.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { setThreadSessionId } from "@/config/local/sessions"; -import { BoundedSet, log } from "@/utils"; -import { buildPromptParts, buildPromptText, buildSystemPrompt, buildSystemWrappedPrompt } from "../shared"; -import { - CliAgentRuntime, - formatShellCommand, - noopStartServer, - runCliJsonCommand, - type SessionEnvironment as RuntimeSessionEnvironment, -} from "../runtime/base"; -import { createCliThreadSessionManager } from "../runtime/cli-session"; -import type { - OpenCodeMessage, - OpenCodeMessageContext, - OpenCodeOptions, -} from "../types"; - -export type SessionEnvironment = RuntimeSessionEnvironment; - -type GeminiJsonRecord = { - type?: string; - role?: string; - content?: string; - delta?: boolean; - session_id?: string; - tool_name?: string; - tool_id?: string; - parameters?: Record; - status?: string; - output?: string; - error?: { - type?: string; - message?: string; - }; - result?: string; - model?: string; -}; - -const runtime = new CliAgentRuntime("Gemini"); -/** See note in claude/client.ts — FIFO-bounded so abandoned sessions don't leak. */ -const NEW_SESSIONS_MAX_ENTRIES = 1000; -function resolveGeminiCliTimeoutMs(): number { - const parsed = Number(process.env.ODE_GEMINI_CLI_TIMEOUT_MS); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 300_000; -} -const newSessions = new BoundedSet(NEW_SESSIONS_MAX_ENTRIES); -export const { createSession, getOrCreateSession } = createCliThreadSessionManager({ - providerId: "gemini", - providerName: "Gemini", - runtime, - newSessions, -}); - -function resolveGeminiBinary(): string { - if (typeof Bun !== "undefined" && Bun.which("gemini")) return "gemini"; - return "gemini"; -} - -function resolveGeminiApprovalMode(agent?: string): "plan" | undefined { - return agent?.trim().toLowerCase() === "plan" ? "plan" : undefined; -} - -export function buildGeminiCommandArgs(params: { - sessionId: string; - isNewSession: boolean; - prompt: string; - approvalMode?: "plan"; - model?: string; -}): string[] { - const args = [ - "-p", - params.prompt, - "--output-format", - "stream-json", - "--approval-mode", - params.approvalMode ?? "yolo", - ]; - if (params.model?.trim()) { - args.push("--model", params.model.trim()); - } - if (!params.isNewSession) { - args.push("--resume", params.sessionId); - } - return args; -} - -export function buildGeminiCommand(args: string[]): string { - return formatShellCommand([resolveGeminiBinary(), ...args]); -} - -function isPlanModeUnavailable(error: unknown): boolean { - const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); - return message.includes("approval mode \"plan\" is only available") - || message.includes("experimental.plan is enabled"); -} - -function getRecordSessionId(record: GeminiJsonRecord, fallbackSessionId: string): string { - return typeof record.session_id === "string" && record.session_id.trim() - ? record.session_id - : fallbackSessionId; -} - -function publishGeminiRecordAsSessionEvents(record: GeminiJsonRecord, fallbackSessionId: string): void { - const sessionId = getRecordSessionId(record, fallbackSessionId); - const rawType = typeof record.type === "string" && record.type.trim() - ? record.type.trim() - : "unknown"; - const eventPayload = { - type: `gemini.raw.${rawType}`, - properties: { - record, - recordType: rawType, - role: typeof record.role === "string" ? record.role : undefined, - }, - }; - runtime.publishSessionEvent(sessionId, eventPayload); - if (sessionId !== fallbackSessionId) { - runtime.publishSessionEvent(fallbackSessionId, eventPayload); - } -} - -function parseGeminiResponse(output: string): { - text: string; - sessionId?: string; -} { - const lines = output - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - - const assistantChunks: string[] = []; - let resultText = ""; - let sessionId: string | undefined; - let errorMessage: string | undefined; - - for (const line of lines) { - try { - const record = JSON.parse(line) as GeminiJsonRecord; - if (typeof record.session_id === "string" && record.session_id.trim()) { - sessionId = record.session_id; - } - - if (record.type === "error") { - errorMessage = record.error?.message || "Gemini returned an error"; - } - - if (record.type === "message" && record.role === "assistant" && typeof record.content === "string") { - assistantChunks.push(record.content); - } - - if (record.type === "result") { - if (record.status === "error") { - errorMessage = record.error?.message || "Gemini returned an error"; - } - if (typeof record.result === "string" && record.result.trim()) { - resultText = record.result.trim(); - } - } - } catch { - // ignore non-json lines - } - } - - if (errorMessage) { - throw new Error(errorMessage); - } - - const text = (resultText || assistantChunks.join("")).trim(); - if (!text) { - throw new Error("Gemini returned empty response"); - } - - return { text, sessionId }; -} - -export async function sendMessage( - channelId: string, - sessionId: string, - message: string, - workingPath: string, - options?: OpenCodeOptions, - context?: OpenCodeMessageContext -): Promise { - const sessionKey = `${channelId}:${sessionId}`; - const entry = runtime.beginRequest(sessionKey); - - try { - return await runtime.withSessionLock(sessionKey, async () => { - const agent = options?.agent; - const approvalMode = resolveGeminiApprovalMode(agent); - const model = options?.model?.modelID; - const parts = buildPromptParts(channelId, message, { ...options, agent }, context); - const prompt = buildPromptText(parts); - const systemPrompt = buildSystemPrompt(context?.slack); - const geminiPrompt = buildSystemWrappedPrompt(systemPrompt, prompt); - const isNewSession = newSessions.has(sessionId); - const envOverrides = runtime.getSessionEnvironment(sessionId); - - const run = async (forceApprovalMode?: "plan"): Promise => { - const args = buildGeminiCommandArgs({ - sessionId, - isNewSession, - prompt: geminiPrompt, - approvalMode: forceApprovalMode, - model, - }); - const command = buildGeminiCommand(args); - log.info("Running Gemini CLI", { - cwd: workingPath, - command, - isNewSession, - approvalMode: forceApprovalMode ?? "yolo", - }); - - try { - return await runCliJsonCommand({ - providerName: "Gemini", - binary: resolveGeminiBinary(), - args, - cwd: workingPath, - env: { - GEMINI_CLI_TRUST_WORKSPACE: "true", - ...envOverrides, - }, - entry, - timeoutMs: resolveGeminiCliTimeoutMs(), - onRecord: (record) => { - publishGeminiRecordAsSessionEvents(record, sessionId); - }, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - publishGeminiRecordAsSessionEvents({ - type: "error", - error: { - message, - }, - }, sessionId); - throw error; - } - }; - - let output = ""; - try { - output = await run(approvalMode); - } catch (error) { - if (approvalMode === "plan" && isPlanModeUnavailable(error)) { - log.warn("Gemini plan approval mode unavailable; retrying with default approval mode", { - sessionId, - error: error instanceof Error ? error.message : String(error), - }); - output = await run(undefined); - } else { - throw error; - } - } - - const parsed = parseGeminiResponse(output); - const responseSessionId = parsed.sessionId; - if (responseSessionId && responseSessionId !== sessionId && context?.slack?.threadId) { - runtime.setSessionEnvironment(responseSessionId, envOverrides); - setThreadSessionId(channelId, context.slack.threadId, responseSessionId); - } - - newSessions.delete(sessionId); - if (responseSessionId) { - newSessions.delete(responseSessionId); - } - - return [{ text: parsed.text, messageType: "assistant" }]; - }); - } finally { - runtime.endRequest(sessionKey); - } -} - -export const ensureSession = runtime.ensureSession.bind(runtime); - -export const subscribeToSession = runtime.subscribeToSession.bind(runtime); - -export const abortSession = runtime.abortSession.bind(runtime); - -export const cancelActiveRequest = runtime.cancelActiveRequest.bind(runtime); - -export const stopServer = runtime.stopServer.bind(runtime); -export const startServer = noopStartServer; diff --git a/packages/agents/gemini/index.ts b/packages/agents/gemini/index.ts deleted file mode 100644 index 5cbc7c96..00000000 --- a/packages/agents/gemini/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { - createSession, - getOrCreateSession, - sendMessage, - cancelActiveRequest, - abortSession, - ensureSession, - subscribeToSession, - startServer, - stopServer, - type SessionEnvironment, -} from "./client"; diff --git a/packages/agents/gemini/session-state.ts b/packages/agents/gemini/session-state.ts deleted file mode 100644 index 1e6e774b..00000000 --- a/packages/agents/gemini/session-state.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { SessionMessageState, SessionTool } from "@/utils/session-inspector"; -import { extractPrefixedRecord, updateTool } from "@/agents/session-state/shared"; - -export type GeminiRawRecord = { - type?: string; - role?: string; - content?: string; - delta?: boolean; - model?: string; - tool_name?: string; - tool_id?: string; - parameters?: Record; - status?: string; - output?: string; - error?: { - type?: string; - message?: string; - }; -}; - -export function extractGeminiRecord( - type: string, - eventData: Record, - eventProps: Record -): GeminiRawRecord | null { - return extractPrefixedRecord(type, "gemini.raw.", eventData, eventProps); -} - -export function applyGeminiRecordToState( - state: SessionMessageState, - record: GeminiRawRecord, - toolById: Map -): void { - const recordType = typeof record.type === "string" ? record.type.trim().toLowerCase() : ""; - - if (recordType === "init") { - state.phaseStatus = "Thinking"; - return; - } - - if (recordType === "message") { - const role = typeof record.role === "string" ? record.role.trim().toLowerCase() : ""; - if (role !== "assistant") return; - const content = typeof record.content === "string" ? record.content : ""; - if (!content) return; - state.currentText = record.delta ? `${state.currentText}${content}` : content; - state.phaseStatus = "Drafting response"; - return; - } - - if (recordType === "tool_use") { - const toolId = typeof record.tool_id === "string" && record.tool_id.trim() - ? record.tool_id - : `gemini-tool-${Date.now()}`; - const toolName = typeof record.tool_name === "string" && record.tool_name.trim() - ? record.tool_name - : "tool"; - const existing = toolById.get(toolId); - const tool: SessionTool = { - id: toolId, - name: toolName, - status: "running", - input: record.parameters ?? existing?.input, - output: existing?.output, - error: existing?.error, - }; - toolById.set(toolId, tool); - updateTool(state, tool); - state.phaseStatus = `Running tool: ${toolName}`; - return; - } - - if (recordType === "tool_result") { - const toolId = typeof record.tool_id === "string" && record.tool_id.trim() ? record.tool_id : ""; - if (!toolId) return; - const existing = toolById.get(toolId); - if (!existing) return; - const isError = record.status === "error"; - const output = typeof record.output === "string" ? record.output : existing.output; - const error = isError ? (record.error?.message || output || "Tool failed") : undefined; - const updated: SessionTool = { - ...existing, - status: isError ? "error" : "completed", - output, - error, - }; - toolById.set(toolId, updated); - updateTool(state, updated); - state.phaseStatus = `${isError ? "Tool failed" : "Finished tool"}: ${updated.name}`; - return; - } - - if (recordType === "result") { - state.phaseStatus = record.status === "error" ? "Gemini reported an error" : "Finalizing response"; - return; - } - - if (recordType === "error") { - state.phaseStatus = record.error?.message - ? `Gemini error: ${record.error.message}` - : "Gemini reported an error"; - } -} diff --git a/packages/agents/goose/client.ts b/packages/agents/goose/client.ts index fd350fd0..ac585cca 100644 --- a/packages/agents/goose/client.ts +++ b/packages/agents/goose/client.ts @@ -1,4 +1,5 @@ -import { setThreadSessionId } from "@/config/local/sessions"; +import { setThreadSessionId, updateThreadSessionBinding } from "@/config/local/sessions"; +import { LEGACY_AGENT_CAPABILITIES } from "@/shared/agent-protocol"; import { BoundedSet, log } from "@/utils"; import { buildPromptParts, buildPromptText, buildSystemPrompt, buildSystemWrappedPrompt } from "../shared"; import { @@ -9,7 +10,15 @@ import { type SessionEnvironment as RuntimeSessionEnvironment, } from "../runtime/base"; import { createCliThreadSessionManager } from "../runtime/cli-session"; +import { inspectCliProtocol } from "../runtime/protocol-drift"; +import { + cancelAcpSession, + prependSystemPrompt, + sendMessageViaAcp, + stopAcpProvider, +} from "../runtime/acp-client"; import type { + AgentInput, OpenCodeMessage, OpenCodeMessageContext, OpenCodeOptions, @@ -44,6 +53,7 @@ const runtime = new CliAgentRuntime("Goose"); /** See note in claude/client.ts — FIFO-bounded so abandoned sessions don't leak. */ const NEW_SESSIONS_MAX_ENTRIES = 1000; const newSessions = new BoundedSet(NEW_SESSIONS_MAX_ENTRIES); +const GOOSE_RECORD_TYPES = ["complete", "message", "assistant", "user", "result", "stream_event"]; export const { createSession, getOrCreateSession } = createCliThreadSessionManager({ providerId: "goose", providerName: "Goose", @@ -93,12 +103,20 @@ function publishGooseRecordAsSessionEvents(record: GooseJsonRecord, fallbackSess : typeof record.role === "string" && record.role.trim() ? record.role.trim() : "unknown"; + const streamEventType = typeof record.event?.type === "string" ? record.event.type : undefined; const eventPayload = { type: `goose.raw.${rawType}`, properties: { record, recordType: rawType, - streamEventType: typeof record.event?.type === "string" ? record.event.type : undefined, + streamEventType, + ...inspectCliProtocol({ + providerName: "Goose", + recordType: rawType, + streamEventType, + knownRecordTypes: GOOSE_RECORD_TYPES, + anthropicStyleStream: true, + }), }, }; runtime.publishSessionEvent(sessionId, eventPayload); @@ -199,10 +217,10 @@ export function parseGooseResponse(output: string): { return { text, sessionId }; } -export async function sendMessage( +async function sendMessageViaCli( channelId: string, sessionId: string, - message: string, + input: AgentInput, workingPath: string, options?: OpenCodeOptions, context?: OpenCodeMessageContext @@ -213,7 +231,7 @@ export async function sendMessage( try { return await runtime.withSessionLock(sessionKey, async () => { const agent = options?.agent; - const parts = buildPromptParts(channelId, message, { ...options, agent }, context); + const parts = buildPromptParts(channelId, input, { ...options, agent }, context); const prompt = buildPromptText(parts); const systemPrompt = buildSystemPrompt(context?.slack); const goosePrompt = buildSystemWrappedPrompt(systemPrompt, prompt); @@ -267,13 +285,80 @@ export async function sendMessage( } } +export async function sendMessage( + channelId: string, + sessionId: string, + input: AgentInput, + workingPath: string, + options?: OpenCodeOptions, + context?: OpenCodeMessageContext +): Promise { + const agent = options?.agent; + const promptParts = buildPromptParts(channelId, input, { ...options, agent }, context); + const systemPrompt = buildSystemPrompt(context?.slack); + const environment = runtime.getSessionEnvironment(sessionId); + + return sendMessageViaAcp({ + providerId: "goose", + providerName: "Goose", + launch: { command: resolveGooseBinary(), args: ["acp"] }, + channelId, + sessionId, + isNewSession: newSessions.has(sessionId), + workingPath, + environment, + parts: prependSystemPrompt(promptParts, systemPrompt), + options, + publisher: runtime, + onNativeSessionId: (nativeSessionId) => { + runtime.setSessionEnvironment(nativeSessionId, environment); + newSessions.delete(sessionId); + newSessions.delete(nativeSessionId); + if (nativeSessionId !== sessionId && context?.slack?.threadId) { + setThreadSessionId(channelId, context.slack.threadId, nativeSessionId); + } + }, + onNegotiated: ({ protocolVersion, capabilities }) => { + if (context?.slack?.threadId) { + updateThreadSessionBinding(channelId, context.slack.threadId, { + transport: "acp", + protocolVersion, + capabilities, + }); + } + }, + onFallback: () => { + if (context?.slack?.threadId) { + updateThreadSessionBinding(channelId, context.slack.threadId, { + transport: "cli-json", + protocolVersion: undefined, + capabilities: LEGACY_AGENT_CAPABILITIES, + }); + } + }, + fallback: () => sendMessageViaCli(channelId, sessionId, input, workingPath, options, context), + }); +} + export const ensureSession = runtime.ensureSession.bind(runtime); export const subscribeToSession = runtime.subscribeToSession.bind(runtime); -export const abortSession = runtime.abortSession.bind(runtime); +export async function abortSession(sessionId: string): Promise { + await cancelAcpSession("goose", sessionId).catch(() => false); + await runtime.abortSession(sessionId); +} -export const cancelActiveRequest = runtime.cancelActiveRequest.bind(runtime); +export async function cancelActiveRequest(channelId: string, sessionId: string): Promise { + const [acpCancelled, cliCancelled] = await Promise.all([ + cancelAcpSession("goose", sessionId).catch(() => false), + runtime.cancelActiveRequest(channelId, sessionId), + ]); + return acpCancelled || cliCancelled; +} -export const stopServer = runtime.stopServer.bind(runtime); +export function stopServer(): void { + stopAcpProvider("goose"); + runtime.stopServer(); +} export const startServer = noopStartServer; diff --git a/packages/agents/index.ts b/packages/agents/index.ts index 7a3b319e..f857ed53 100644 --- a/packages/agents/index.ts +++ b/packages/agents/index.ts @@ -6,6 +6,11 @@ export type { OpenCodeMessageContext, OpenCodeOptions, OpenCodeSessionInfo, + AgentInput, + AgentInputPart, + AgentCapabilities, + AgentSessionBinding, + AgentTransport, } from "./types"; const agent = getSelectedAgentProvider(); diff --git a/packages/agents/kilo/client.ts b/packages/agents/kilo/client.ts index dfaf1a63..dfbda067 100644 --- a/packages/agents/kilo/client.ts +++ b/packages/agents/kilo/client.ts @@ -1,4 +1,5 @@ -import { setThreadSessionId } from "@/config/local/sessions"; +import { setThreadSessionId, updateThreadSessionBinding } from "@/config/local/sessions"; +import { LEGACY_AGENT_CAPABILITIES } from "@/shared/agent-protocol"; import { BoundedSet, log } from "@/utils"; import { buildPromptParts, buildPromptText, buildSystemPrompt, buildSystemWrappedPrompt } from "../shared"; import { @@ -9,7 +10,15 @@ import { type SessionEnvironment as RuntimeSessionEnvironment, } from "../runtime/base"; import { createCliThreadSessionManager } from "../runtime/cli-session"; +import { inspectCliProtocol } from "../runtime/protocol-drift"; +import { + cancelAcpSession, + prependSystemPrompt, + sendMessageViaAcp, + stopAcpProvider, +} from "../runtime/acp-client"; import type { + AgentInput, OpenCodeMessage, OpenCodeMessageContext, OpenCodeOptions, @@ -81,6 +90,17 @@ const runtime = new CliAgentRuntime("Kilo"); /** See note in claude/client.ts — FIFO-bounded so abandoned sessions don't leak. */ const NEW_SESSIONS_MAX_ENTRIES = 1000; const newSessions = new BoundedSet(NEW_SESSIONS_MAX_ENTRIES); +const KILO_RECORD_TYPES = [ + "text", + "tool_use", + "step_start", + "step_finish", + "assistant", + "user", + "tool", + "result", + "stream_event", +]; const kiloSessionPrefix = "ses_"; function resolveKiloBinary(): string { @@ -122,7 +142,6 @@ export function buildKiloCommandArgs(params: { }): string[] { const args = [ "run", - "--auto", "--format", "json", ]; @@ -168,12 +187,20 @@ function publishKiloRecordAsSessionEvents(record: KiloJsonRecord, fallbackSessio : typeof record.role === "string" && record.role.trim() ? record.role.trim() : "unknown"; + const streamEventType = typeof record.event?.type === "string" ? record.event.type : undefined; const eventPayload = { type: `kilo.raw.${rawType}`, properties: { record, recordType: rawType, - streamEventType: typeof record.event?.type === "string" ? record.event.type : undefined, + streamEventType, + ...inspectCliProtocol({ + providerName: "Kilo", + recordType: rawType, + streamEventType, + knownRecordTypes: KILO_RECORD_TYPES, + anthropicStyleStream: true, + }), }, }; runtime.publishSessionEvent(sessionId, eventPayload); @@ -308,10 +335,10 @@ export function extractKiloFinalResponse(output: string): string { return text || cleaned.trim(); } -export async function sendMessage( +async function sendMessageViaCli( channelId: string, sessionId: string, - message: string, + input: AgentInput, workingPath: string, options?: OpenCodeOptions, context?: OpenCodeMessageContext @@ -323,7 +350,7 @@ export async function sendMessage( return await runtime.withSessionLock(sessionKey, async () => { const agent = options?.agent; const isNewSession = newSessions.has(sessionId); - const parts = buildPromptParts(channelId, message, { ...options, agent }, context); + const parts = buildPromptParts(channelId, input, { ...options, agent }, context); const prompt = buildPromptText(parts); const systemPrompt = buildSystemPrompt(context?.slack); const kiloPrompt = buildSystemWrappedPrompt(systemPrompt, prompt); @@ -445,13 +472,80 @@ export async function sendMessage( } } +export async function sendMessage( + channelId: string, + sessionId: string, + input: AgentInput, + workingPath: string, + options?: OpenCodeOptions, + context?: OpenCodeMessageContext +): Promise { + const agent = options?.agent; + const promptParts = buildPromptParts(channelId, input, { ...options, agent }, context); + const systemPrompt = buildSystemPrompt(context?.slack); + const environment = runtime.getSessionEnvironment(sessionId); + + return sendMessageViaAcp({ + providerId: "kilo", + providerName: "Kilo", + launch: { command: resolveKiloBinary(), args: ["acp"] }, + channelId, + sessionId, + isNewSession: newSessions.has(sessionId), + workingPath, + environment, + parts: prependSystemPrompt(promptParts, systemPrompt), + options, + publisher: runtime, + onNativeSessionId: (nativeSessionId) => { + runtime.setSessionEnvironment(nativeSessionId, environment); + newSessions.delete(sessionId); + newSessions.delete(nativeSessionId); + if (nativeSessionId !== sessionId && context?.slack?.threadId) { + setThreadSessionId(channelId, context.slack.threadId, nativeSessionId); + } + }, + onNegotiated: ({ protocolVersion, capabilities }) => { + if (context?.slack?.threadId) { + updateThreadSessionBinding(channelId, context.slack.threadId, { + transport: "acp", + protocolVersion, + capabilities, + }); + } + }, + onFallback: () => { + if (context?.slack?.threadId) { + updateThreadSessionBinding(channelId, context.slack.threadId, { + transport: "cli-json", + protocolVersion: undefined, + capabilities: LEGACY_AGENT_CAPABILITIES, + }); + } + }, + fallback: () => sendMessageViaCli(channelId, sessionId, input, workingPath, options, context), + }); +} + export const ensureSession = runtime.ensureSession.bind(runtime); export const subscribeToSession = runtime.subscribeToSession.bind(runtime); -export const abortSession = runtime.abortSession.bind(runtime); +export async function abortSession(sessionId: string): Promise { + await cancelAcpSession("kilo", sessionId).catch(() => false); + await runtime.abortSession(sessionId); +} -export const cancelActiveRequest = runtime.cancelActiveRequest.bind(runtime); +export async function cancelActiveRequest(channelId: string, sessionId: string): Promise { + const [acpCancelled, cliCancelled] = await Promise.all([ + cancelAcpSession("kilo", sessionId).catch(() => false), + runtime.cancelActiveRequest(channelId, sessionId), + ]); + return acpCancelled || cliCancelled; +} -export const stopServer = runtime.stopServer.bind(runtime); +export function stopServer(): void { + stopAcpProvider("kilo"); + runtime.stopServer(); +} export const startServer = noopStartServer; diff --git a/packages/agents/kimi/client.ts b/packages/agents/kimi/client.ts index fa874b8f..9c948e52 100644 --- a/packages/agents/kimi/client.ts +++ b/packages/agents/kimi/client.ts @@ -1,6 +1,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; -import { setThreadSessionId } from "@/config/local/sessions"; +import { setThreadSessionId, updateThreadSessionBinding } from "@/config/local/sessions"; +import { LEGACY_AGENT_CAPABILITIES } from "@/shared/agent-protocol"; import { BoundedSet } from "@/utils"; import { log } from "@/utils"; import { buildPromptParts, buildPromptText, buildSystemPrompt, buildSystemWrappedPrompt } from "../shared"; @@ -12,7 +13,15 @@ import { type SessionEnvironment as RuntimeSessionEnvironment, } from "../runtime/base"; import { createCliThreadSessionManager } from "../runtime/cli-session"; +import { inspectCliProtocol } from "../runtime/protocol-drift"; +import { + cancelAcpSession, + prependSystemPrompt, + sendMessageViaAcp, + stopAcpProvider, +} from "../runtime/acp-client"; import type { + AgentInput, OpenCodeMessage, OpenCodeMessageContext, OpenCodeOptions, @@ -31,6 +40,7 @@ type KimiJsonRecord = { const runtime = new CliAgentRuntime("Kimi"); const NEW_SESSIONS_MAX_ENTRIES = 1000; const newSessions = new BoundedSet(NEW_SESSIONS_MAX_ENTRIES); +const KIMI_RECORD_TYPES = ["assistant", "tool", "user", "system"]; export const { createSession, getOrCreateSession } = createCliThreadSessionManager({ providerId: "kimi", providerName: "Kimi", @@ -46,7 +56,7 @@ const KIMI_PLAN_SYSTEM_PROMPT = [ "- Return an implementation plan and risk notes.", ].join("\n"); -function buildKimiSystemPrompt(baseSystemPrompt: string, agent?: string): string { +export function buildKimiSystemPrompt(baseSystemPrompt: string, agent?: string): string { if (agent?.trim().toLowerCase() !== "plan") { return baseSystemPrompt; } @@ -81,6 +91,12 @@ function publishKimiEvent(sessionId: string, record: KimiJsonRecord): void { properties: { record, role, + recordType: role, + ...inspectCliProtocol({ + providerName: "Kimi", + recordType: role, + knownRecordTypes: KIMI_RECORD_TYPES, + }), }, }); } @@ -220,10 +236,10 @@ async function readLatestKimiSessionIdForWorkDir(workingPath: string, startedAtM return latest?.sessionId; } -export async function sendMessage( +async function sendMessageViaCli( channelId: string, sessionId: string, - message: string, + input: AgentInput, workingPath: string, options?: OpenCodeOptions, context?: OpenCodeMessageContext @@ -234,7 +250,7 @@ export async function sendMessage( try { return await runtime.withSessionLock(sessionKey, async () => { const agent = options?.agent; - const parts = buildPromptParts(channelId, message, { ...options, agent }, context); + const parts = buildPromptParts(channelId, input, { ...options, agent }, context); const prompt = buildPromptText(parts); const systemPrompt = buildKimiSystemPrompt(buildSystemPrompt(context?.slack), agent); const kimiPrompt = buildSystemWrappedPrompt(systemPrompt, prompt); @@ -284,13 +300,80 @@ export async function sendMessage( } } +export async function sendMessage( + channelId: string, + sessionId: string, + input: AgentInput, + workingPath: string, + options?: OpenCodeOptions, + context?: OpenCodeMessageContext +): Promise { + const agent = options?.agent; + const promptParts = buildPromptParts(channelId, input, { ...options, agent }, context); + const systemPrompt = buildKimiSystemPrompt(buildSystemPrompt(context?.slack), agent); + const environment = runtime.getSessionEnvironment(sessionId); + + return sendMessageViaAcp({ + providerId: "kimi", + providerName: "Kimi", + launch: { command: "kimi", args: ["acp"] }, + channelId, + sessionId, + isNewSession: newSessions.has(sessionId), + workingPath, + environment, + parts: prependSystemPrompt(promptParts, systemPrompt), + options, + publisher: runtime, + onNativeSessionId: (nativeSessionId) => { + runtime.setSessionEnvironment(nativeSessionId, environment); + newSessions.delete(sessionId); + newSessions.delete(nativeSessionId); + if (nativeSessionId !== sessionId && context?.slack?.threadId) { + setThreadSessionId(channelId, context.slack.threadId, nativeSessionId); + } + }, + onNegotiated: ({ protocolVersion, capabilities }) => { + if (context?.slack?.threadId) { + updateThreadSessionBinding(channelId, context.slack.threadId, { + transport: "acp", + protocolVersion, + capabilities, + }); + } + }, + onFallback: () => { + if (context?.slack?.threadId) { + updateThreadSessionBinding(channelId, context.slack.threadId, { + transport: "cli-json", + protocolVersion: undefined, + capabilities: LEGACY_AGENT_CAPABILITIES, + }); + } + }, + fallback: () => sendMessageViaCli(channelId, sessionId, input, workingPath, options, context), + }); +} + export const ensureSession = runtime.ensureSession.bind(runtime); export const subscribeToSession = runtime.subscribeToSession.bind(runtime); -export const abortSession = runtime.abortSession.bind(runtime); +export async function abortSession(sessionId: string): Promise { + await cancelAcpSession("kimi", sessionId).catch(() => false); + await runtime.abortSession(sessionId); +} -export const cancelActiveRequest = runtime.cancelActiveRequest.bind(runtime); +export async function cancelActiveRequest(channelId: string, sessionId: string): Promise { + const [acpCancelled, cliCancelled] = await Promise.all([ + cancelAcpSession("kimi", sessionId).catch(() => false), + runtime.cancelActiveRequest(channelId, sessionId), + ]); + return acpCancelled || cliCancelled; +} -export const stopServer = runtime.stopServer.bind(runtime); +export function stopServer(): void { + stopAcpProvider("kimi"); + runtime.stopServer(); +} export const startServer = noopStartServer; diff --git a/packages/agents/kiro/client.ts b/packages/agents/kiro/client.ts deleted file mode 100644 index fb3e7fae..00000000 --- a/packages/agents/kiro/client.ts +++ /dev/null @@ -1,405 +0,0 @@ -import { spawn, type ChildProcess } from "child_process"; -import { BoundedSet, log } from "@/utils"; -import { buildPromptParts, buildPromptText, buildSystemPrompt, buildSystemWrappedPrompt } from "../shared"; -import { - CliAgentRuntime, - formatShellCommand, - noopStartServer, - type SessionEnvironment as RuntimeSessionEnvironment, -} from "../runtime/base"; -import { createCliThreadSessionManager } from "../runtime/cli-session"; -import type { - OpenCodeMessage, - OpenCodeMessageContext, - OpenCodeOptions, -} from "../types"; - -export type SessionEnvironment = RuntimeSessionEnvironment; - -const runtime = new CliAgentRuntime("Kiro"); -/** See note in claude/client.ts — FIFO-bounded so abandoned sessions don't leak. */ -const NEW_SESSIONS_MAX_ENTRIES = 1000; -const newSessions = new BoundedSet(NEW_SESSIONS_MAX_ENTRIES); -export const { createSession, getOrCreateSession } = createCliThreadSessionManager({ - providerId: "kiro", - providerName: "Kiro", - runtime, - newSessions, -}); - -const TOOL_MARKER_PATTERN = /\(using tool:\s*([^\)]+)\)/i; -const READ_OPERATION_PATTERN = /Reading file:\s*(.+?),\s*from line\s*(\d+)\s*to\s*(\d+)/i; - -function resolveKiroBinary(): string { - if (typeof Bun !== "undefined") { - if (Bun.which("kiro-cli")) return "kiro-cli"; - if (Bun.which("kiro")) return "kiro"; - } - return "kiro-cli"; -} - -export function buildKiroCommandArgs(params: { - isNewSession: boolean; - prompt: string; - agent?: string; -}): string[] { - const args = [ - "chat", - "--no-interactive", - "--trust-all-tools", - ]; - if (!params.isNewSession) { - args.push("--resume"); - } - if (params.agent?.trim()) { - args.push("--agent", params.agent.trim()); - } - args.push(params.prompt); - return args; -} - -export function buildKiroCommand(binary: string, args: string[]): string { - return formatShellCommand([binary, ...args]); -} - -function publishKiroTextUpdate(sessionId: string, text: string): void { - runtime.publishSessionEvent(sessionId, { - type: "message.part.updated", - properties: { - part: { - id: "kiro-text", - type: "text", - text, - }, - }, - }); -} - -function publishKiroToolUpdate(params: { - sessionId: string; - id: string; - tool: string; - status: "pending" | "running" | "completed" | "error"; - title?: string; - input?: Record; - output?: string; - error?: string; -}): void { - runtime.publishSessionEvent(params.sessionId, { - type: "message.part.updated", - properties: { - part: { - id: params.id, - type: "tool", - tool: params.tool, - state: { - status: params.status, - ...(params.title ? { title: params.title } : {}), - ...(params.input ? { input: params.input } : {}), - ...(params.output ? { output: params.output } : {}), - ...(params.error ? { error: params.error } : {}), - }, - }, - }, - }); -} - -export function sanitizeKiroOutput(text: string): string { - return text - .replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "") - .replace(/\u001B[@-_]/g, "") - .replace(/\r/g, "\n") - .replace(/[\u0000-\u0008\u000B-\u001A\u001C-\u001F\u007F]/g, "") - .replace(/[ \t]+$/gm, ""); -} - -function normalizeToolName(value: string): string { - const raw = value.trim().toLowerCase(); - if (!raw) return "Unknown tool"; - if (raw === "fs_read" || raw === "read" || raw === "readfile") return "Read"; - if (raw === "grep" || raw === "search" || raw === "ripgrep") return "Grep"; - if (raw === "glob") return "Glob"; - if (raw === "shell" || raw === "bash" || raw === "command") return "Bash"; - if (raw === "code") return "Task"; - return raw.charAt(0).toUpperCase() + raw.slice(1); -} - -function isOperationalLine(line: string): boolean { - if (!line) return true; - if (TOOL_MARKER_PATTERN.test(line)) return true; - if (READ_OPERATION_PATTERN.test(line)) return true; - if (/^(↱|✓|\.|- Completed in|- Summary:|Summary:|Batch\s+.+operation)/i.test(line)) return true; - if (/^(Searching for:|Operation \d+:|Completed in \d|\[.*\]\s*\d+ bytes)/i.test(line)) return true; - return false; -} - -export function extractKiroFinalResponse(rawOutput: string): string { - const cleaned = sanitizeKiroOutput(rawOutput); - const lines = cleaned.split("\n").map((line) => line.trimEnd()); - const promptIndex = lines.findLastIndex((line) => line.trimStart().startsWith(">")); - const start = promptIndex >= 0 ? promptIndex : 0; - const candidate = lines.slice(start) - .filter((line) => !isOperationalLine(line.trim())) - .join("\n") - .trim(); - - if (!candidate) { - return cleaned.trim(); - } - - return candidate.replace(/^>\s?/, "").trim(); -} - -type KiroToolRuntimeState = { - id: string; - name: string; - title?: string; - input?: Record; -}; - -function createKiroStreamParser(sessionId: string) { - let toolCounter = 0; - let pending = ""; - let runningTool: KiroToolRuntimeState | null = null; - let latestAssistantLine = ""; - - const completeRunningTool = () => { - if (!runningTool) return; - publishKiroToolUpdate({ - sessionId, - id: runningTool.id, - tool: runningTool.name, - status: "completed", - title: runningTool.title, - input: runningTool.input, - }); - runningTool = null; - }; - - const startTool = (name: string, title: string, input?: Record) => { - completeRunningTool(); - const id = `kiro-tool-${++toolCounter}`; - const toolState: KiroToolRuntimeState = { - id, - name, - title, - ...(input ? { input } : {}), - }; - runningTool = toolState; - publishKiroToolUpdate({ - sessionId, - id, - tool: name, - status: "running", - title, - input, - }); - }; - - const pushLine = (rawLine: string) => { - const line = sanitizeKiroOutput(rawLine).trim(); - if (!line) return; - - const readMatch = line.match(READ_OPERATION_PATTERN); - if (readMatch) { - const filePath = readMatch[1]?.trim() ?? ""; - const fromLine = Number(readMatch[2] ?? 1); - const toLine = Number(readMatch[3] ?? fromLine); - const id = `kiro-tool-${++toolCounter}`; - publishKiroToolUpdate({ - sessionId, - id, - tool: "Read", - status: "completed", - title: `Read ${filePath}`, - input: { - filePath, - offset: Math.max(0, fromLine - 1), - limit: Math.max(1, toLine - fromLine + 1), - }, - }); - return; - } - - const toolMatch = line.match(TOOL_MARKER_PATTERN); - if (toolMatch) { - const rawTool = toolMatch[1] ?? ""; - const name = normalizeToolName(rawTool); - const detail = line.replace(TOOL_MARKER_PATTERN, "").trim(); - const input = name === "Grep" && detail.startsWith("Searching for:") - ? { pattern: detail.slice("Searching for:".length).trim() } - : undefined; - startTool(name, detail || `${name} operation`, input); - return; - } - - if (/completed in\s+\d/i.test(line) || /^summary:/i.test(line) || /^-\s+summary:/i.test(line)) { - completeRunningTool(); - return; - } - - if (line.startsWith(">")) { - latestAssistantLine = line.replace(/^>\s?/, "").trim(); - if (latestAssistantLine) { - publishKiroTextUpdate(sessionId, latestAssistantLine); - } - } - }; - - return { - pushChunk(chunk: string) { - pending += chunk; - const lines = pending.split("\n"); - pending = lines.pop() ?? ""; - for (const line of lines) { - pushLine(line); - } - }, - finalize(rawOutput: string): string { - if (pending.trim()) { - pushLine(pending); - } - completeRunningTool(); - return extractKiroFinalResponse(rawOutput); - }, - getLatestAssistantLine(): string { - return latestAssistantLine; - }, - }; -} - -async function runKiroCommand( - binary: string, - args: string[], - cwd: string, - env: SessionEnvironment, - entry: { controller: AbortController; process?: ChildProcess }, - onChunk?: (chunk: string) => void -): Promise { - return new Promise((resolve, reject) => { - const child = spawn(binary, args, { - cwd, - env: { ...process.env, ...env }, - signal: entry.controller.signal, - }); - - entry.process = child; - child.stdin?.end(); - - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - child.stdout?.on("data", (chunk) => { - const bufferChunk = Buffer.from(chunk); - stdoutChunks.push(bufferChunk); - onChunk?.(bufferChunk.toString("utf-8")); - }); - - child.stderr?.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); - - child.on("error", (err) => { - reject(err); - }); - - child.on("close", (code) => { - const stdout = Buffer.concat(stdoutChunks).toString("utf-8").trim(); - const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim(); - - log.info("Kiro CLI completed", { - code, - stdoutLength: stdout.length, - stderrLength: stderr.length, - }); - - if (code !== 0) { - reject(new Error(stderr || `Kiro CLI exited with code ${code}`)); - return; - } - - resolve(stdout); - }); - }); -} - -function parseKiroResponse(output: string): string { - const text = extractKiroFinalResponse(output); - if (!text) { - throw new Error("Kiro returned empty response"); - } - return text; -} - -export async function sendMessage( - channelId: string, - sessionId: string, - message: string, - workingPath: string, - options?: OpenCodeOptions, - context?: OpenCodeMessageContext -): Promise { - const sessionKey = `${channelId}:${sessionId}`; - const entry = runtime.beginRequest(sessionKey) as { controller: AbortController; process?: ChildProcess }; - - try { - return await runtime.withSessionLock(sessionKey, async () => { - const parts = buildPromptParts(channelId, message, options, context); - const prompt = buildPromptText(parts); - const systemPrompt = buildSystemPrompt(context?.slack); - const kiroPrompt = buildSystemWrappedPrompt(systemPrompt, prompt); - - const envOverrides = runtime.getSessionEnvironment(sessionId); - const binary = resolveKiroBinary(); - const args = buildKiroCommandArgs({ - isNewSession: newSessions.has(sessionId), - prompt: kiroPrompt, - agent: options?.agent, - }); - const command = buildKiroCommand(binary, args); - - runtime.publishSessionEvent(sessionId, { - type: "session.status", - properties: { - status: { - type: "busy", - }, - }, - }); - - log.info("Running Kiro CLI", { - cwd: workingPath, - command, - }); - - const parser = createKiroStreamParser(sessionId); - const output = await runKiroCommand(binary, args, workingPath, envOverrides, entry, (chunk) => { - parser.pushChunk(chunk); - }); - - const text = parser.finalize(output) || parseKiroResponse(output); - publishKiroTextUpdate(sessionId, text); - runtime.publishSessionEvent(sessionId, { - type: "session.status", - properties: { - status: { - type: "idle", - }, - }, - }); - newSessions.delete(sessionId); - return [{ text, messageType: "assistant" }]; - }); - } finally { - runtime.endRequest(sessionKey); - } -} - -export const ensureSession = runtime.ensureSession.bind(runtime); - -export const subscribeToSession = runtime.subscribeToSession.bind(runtime); - -export const abortSession = runtime.abortSession.bind(runtime); - -export const cancelActiveRequest = runtime.cancelActiveRequest.bind(runtime); - -export const stopServer = runtime.stopServer.bind(runtime); -export const startServer = noopStartServer; diff --git a/packages/agents/kiro/index.ts b/packages/agents/kiro/index.ts deleted file mode 100644 index 5cbc7c96..00000000 --- a/packages/agents/kiro/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { - createSession, - getOrCreateSession, - sendMessage, - cancelActiveRequest, - abortSession, - ensureSession, - subscribeToSession, - startServer, - stopServer, - type SessionEnvironment, -} from "./client"; diff --git a/packages/agents/kiro/session-state.ts b/packages/agents/kiro/session-state.ts deleted file mode 100644 index cd405088..00000000 --- a/packages/agents/kiro/session-state.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { SessionMessageState, SessionTodo } from "@/utils/session-inspector"; - -export type KiroTaskRecord = { - id: string; - status: string; - title?: string; -}; - -function normalizeTaskStatus(status: string): SessionTodo["status"] { - if (status === "completed") return "completed"; - if (status === "error") return "cancelled"; - return "in_progress"; -} - -export function extractKiroRecord( - type: string, - eventData: Record, - eventProps: Record -): KiroTaskRecord | null { - if (type !== "message.part.updated") return null; - const part = (eventProps.part ?? eventData.part) as Record | undefined; - if (!part || part.type !== "tool") return null; - const toolName = typeof part.tool === "string" ? part.tool.trim().toLowerCase() : ""; - if (toolName !== "task") return null; - const toolState = (part.state ?? {}) as Record; - const id = typeof part.id === "string" && part.id.trim() ? part.id.trim() : "kiro-task"; - const status = typeof toolState.status === "string" ? toolState.status.trim().toLowerCase() : ""; - const title = typeof toolState.title === "string" ? toolState.title.trim() : undefined; - return { - id, - status, - title, - }; -} - -export function applyKiroRecordToState( - state: SessionMessageState, - record: KiroTaskRecord, - todoById: Map -): void { - todoById.set(record.id, { - content: record.title || "Task", - status: normalizeTaskStatus(record.status), - }); - state.todos = Array.from(todoById.values()); -} diff --git a/packages/agents/opencode/client.ts b/packages/agents/opencode/client.ts index b38854df..4c5ec411 100644 --- a/packages/agents/opencode/client.ts +++ b/packages/agents/opencode/client.ts @@ -3,9 +3,15 @@ import { getSessionClient, ensureValidSession, getSessionEnvironment, + getSessionRuntimeSnapshot, getSessionServerUrl, type SessionEnvironment, } from "./server"; +import { + DEFAULT_OPENCODE_IDLE_TIMEOUT_MS, + monitorOpenCodePrompt, +} from "./prompt-monitor"; +import { pathToFileURL } from "node:url"; import { setThreadSessionId, updateActiveRequest, @@ -16,6 +22,7 @@ import { buildPromptParts, buildSystemPrompt } from "../shared"; import { ServerAgentRuntime, formatShellCommand } from "../runtime/base"; import { getOrCreateThreadSession } from "../runtime/thread-session"; import type { + AgentInput, OpenCodeMessage, OpenCodeMessageContext, OpenCodeOptions, @@ -24,6 +31,14 @@ import type { const runtime = new ServerAgentRuntime(); +function getOpenCodeIdleTimeoutMs(): number | null { + const raw = process.env.ODE_OPENCODE_IDLE_TIMEOUT_MS?.trim(); + if (!raw) return DEFAULT_OPENCODE_IDLE_TIMEOUT_MS; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_OPENCODE_IDLE_TIMEOUT_MS; + return parsed === 0 ? null : Math.floor(parsed); +} + export function buildOpenCodeCommand( url: string, sessionId: string, @@ -205,7 +220,7 @@ export async function getOrCreateSession( export async function sendMessage( channelId: string, sessionId: string, - message: string, + input: AgentInput, workingPath: string, options?: OpenCodeOptions, context?: OpenCodeMessageContext @@ -260,7 +275,15 @@ export async function sendMessage( : undefined); // Build message parts - const parts = buildPromptParts(channelId, message, { ...options, agent }, context); + const parts = buildPromptParts(channelId, input, { ...options, agent }, context).map((part) => { + if (part.type === "text") return part; + return { + type: "file" as const, + mime: part.mimeType, + filename: part.filename, + url: pathToFileURL(part.path).href, + }; + }); // Build system prompt with Slack context const system = buildSystemPrompt(context?.slack); @@ -278,10 +301,54 @@ export async function sendMessage( log.debug("Sending message via SDK", { sessionId: activeSessionId, agent, model, command }); - const result = await client.session.prompt({ + const promptParameters = { sessionID: activeSessionId, ...payload, + }; + const promptAbortController = new AbortController(); + const promptPromise = client.session.prompt(promptParameters, { + signal: promptAbortController.signal, }); + const idleTimeoutMs = getOpenCodeIdleTimeoutMs(); + const result = idleTimeoutMs === null + ? await promptPromise + : await monitorOpenCodePrompt({ + prompt: promptPromise, + timeoutMs: idleTimeoutMs, + readHealth: async () => { + const snapshot = getSessionRuntimeSnapshot(activeSessionId); + if (!snapshot) return null; + try { + const statusResult = await client.session.status({ directory: workingPath }); + if (statusResult.error || !statusResult.data) return null; + return { + relatedSessionIds: snapshot.relatedSessionIds, + lastMeaningfulEventAt: snapshot.lastMeaningfulEventAt, + awaitingInteraction: snapshot.awaitingInteraction, + statuses: statusResult.data as Record, + }; + } catch (error) { + // A transient health-check failure must not cancel a prompt + // that may still be progressing. The next poll can retry. + log.debug("OpenCode prompt health check failed", { + sessionId: activeSessionId, + error: String(error), + }); + return null; + } + }, + abort: async () => { + const snapshot = getSessionRuntimeSnapshot(activeSessionId); + const sessionIds = snapshot?.relatedSessionIds ?? [activeSessionId]; + await Promise.allSettled(sessionIds.map((relatedSessionId) => + client.session.abort({ + sessionID: relatedSessionId, + directory: workingPath, + }) + )); + promptAbortController.abort(); + }, + }); log.debug("OpenCode SDK response received", { hasData: !!result.data, diff --git a/packages/agents/opencode/events.ts b/packages/agents/opencode/events.ts new file mode 100644 index 00000000..1cad173b --- /dev/null +++ b/packages/agents/opencode/events.ts @@ -0,0 +1,288 @@ +import { extractEventSessionId } from "@/utils/session-id"; + +type UnknownRecord = Record; + +export type OpenCodeEventContext = { + rootSessionID: string; + sourceSessionID?: string; + childSession: boolean; + childTitle?: string; + transportType: "event" | "sync"; + syncSequence?: number; +}; + +export type NormalizedOpenCodeGlobalEvent = { + directory?: string; + payload: UnknownRecord; +}; + +export type OpenCodeChildSession = { + sessionId: string; + parentSessionId?: string; + title?: string; +}; + +export type OpenCodePermissionReply = "once" | "always" | "reject"; + +function asRecord(value: unknown): UnknownRecord | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? value as UnknownRecord + : undefined; +} + +function asNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function compactPermissionValue(value: unknown, maxLength = 240): string | undefined { + if (value === undefined || value === null) return undefined; + let text: string; + if (typeof value === "string") { + text = value; + } else { + try { + text = JSON.stringify(value); + } catch { + text = String(value); + } + } + const compact = text.replace(/\s+/g, " ").trim(); + if (!compact) return undefined; + return compact.length > maxLength ? `${compact.slice(0, maxLength - 3)}...` : compact; +} + +export function normalizeOpenCodePermissionQuestion(event: unknown): UnknownRecord | null { + const record = asRecord(event); + if (record?.type !== "permission.asked") return null; + const properties = asRecord(record.properties); + const requestId = asNonEmptyString(properties?.id); + const sessionID = asNonEmptyString(properties?.sessionID); + if (!properties || !requestId || !sessionID) return null; + const permission = asNonEmptyString(properties.permission) ?? "use a protected capability"; + const patterns = Array.isArray(properties.patterns) + ? properties.patterns.map(compactPermissionValue).filter((value): value is string => Boolean(value)) + : []; + const metadata = compactPermissionValue(properties.metadata); + const detail = [ + patterns.length > 0 ? `Targets: ${patterns.join(", ")}` : undefined, + metadata && metadata !== "{}" ? `Details: ${metadata}` : undefined, + ].filter(Boolean).join("\n"); + return { + type: "question.asked", + properties: { + id: requestId, + sessionID, + questions: [{ + header: "Permission", + question: `OpenCode wants permission to ${permission}.${detail ? `\n${detail}` : ""}`, + options: [ + { label: "Allow once" }, + { label: "Always allow" }, + { label: "Reject" }, + ], + multiple: false, + custom: false, + }], + odeContext: properties.odeContext, + }, + odeContext: record.odeContext, + }; +} + +export function parseOpenCodePermissionReply(answers: Array>): OpenCodePermissionReply { + const answer = answers.flat().map((value) => value.trim().toLowerCase()).find(Boolean) ?? ""; + if (answer === "allow once" || answer === "once") return "once"; + if (answer === "always allow" || answer === "allow always" || answer === "always") return "always"; + return "reject"; +} + +function stripSyncSchemaVersion(type: string): string { + return type.replace(/\.\d+$/, ""); +} + +function stringFingerprint(value: unknown): [number, string] | undefined { + if (typeof value !== "string") return undefined; + return [value.length, value.slice(-96)]; +} + +/** + * OpenCode's global SSE stream contains both ordinary events and durable + * `syncEvent` envelopes. Normalize the latter to the same `{type,properties}` + * shape consumed by Ode's provider-neutral event parser. + */ +export function normalizeOpenCodeGlobalEvent( + globalEvent: unknown, + params: { + rootSessionId: string; + childTitle?: (sessionId: string) => string | undefined; + } +): NormalizedOpenCodeGlobalEvent | null { + const wrapper = asRecord(globalEvent); + if (!wrapper) return null; + const rawPayload = asRecord(wrapper.payload) ?? wrapper; + const rawType = asNonEmptyString(rawPayload.type); + if (!rawType) return null; + + let payload: UnknownRecord = rawPayload; + let transportType: OpenCodeEventContext["transportType"] = "event"; + let syncSequence: number | undefined; + let aggregateSessionId: string | undefined; + + if (rawType === "sync") { + const syncEvent = asRecord(rawPayload.syncEvent); + const syncType = asNonEmptyString(syncEvent?.type); + const syncData = asRecord(syncEvent?.data); + if (!syncEvent || !syncType || !syncData) return null; + transportType = "sync"; + syncSequence = typeof syncEvent.seq === "number" ? syncEvent.seq : undefined; + aggregateSessionId = asNonEmptyString(syncEvent.aggregateID); + payload = { + id: asNonEmptyString(syncEvent.id) ?? asNonEmptyString(rawPayload.id), + type: stripSyncSchemaVersion(syncType), + properties: syncData, + }; + } + + const sourceSessionID = extractEventSessionId(payload) ?? aggregateSessionId; + const context: OpenCodeEventContext = { + rootSessionID: params.rootSessionId, + sourceSessionID, + childSession: Boolean(sourceSessionID && sourceSessionID !== params.rootSessionId), + childTitle: sourceSessionID ? params.childTitle?.(sourceSessionID) : undefined, + transportType, + syncSequence, + }; + + const properties = asRecord(payload.properties); + payload = { + ...payload, + ...(properties ? { properties: { ...properties, odeContext: context } } : {}), + odeContext: context, + }; + return { + directory: asNonEmptyString(wrapper.directory), + payload, + }; +} + +export function getOpenCodeEventContext(event: unknown): OpenCodeEventContext | undefined { + const record = asRecord(event); + const context = asRecord(record?.odeContext); + const rootSessionID = asNonEmptyString(context?.rootSessionID); + if (!rootSessionID) return undefined; + return { + rootSessionID, + sourceSessionID: asNonEmptyString(context?.sourceSessionID), + childSession: context?.childSession === true, + childTitle: asNonEmptyString(context?.childTitle), + transportType: context?.transportType === "sync" ? "sync" : "event", + syncSequence: typeof context?.syncSequence === "number" ? context.syncSequence : undefined, + }; +} + +export function extractOpenCodeChildSession(event: unknown): OpenCodeChildSession | null { + const record = asRecord(event); + if (!record) return null; + const properties = asRecord(record.properties) ?? {}; + const part = asRecord(properties.part); + const state = asRecord(part?.state); + const metadata = asRecord(state?.metadata); + const tool = asNonEmptyString(part?.tool)?.toLowerCase(); + + if (part?.type === "tool" && (tool === "task" || tool === "subtask" || tool === "subagent")) { + const sessionId = asNonEmptyString(metadata?.sessionId) + ?? asNonEmptyString(metadata?.sessionID) + ?? asNonEmptyString(metadata?.session_id); + if (sessionId) { + return { + sessionId, + parentSessionId: asNonEmptyString(metadata?.parentSessionId) + ?? asNonEmptyString(metadata?.parentSessionID), + title: asNonEmptyString(state?.title) + ?? asNonEmptyString((state?.input as UnknownRecord | undefined)?.description), + }; + } + } + + if (record.type === "session.created" || record.type === "session.updated") { + const info = asRecord(properties.info) ?? asRecord(properties.session) ?? properties; + const sessionId = asNonEmptyString(info.id) ?? asNonEmptyString(info.sessionID); + const parentSessionId = asNonEmptyString(info.parentID) ?? asNonEmptyString(info.parentId); + if (sessionId && parentSessionId) { + return { + sessionId, + parentSessionId, + title: asNonEmptyString(info.title), + }; + } + } + + return null; +} + +export function isMeaningfulOpenCodeEvent(event: unknown): boolean { + const record = asRecord(event); + const type = asNonEmptyString(record?.type); + if (!type) return false; + return ![ + "server.connected", + "server.heartbeat", + "plugin.added", + "catalog.updated", + "reference.updated", + "integration.updated", + "project.directories.updated", + "file.watcher.updated", + ].includes(type); +} + +export function getOpenCodeEventFingerprint(event: unknown): string | null { + const record = asRecord(event); + const type = asNonEmptyString(record?.type); + if (!record || !type) return null; + const properties = asRecord(record.properties) ?? {}; + const context = getOpenCodeEventContext(record); + const source = context?.sourceSessionID ?? extractEventSessionId(record) ?? "global"; + + if (type === "message.part.updated") { + const part = asRecord(properties.part); + const state = asRecord(part?.state); + const time = asRecord(state?.time) ?? asRecord(part?.time); + const id = asNonEmptyString(part?.id); + if (!id) return null; + return JSON.stringify([ + source, + type, + id, + part?.type, + stringFingerprint(part?.text), + stringFingerprint(part?.snapshot), + state?.status, + state?.title, + stringFingerprint(state?.output), + time?.start, + time?.end, + ]); + } + + if (type === "message.updated") { + const info = asRecord(properties.info) ?? asRecord(properties.message); + const id = asNonEmptyString(info?.id); + if (!id) return null; + return JSON.stringify([ + source, + type, + id, + info?.finish, + info?.error, + info?.time, + info?.tokens, + ]); + } + + if (type === "session.status" || type === "todo.updated") { + return JSON.stringify([source, type, properties]); + } + + return null; +} diff --git a/packages/agents/opencode/index.ts b/packages/agents/opencode/index.ts index 67d42694..14f41492 100644 --- a/packages/agents/opencode/index.ts +++ b/packages/agents/opencode/index.ts @@ -4,12 +4,15 @@ export { isServerReady, createSessionInstance, getSessionClient, + replyToOpenCodePermission, + getSessionRuntimeSnapshot, getAnyServerUrl, ensureSession, ensureValidSession, stopAllSessions, subscribeToSession, type EventHandler, + type OpenCodeSessionRuntimeSnapshot, } from "./server"; export { diff --git a/packages/agents/opencode/prompt-monitor.ts b/packages/agents/opencode/prompt-monitor.ts new file mode 100644 index 00000000..8922d51b --- /dev/null +++ b/packages/agents/opencode/prompt-monitor.ts @@ -0,0 +1,70 @@ +export const DEFAULT_OPENCODE_IDLE_TIMEOUT_MS = 60_000; +export const DEFAULT_OPENCODE_HEALTH_POLL_MS = 5_000; + +export class OpenCodeIdlePromptError extends Error { + constructor(timeoutMs: number) { + super( + `OpenCode stopped reporting an active session and produced no progress for ${Math.round(timeoutMs / 1000)}s` + ); + this.name = "OpenCodeIdlePromptError"; + } +} + +export type OpenCodePromptHealth = { + relatedSessionIds: readonly string[]; + lastMeaningfulEventAt: number; + awaitingInteraction: boolean; + statuses: Record; +}; + +function statusIsActive(value: unknown): boolean { + if (typeof value === "string") return value === "busy" || value === "retry"; + if (!value || typeof value !== "object") return false; + const type = (value as Record).type; + return type === "busy" || type === "retry"; +} + +export function isOpenCodePromptIdleTimedOut(params: { + health: OpenCodePromptHealth; + now: number; + timeoutMs: number; +}): boolean { + const { health, now, timeoutMs } = params; + if (health.awaitingInteraction) return false; + if (now - health.lastMeaningfulEventAt < timeoutMs) return false; + return !health.relatedSessionIds.some((sessionId) => statusIsActive(health.statuses[sessionId])); +} + +export async function monitorOpenCodePrompt(params: { + prompt: Promise; + readHealth: () => Promise; + abort: () => Promise; + timeoutMs?: number; + pollIntervalMs?: number; +}): Promise { + const timeoutMs = params.timeoutMs ?? DEFAULT_OPENCODE_IDLE_TIMEOUT_MS; + const pollIntervalMs = params.pollIntervalMs ?? DEFAULT_OPENCODE_HEALTH_POLL_MS; + let settled = false; + + const prompt = params.prompt.finally(() => { + settled = true; + }); + const monitor = (async (): Promise => { + while (!settled) { + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + if (settled) break; + const health = await params.readHealth(); + if (!health) continue; + if (!isOpenCodePromptIdleTimedOut({ health, now: Date.now(), timeoutMs })) continue; + await params.abort(); + throw new OpenCodeIdlePromptError(timeoutMs); + } + return await new Promise(() => {}); + })(); + + try { + return await Promise.race([prompt, monitor]); + } finally { + settled = true; + } +} diff --git a/packages/agents/opencode/server.ts b/packages/agents/opencode/server.ts index 4d007395..29c3663e 100644 --- a/packages/agents/opencode/server.ts +++ b/packages/agents/opencode/server.ts @@ -4,8 +4,16 @@ import { type EventPermissionAsked, } from "@opencode-ai/sdk/v2"; import { spawn, type ChildProcess } from "child_process"; -import { extractEventSessionId, log } from "@/utils"; +import { BoundedSet, extractEventSessionId, log } from "@/utils"; import { getOpenCodeModels, setOpenCodeModels } from "@/config"; +import { + extractOpenCodeChildSession, + getOpenCodeEventFingerprint, + isMeaningfulOpenCodeEvent, + normalizeOpenCodeGlobalEvent, + normalizeOpenCodePermissionQuestion, + parseOpenCodePermissionReply, +} from "./events"; // Per-session OpenCode instances export type SessionEnvironment = Record; @@ -14,12 +22,24 @@ interface SessionInstance { client: OpencodeClient; handlers: Set; lastActive: number; + lastMeaningfulEventAt: number; eventLoopRunning: boolean; + rootSessionId: string; validSessionIds: Set; // Sessions created in this instance + childTitles: Map; + awaitingInteractionSessionIds: Set; + seenEventFingerprints: BoundedSet; env: SessionEnvironment; baseUrl: string; } +export type OpenCodeSessionRuntimeSnapshot = { + rootSessionId: string; + relatedSessionIds: string[]; + lastMeaningfulEventAt: number; + awaitingInteraction: boolean; +}; + class OpenCodeServerRuntimeState { readonly sessionInstances = new Map(); readonly sessionStartPromises = new Map>(); @@ -33,6 +53,15 @@ class OpenCodeServerRuntimeState { const runtimeState = new OpenCodeServerRuntimeState(); +type PendingOpenCodePermission = { + session: SessionInstance; + subscriptionSessionId: string; + interactionSessionId: string; + directory?: string; +}; + +const pendingOpenCodePermissions = new Map(); + const LISTENING_URL_REGEX = /opencode server listening on\s+(https?:\/\/\S+)/i; function resolveServerUrl(): string { @@ -240,7 +269,10 @@ function ensureCleanupInterval(): void { runtimeState.cleanupInterval = setInterval(() => { const now = Date.now(); for (const [sessionId, session] of runtimeState.sessionInstances) { - if (now - session.lastActive > INACTIVE_TIMEOUT_MS) { + if ( + session.awaitingInteractionSessionIds.size === 0 + && now - session.lastActive > INACTIVE_TIMEOUT_MS + ) { log.debug("Cleaning up inactive session", { sessionId }); stopSessionInstance(sessionId); } @@ -282,8 +314,13 @@ async function getOrCreateSessionInstance( client, handlers: new Set(), lastActive: Date.now(), + lastMeaningfulEventAt: Date.now(), eventLoopRunning: false, + rootSessionId: sessionId, validSessionIds: new Set(), + childTitles: new Map(), + awaitingInteractionSessionIds: new Set(), + seenEventFingerprints: new BoundedSet(5_000), env, baseUrl, }; @@ -314,6 +351,9 @@ function stopSessionInstance(sessionId: string): void { session.eventLoopRunning = false; session.handlers.clear(); + for (const [requestId, pending] of pendingOpenCodePermissions) { + if (pending.session === session) pendingOpenCodePermissions.delete(requestId); + } runtimeState.sessionInstances.delete(sessionId); log.debug("Stopped OpenCode session state", { sessionId }); } @@ -331,48 +371,88 @@ function startSessionEventLoop(sessionId: string, session: SessionInstance): voi for await (const globalEvent of events.stream) { if (!session.eventLoopRunning) break; - const event = (globalEvent as any).payload ?? globalEvent; - const directory = (globalEvent as any).directory; + const normalizedGlobalEvent = normalizeOpenCodeGlobalEvent(globalEvent, { + rootSessionId: session.rootSessionId, + childTitle: (childSessionId) => session.childTitles.get(childSessionId), + }); + if (!normalizedGlobalEvent) continue; + const event = normalizedGlobalEvent.payload; + const directory = normalizedGlobalEvent.directory; + + const child = extractOpenCodeChildSession(event); + if ( + child + && (!child.parentSessionId || session.validSessionIds.has(child.parentSessionId)) + ) { + session.validSessionIds.add(child.sessionId); + if (child.title) session.childTitles.set(child.sessionId, child.title); + } + const eventSessionId = extractEventSessionId(event as Record | undefined); if (eventSessionId && !session.validSessionIds.has(eventSessionId)) { continue; } + if (!eventSessionId && !isMeaningfulOpenCodeEvent(event)) { + continue; + } + + const fingerprint = getOpenCodeEventFingerprint(event); + if (fingerprint && session.seenEventFingerprints.has(fingerprint)) { + continue; + } + if (fingerprint) session.seenEventFingerprints.add(fingerprint); session.lastActive = Date.now(); + if (isMeaningfulOpenCodeEvent(event)) { + session.lastMeaningfulEventAt = session.lastActive; + } - // Handle permissions + const interactionSessionId = eventSessionId ?? session.rootSessionId; + if (event.type === "question.asked") { + session.awaitingInteractionSessionIds.add(interactionSessionId); + } else if ( + event.type === "question.replied" + || event.type === "question.rejected" + || event.type === "permission.replied" + ) { + session.awaitingInteractionSessionIds.delete(interactionSessionId); + if (event.type === "permission.replied") { + const properties = event.properties as { requestID?: unknown } | undefined; + if (typeof properties?.requestID === "string") { + pendingOpenCodePermissions.delete(properties.requestID); + } + } + } + + const eventsToDispatch = [normalizedGlobalEvent]; if (event.type === "permission.asked") { const permEvent = event as EventPermissionAsked; const requestId = permEvent.properties?.id; - if (requestId) { - log.debug("Auto-approving permission", { sessionId, requestId }); + const permissionQuestion = normalizeOpenCodePermissionQuestion(event); + if (requestId && permissionQuestion) { + pendingOpenCodePermissions.set(requestId, { + session, + subscriptionSessionId: sessionId, + interactionSessionId, + directory, + }); + session.awaitingInteractionSessionIds.add(interactionSessionId); + eventsToDispatch.push({ directory, payload: permissionQuestion }); + } + } + + for (const eventToDispatch of eventsToDispatch) { + for (const handler of session.handlers) { try { - await session.client.permission.reply({ - requestID: requestId, - reply: "always", - directory, - }); + handler(eventToDispatch); } catch (err) { - log.warn("Failed to approve permission", { + log.debug("Session event handler error", { sessionId, - requestId, error: String(err), }); } } } - - // Dispatch to all handlers for this session - for (const handler of session.handlers) { - try { - handler(globalEvent); - } catch (err) { - log.debug("Session event handler error", { - sessionId, - error: String(err), - }); - } - } } } catch (err) { if (session.eventLoopRunning) { @@ -402,8 +482,13 @@ export async function createSessionInstance(envOverrides?: SessionEnvironment): client: getClientForBaseUrl(normalizedBaseUrl), handlers: new Set(), lastActive: Date.now(), + lastMeaningfulEventAt: Date.now(), eventLoopRunning: false, + rootSessionId: sessionId, validSessionIds: new Set([sessionId]), // This session is valid in this instance + childTitles: new Map(), + awaitingInteractionSessionIds: new Set(), + seenEventFingerprints: new BoundedSet(5_000), env: normalizedEnv, baseUrl: normalizedBaseUrl, }; @@ -424,6 +509,32 @@ export async function getSessionClient(sessionId: string): Promise>; +}): Promise { + const pending = pendingOpenCodePermissions.get(params.requestId); + if (!pending) return false; + const validSession = params.sessionId === pending.subscriptionSessionId + || params.sessionId === pending.interactionSessionId + || pending.session.validSessionIds.has(params.sessionId); + if (!validSession) { + throw new Error(`OpenCode permission request does not belong to session: ${params.sessionId}`); + } + const response = await pending.session.client.permission.reply({ + requestID: params.requestId, + reply: parseOpenCodePermissionReply(params.answers), + directory: pending.directory, + }); + if (response.error) { + throw new Error(`OpenCode permission reply error: ${response.error}`); + } + pendingOpenCodePermissions.delete(params.requestId); + pending.session.awaitingInteractionSessionIds.delete(pending.interactionSessionId); + return true; +} + export function getSessionEnvironment(sessionId: string): SessionEnvironment | null { return runtimeState.sessionEnvironments.get(sessionId) ?? null; } @@ -433,6 +544,19 @@ export function getSessionServerUrl(sessionId: string): string | null { return session?.baseUrl ?? null; } +export function getSessionRuntimeSnapshot( + sessionId: string +): OpenCodeSessionRuntimeSnapshot | null { + const session = runtimeState.sessionInstances.get(sessionId); + if (!session) return null; + return { + rootSessionId: session.rootSessionId, + relatedSessionIds: [...session.validSessionIds], + lastMeaningfulEventAt: session.lastMeaningfulEventAt, + awaitingInteraction: session.awaitingInteractionSessionIds.size > 0, + }; +} + // Subscribe to events for a session (sync if instance exists, else queues) export function subscribeToSession( sessionId: string, @@ -494,6 +618,8 @@ export async function ensureValidSession( const newSessionId = result.data.id; session.validSessionIds.add(newSessionId); + session.rootSessionId = newSessionId; + session.lastMeaningfulEventAt = Date.now(); // Update the instance mapping to use the new sessionId runtimeState.sessionInstances.delete(sessionId); diff --git a/packages/agents/openhands/client.ts b/packages/agents/openhands/client.ts index 5b6c78f8..4e0986a4 100644 --- a/packages/agents/openhands/client.ts +++ b/packages/agents/openhands/client.ts @@ -12,7 +12,9 @@ import { type SessionEnvironment as RuntimeSessionEnvironment, } from "../runtime/base"; import { createCliThreadSessionManager } from "../runtime/cli-session"; +import { inspectCliProtocol } from "../runtime/protocol-drift"; import type { + AgentInput, OpenCodeMessage, OpenCodeMessageContext, OpenCodeOptions, @@ -50,6 +52,14 @@ const NEW_SESSIONS_MAX_ENTRIES = 1000; const newSessions = new BoundedSet(NEW_SESSIONS_MAX_ENTRIES); const DEFAULT_OPENHANDS_MODEL = "anthropic/claude-sonnet-4-5-20250929"; const OPENHANDS_EVENT_POLL_MS = 1000; +const OPENHANDS_RECORD_TYPES = [ + "start", + "progress", + "SystemPromptEvent", + "ActionEvent", + "ObservationEvent", + "MessageEvent", +]; export const { createSession, getOrCreateSession } = createCliThreadSessionManager({ providerId: "openhands", @@ -436,6 +446,11 @@ function publishOpenHandsRecord(record: OpenHandsJsonRecord, fallbackSessionId: properties: { record, recordType: rawType, + ...inspectCliProtocol({ + providerName: "OpenHands", + recordType: rawType, + knownRecordTypes: OPENHANDS_RECORD_TYPES, + }), }, }); } @@ -443,7 +458,7 @@ function publishOpenHandsRecord(record: OpenHandsJsonRecord, fallbackSessionId: export async function sendMessage( channelId: string, sessionId: string, - message: string, + input: AgentInput, workingPath: string, options?: OpenCodeOptions, context?: OpenCodeMessageContext @@ -454,7 +469,7 @@ export async function sendMessage( try { return await runtime.withSessionLock(sessionKey, async () => { const agent = options?.agent; - const parts = buildPromptParts(channelId, message, { ...options, agent }, context); + const parts = buildPromptParts(channelId, input, { ...options, agent }, context); const prompt = buildPromptText(parts); const openHandsPrompt = buildSystemWrappedPrompt(buildSystemPrompt(context?.slack), prompt); const envOverrides = runtime.getSessionEnvironment(sessionId); @@ -464,13 +479,13 @@ export async function sendMessage( publishOpenHandsRecord({ type: "start", model, - prompt: message, + prompt, }, sessionId); const progressTimer = setInterval(() => { publishOpenHandsRecord({ type: "progress", model, - prompt: message, + prompt, elapsedMs: Date.now() - startedAtMs, }, sessionId); }, 15_000); diff --git a/packages/agents/openhands/session-state.ts b/packages/agents/openhands/session-state.ts index 0ae12d5b..803d48f4 100644 --- a/packages/agents/openhands/session-state.ts +++ b/packages/agents/openhands/session-state.ts @@ -155,6 +155,11 @@ export function applyOpenHandsRecordToState( return; } + if (record.kind === "SystemPromptEvent") { + state.phaseStatus = "Preparing OpenHands context"; + return; + } + if (record.kind === "ActionEvent") { applyOpenHandsAction(state, record, toolById); return; diff --git a/packages/agents/pi/client.ts b/packages/agents/pi/client.ts index 14bc3f7a..f10b9043 100644 --- a/packages/agents/pi/client.ts +++ b/packages/agents/pi/client.ts @@ -9,7 +9,9 @@ import { type SessionEnvironment as RuntimeSessionEnvironment, } from "../runtime/base"; import { createCliThreadSessionManager } from "../runtime/cli-session"; +import { inspectCliProtocol } from "../runtime/protocol-drift"; import type { + AgentInput, OpenCodeMessage, OpenCodeMessageContext, OpenCodeOptions, @@ -26,6 +28,13 @@ type PiContentBlock = { export type PiJsonRecord = { type?: string; id?: string; + toolCallId?: string; + toolName?: string; + args?: Record; + result?: { + content?: Array<{ type?: string; text?: string }> | string; + isError?: boolean; + }; message?: { role?: string; content?: Array | string; @@ -47,6 +56,19 @@ export type PiJsonRecord = { const runtime = new CliAgentRuntime("Pi"); const NEW_SESSIONS_MAX_ENTRIES = 1000; const newSessions = new BoundedSet(NEW_SESSIONS_MAX_ENTRIES); +const PI_RECORD_TYPES = [ + "agent_start", + "turn_start", + "message_start", + "message_update", + "message_end", + "turn_end", + "agent_end", + "session", + "tool_execution_start", + "tool_execution_end", + "agent_settled", +]; const DEFAULT_PI_MODEL = "claude-sonnet-4-5-20250929"; export const { createSession, getOrCreateSession } = createCliThreadSessionManager({ @@ -145,6 +167,11 @@ function publishPiRecord(record: PiJsonRecord, fallbackSessionId: string): void properties: { record, recordType: rawType, + ...inspectCliProtocol({ + providerName: "Pi", + recordType: rawType, + knownRecordTypes: PI_RECORD_TYPES, + }), }, }); } @@ -152,7 +179,7 @@ function publishPiRecord(record: PiJsonRecord, fallbackSessionId: string): void export async function sendMessage( channelId: string, sessionId: string, - message: string, + input: AgentInput, workingPath: string, options?: OpenCodeOptions, context?: OpenCodeMessageContext @@ -163,7 +190,7 @@ export async function sendMessage( try { return await runtime.withSessionLock(sessionKey, async () => { const agent = options?.agent; - const parts = buildPromptParts(channelId, message, { ...options, agent }, context); + const parts = buildPromptParts(channelId, input, { ...options, agent }, context); const prompt = buildPromptText(parts); const piPrompt = buildSystemWrappedPrompt(buildSystemPrompt(context?.slack), prompt); const envOverrides = runtime.getSessionEnvironment(sessionId); diff --git a/packages/agents/pi/session-state.ts b/packages/agents/pi/session-state.ts index 77467ba9..b20f9ac4 100644 --- a/packages/agents/pi/session-state.ts +++ b/packages/agents/pi/session-state.ts @@ -23,6 +23,14 @@ type PiContentBlock = { export type PiRawRecord = { type?: string; + id?: string; + toolCallId?: string; + toolName?: string; + args?: Record; + result?: { + content?: PiContentBlock[] | string; + isError?: boolean; + }; message?: { role?: string; content?: PiContentBlock[] | string; @@ -162,6 +170,61 @@ export function applyPiRecordToState( if (title) state.sessionTitle = title; const type = typeof record.type === "string" ? record.type.trim() : ""; + if (type === "session") { + state.phaseStatus = "Started Pi session"; + return; + } + + if (type === "tool_execution_start") { + const toolId = typeof record.toolCallId === "string" && record.toolCallId.trim() + ? record.toolCallId + : `pi-tool-${Date.now()}`; + const toolName = typeof record.toolName === "string" && record.toolName.trim() + ? record.toolName + : "tool"; + const tool: StreamToolState = { + id: toolId, + name: toolName, + status: "running", + input: record.args, + title: buildToolTitle(toolName, record.args), + }; + streamState.toolById.set(toolId, tool); + updateTool(state, tool); + state.phaseStatus = tool.title + ? `Running tool: ${toolName} - ${tool.title}` + : `Running tool: ${toolName}`; + return; + } + + if (type === "tool_execution_end") { + const toolId = typeof record.toolCallId === "string" ? record.toolCallId.trim() : ""; + const existing = toolId ? streamState.toolById.get(toolId) : undefined; + const toolName = record.toolName || existing?.name || "tool"; + const isError = record.result?.isError === true; + const output = contentToText(record.result?.content, "text"); + const tool: StreamToolState = { + id: toolId || existing?.id || `pi-tool-${Date.now()}`, + name: toolName, + status: isError ? "error" : "completed", + input: existing?.input, + output: output || existing?.output, + error: isError ? output || "Tool failed" : undefined, + title: existing?.title, + metadata: existing?.metadata, + }; + streamState.toolById.set(tool.id, tool); + updateTool(state, tool); + const detail = tool.title ? `${tool.name} - ${tool.title}` : tool.name; + state.phaseStatus = `${isError ? "Tool failed" : "Finished tool"}: ${detail}`; + return; + } + + if (type === "agent_settled") { + state.phaseStatus = "Finalizing response"; + return; + } + if (type === "agent_start" || type === "turn_start") { state.phaseStatus = "Thinking"; return; diff --git a/packages/agents/qwen/client.ts b/packages/agents/qwen/client.ts index 5cbccac7..66017968 100644 --- a/packages/agents/qwen/client.ts +++ b/packages/agents/qwen/client.ts @@ -9,7 +9,9 @@ import { type SessionEnvironment as RuntimeSessionEnvironment, } from "../runtime/base"; import { createCliThreadSessionManager } from "../runtime/cli-session"; +import { inspectCliProtocol } from "../runtime/protocol-drift"; import type { + AgentInput, OpenCodeMessage, OpenCodeMessageContext, OpenCodeOptions, @@ -38,6 +40,7 @@ const runtime = new CliAgentRuntime("Qwen"); /** See note in claude/client.ts — FIFO-bounded so abandoned sessions don't leak. */ const NEW_SESSIONS_MAX_ENTRIES = 1000; const newSessions = new BoundedSet(NEW_SESSIONS_MAX_ENTRIES); +const QWEN_RECORD_TYPES = ["system", "assistant", "user", "stream_event", "result"]; export const { createSession, getOrCreateSession } = createCliThreadSessionManager({ providerId: "qwen", providerName: "Qwen", @@ -67,8 +70,12 @@ export function buildQwenCommandArgs(params: { if (params.approvalMode === "plan") { args.push("--approval-mode", "plan"); } else { - args.push("--yolo"); + // Headless mode cannot relay Qwen's interactive "default" approval UI + // back to the originating IM thread. `auto` keeps the current classifier + // guardrails (fail-closed for risky operations) without granting YOLO. + args.push("--approval-mode", "auto"); } + args.push("--max-wall-time", "10m", "--max-tool-calls", "100"); if (!params.isNewSession) { args.push("--resume", params.sessionId); } @@ -89,12 +96,20 @@ function publishQwenRecordAsSessionEvents(record: QwenJsonRecord, fallbackSessio const rawType = typeof record.type === "string" && record.type.trim() ? record.type.trim() : "unknown"; + const streamEventType = typeof record.event?.type === "string" ? record.event.type : undefined; const eventPayload = { type: `qwen.raw.${rawType}`, properties: { record, recordType: rawType, - streamEventType: typeof record.event?.type === "string" ? record.event.type : undefined, + streamEventType, + ...inspectCliProtocol({ + providerName: "Qwen", + recordType: rawType, + streamEventType, + knownRecordTypes: QWEN_RECORD_TYPES, + anthropicStyleStream: true, + }), }, }; runtime.publishSessionEvent(sessionId, eventPayload); @@ -159,7 +174,7 @@ function parseQwenResponse(output: string): { export async function sendMessage( channelId: string, sessionId: string, - message: string, + input: AgentInput, workingPath: string, options?: OpenCodeOptions, context?: OpenCodeMessageContext @@ -171,7 +186,7 @@ export async function sendMessage( return await runtime.withSessionLock(sessionKey, async () => { const agent = options?.agent; const approvalMode = agent?.trim().toLowerCase() === "plan" ? "plan" : undefined; - const parts = buildPromptParts(channelId, message, { ...options, agent }, context); + const parts = buildPromptParts(channelId, input, { ...options, agent }, context); const prompt = buildPromptText(parts); const systemPrompt = buildSystemPrompt(context?.slack); const qwenPrompt = buildSystemWrappedPrompt(systemPrompt, prompt); diff --git a/packages/agents/registry.ts b/packages/agents/registry.ts index 672aa811..65557bcd 100644 --- a/packages/agents/registry.ts +++ b/packages/agents/registry.ts @@ -1,12 +1,10 @@ import * as claude from "./claude"; import * as codex from "./codex"; import * as kimi from "./kimi"; -import * as kiro from "./kiro"; import * as kilo from "./kilo"; import * as opencode from "./opencode"; import * as qwen from "./qwen"; import * as goose from "./goose"; -import * as gemini from "./gemini"; import * as pi from "./pi"; import * as openhands from "./openhands"; import * as codebuddy from "./codebuddy"; @@ -23,6 +21,7 @@ import type { OpenCodeMessageContext, OpenCodeOptions, OpenCodeSessionInfo, + AgentInput, } from "./types"; export type AgentProvider = { @@ -40,7 +39,7 @@ export type AgentProvider = { sendMessage: ( channelId: string, sessionId: string, - message: string, + input: AgentInput, workingPath: string, options?: OpenCodeOptions, context?: OpenCodeMessageContext @@ -58,11 +57,9 @@ const providerModules = { claudecode: claude, codex, kimi, - kiro, kilo, qwen, goose, - gemini, pi, openhands, codebuddy, diff --git a/packages/agents/runtime/acp-client.ts b/packages/agents/runtime/acp-client.ts new file mode 100644 index 00000000..8127a860 --- /dev/null +++ b/packages/agents/runtime/acp-client.ts @@ -0,0 +1,1045 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { Readable, Writable } from "node:stream"; +import { pathToFileURL } from "node:url"; +import { + PROTOCOL_VERSION, + client, + methods, + ndJsonStream, + type AgentCapabilities as AcpAgentCapabilities, + type ClientConnection, + type ClientContext, + type ContentBlock, + type PermissionOption, + type RequestPermissionOutcome, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionConfigOption, + type SessionModeState, + type SessionNotification, + type SessionUpdate, + type ToolCallContent, +} from "@agentclientprotocol/sdk"; +import packageJson from "../../../package.json" with { type: "json" }; +import type { AgentProviderId } from "@/shared/agent-provider"; +import type { + AgentCapabilities as OdeAgentCapabilities, + AgentInputPart, +} from "@/shared/agent-protocol"; +import { log } from "@/utils"; +import type { OpenCodeMessage, OpenCodeOptions } from "../types"; +import type { SessionEnvironment } from "./base"; + +const ACP_SETUP_TIMEOUT_MS = 15_000; +const ODE_CLIENT_VERSION = packageJson.version ?? "0.0.0"; +const KNOWN_ACP_SESSION_UPDATES = new Set([ + "user_message_chunk", + "agent_message_chunk", + "agent_thought_chunk", + "tool_call", + "tool_call_update", + "plan", + "plan_update", + "plan_removed", + "available_commands_update", + "current_mode_update", + "config_option_update", + "session_info_update", + "usage_update", +]); + +type SessionEventPublisher = { + publishSessionEvent(sessionId: string, event: unknown): void; +}; + +export type AcpLaunchConfig = { + command: string; + args: string[]; +}; + +export type AcpSendParams = { + providerId: AgentProviderId; + providerName: string; + launch: AcpLaunchConfig; + channelId: string; + sessionId: string; + isNewSession: boolean; + workingPath: string; + environment: SessionEnvironment; + parts: readonly AgentInputPart[]; + options?: OpenCodeOptions; + publisher: SessionEventPublisher; + onNativeSessionId?: (nativeSessionId: string) => void; + onNegotiated?: (details: { + protocolVersion: string; + capabilities: OdeAgentCapabilities; + }) => void; + onFallback?: () => void; + fallback: () => Promise; +}; + +type ToolSnapshot = { + id: string; + name: string; + title?: string; + status: "pending" | "running" | "completed" | "error"; + input?: Record; + output?: string; + error?: string; + metadata?: Record; +}; + +type PendingAcpPermission = { + providerId: AgentProviderId; + sessionIds: Set; + options: PermissionOption[]; + settle: (outcome: RequestPermissionOutcome) => void; +}; + +const pendingAcpPermissions = new Map(); + +export class AcpUnavailableError extends Error { + constructor(providerName: string, cause: unknown) { + super(`${providerName} ACP is unavailable: ${cause instanceof Error ? cause.message : String(cause)}`); + this.name = "AcpUnavailableError"; + this.cause = cause; + } +} + +export function mapAcpCapabilities( + capabilities: AcpAgentCapabilities | undefined +): OdeAgentCapabilities { + const sessions = capabilities?.sessionCapabilities; + return { + sessions: { + create: true, + resume: sessions?.resume != null, + load: capabilities?.loadSession === true, + list: sessions?.list != null, + delete: sessions?.delete != null, + close: sessions?.close != null, + fork: sessions?.fork != null, + }, + input: { + text: true, + image: capabilities?.promptCapabilities?.image === true, + resource: true, + fileRef: true, + }, + events: { + message: true, + reasoningSummary: true, + plan: true, + tool: true, + command: false, + fileDiff: false, + usage: true, + }, + interaction: { + approval: true, + question: false, + cancel: true, + }, + }; +} + +function isEnabled(value: string | undefined): boolean { + return value === "1" || value === "true" || value === "yes" || value === "on"; +} + +function normalizeEnvironment(environment: SessionEnvironment): string { + return Object.keys(environment) + .sort() + .map((key) => `${key}=${environment[key]}`) + .join("\n"); +} + +function withTimeoutSignal(timeoutMs: number): { signal: AbortSignal; dispose: () => void } { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error("ACP setup timed out")), timeoutMs); + return { + signal: controller.signal, + dispose: () => clearTimeout(timer), + }; +} + +function readTextContent(content: ContentBlock): string { + if (content.type === "text") return content.text; + if (content.type === "resource" && "text" in content.resource) return content.resource.text; + return ""; +} + +export interface AcpTextAccumulator { + messageId?: string; + text: string; +} + +export function appendAcpContentChunk( + current: AcpTextAccumulator, + update: { content: ContentBlock; messageId?: string | null } +): AcpTextAccumulator { + const chunk = readTextContent(update.content); + if (!chunk) return current; + const messageId = update.messageId || undefined; + const startsNewMessage = !!messageId && !!current.messageId && messageId !== current.messageId; + return { + messageId: messageId ?? current.messageId, + text: `${startsNewMessage ? "" : current.text}${chunk}`, + }; +} + +function stringifyUnknown(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (value === undefined || value === null) return undefined; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +export function stringifyAcpToolContent(content: readonly ToolCallContent[] | null | undefined): string | undefined { + const lines = (content ?? []).flatMap((entry) => { + if (entry.type === "content") { + const text = readTextContent(entry.content).trim(); + if (text) return [text]; + if (entry.content.type === "resource_link") { + return [`Resource: ${entry.content.name} (${entry.content.uri})`]; + } + return []; + } + if (entry.type === "diff") return [`Changed ${entry.path}`]; + if (entry.type === "terminal") return [`Terminal: ${entry.terminalId}`]; + return []; + }); + return lines.length > 0 ? lines.join("\n") : undefined; +} + +function normalizeToolStatus( + status: "pending" | "in_progress" | "completed" | "failed" | null | undefined +): ToolSnapshot["status"] { + if (status === "in_progress") return "running"; + if (status === "failed") return "error"; + return status ?? "pending"; +} + +function flattenConfigOptions(option: SessionConfigOption): Array<{ value: string; name: string }> { + if (option.type !== "select") return []; + return option.options.flatMap((entry) => { + if ("group" in entry) return entry.options; + return [entry]; + }); +} + +function findConfigValue( + options: SessionConfigOption[] | null | undefined, + category: string, + requested: string | undefined +): { configId: string; value: string } | undefined { + const target = requested?.trim().toLowerCase(); + if (!target) return undefined; + for (const option of options ?? []) { + const id = option.id.toLowerCase(); + const name = option.name.toLowerCase(); + if (option.category !== category && !id.includes(category) && !name.includes(category)) continue; + const match = flattenConfigOptions(option).find((candidate) => { + const value = candidate.value.toLowerCase(); + const label = candidate.name.toLowerCase(); + return value === target || label === target || value.endsWith(`/${target}`); + }); + if (match) return { configId: option.id, value: match.value }; + } + return undefined; +} + +async function inputPartToAcpBlock( + part: AgentInputPart, + capabilities: AcpAgentCapabilities | undefined +): Promise { + if (part.type === "text") { + return { type: "text", text: part.text }; + } + + const uri = pathToFileURL(part.path).href; + if (part.type === "image" && capabilities?.promptCapabilities?.image) { + const bytes = await Bun.file(part.path).arrayBuffer(); + return { + type: "image", + mimeType: part.mimeType, + data: Buffer.from(bytes).toString("base64"), + }; + } + + if (part.type === "resource" && capabilities?.promptCapabilities?.embeddedContext) { + if (part.text !== undefined) { + return { + type: "resource", + resource: { uri, mimeType: part.mimeType, text: part.text }, + }; + } + const file = Bun.file(part.path); + if (part.mimeType.startsWith("text/") || part.mimeType === "application/json") { + return { + type: "resource", + resource: { uri, mimeType: part.mimeType, text: await file.text() }, + }; + } + return { + type: "resource", + resource: { + uri, + mimeType: part.mimeType, + blob: Buffer.from(await file.arrayBuffer()).toString("base64"), + }, + }; + } + + return { + type: "resource_link", + uri, + name: part.filename, + mimeType: part.mimeType, + size: part.size, + }; +} + +export async function buildAcpPrompt( + parts: readonly AgentInputPart[], + capabilities: AcpAgentCapabilities | undefined +): Promise { + return Promise.all(parts.map((part) => inputPartToAcpBlock(part, capabilities))); +} + +export function prependSystemPrompt( + parts: readonly AgentInputPart[], + systemPrompt: string +): AgentInputPart[] { + const trimmed = systemPrompt.trim(); + if (!trimmed) return [...parts]; + return [ + { type: "text", text: `\n${trimmed}\n` }, + ...parts, + ]; +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +function compactPermissionDetail(value: unknown, maxLength = 320): string | undefined { + const text = stringifyUnknown(value)?.replace(/\s+/g, " ").trim(); + if (!text) return undefined; + return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text; +} + +export function buildAcpPermissionQuestion(params: { + providerName: string; + requestId: string; + sessionId: string; + request: RequestPermissionRequest; +}): Record { + const tool = params.request.toolCall; + const title = tool.title || tool.name || tool.kind || "use a tool"; + const detail = compactPermissionDetail(tool.rawInput); + const locations = tool.locations?.map((location) => location.path).filter(Boolean).join(", "); + const suffix = [detail, locations ? `Locations: ${locations}` : undefined] + .filter(Boolean) + .join("\n"); + return { + type: "question.asked", + properties: { + id: params.requestId, + sessionID: params.sessionId, + questions: [{ + header: "Permission", + question: `${params.providerName} wants permission to ${title}.${suffix ? `\n${suffix}` : ""}`, + options: params.request.options.map((option) => ({ + label: option.name, + description: option.kind.replaceAll("_", " "), + })), + multiple: false, + custom: false, + }], + }, + }; +} + +function normalizePermissionChoice(value: string): string { + return value.trim().toLowerCase().replace(/[\s_-]+/g, " "); +} + +export function selectAcpPermissionOutcome( + options: readonly PermissionOption[], + answers: Array> +): RequestPermissionOutcome { + const answer = answers.flat().map((value) => value.trim()).find(Boolean); + if (!answer) return { outcome: "cancelled" }; + const normalized = normalizePermissionChoice(answer); + const selected = options.find((option) => { + const aliases = [ + option.optionId, + option.name, + option.kind, + option.kind.replaceAll("_", " "), + ]; + return aliases.some((alias) => normalizePermissionChoice(alias) === normalized); + }); + return selected + ? { outcome: "selected", optionId: selected.optionId } + : { outcome: "cancelled" }; +} + +export function scopeAcpSessionEvent(event: unknown, sessionId: string): unknown { + const record = asRecord(event); + const properties = asRecord(record?.properties); + if (!record || !properties) return event; + if (record.type === "question.asked" || record.type === "question.replied" || record.type === "question.rejected") { + return { ...record, properties: { ...properties, sessionID: sessionId } }; + } + if (record.type !== "message.part.updated") return event; + const part = asRecord(properties.part); + if (!part) return event; + return { + ...record, + properties: { + ...properties, + part: { ...part, sessionID: sessionId }, + }, + }; +} + +export async function replyToAcpQuestion(params: { + providerId: AgentProviderId; + sessionId: string; + requestId: string; + answers: Array>; +}): Promise { + const pending = pendingAcpPermissions.get(params.requestId); + if (!pending || pending.providerId !== params.providerId || !pending.sessionIds.has(params.sessionId)) { + throw new Error(`No pending ACP permission request found: ${params.requestId}`); + } + pending.settle(selectAcpPermissionOutcome(pending.options, params.answers)); +} + +class AcpAgentConnection { + readonly aliases = new Set(); + private child?: ChildProcessWithoutNullStreams; + private connection?: ClientConnection; + private context?: ClientContext; + private capabilities?: AcpAgentCapabilities; + private protocolVersion = String(PROTOCOL_VERSION); + private nativeSessionId?: string; + private stderr = ""; + private assistantText = ""; + private assistantMessageId?: string; + private reasoningText = ""; + private reasoningMessageId?: string; + private readonly tools = new Map(); + private readonly pendingPermissionIds = new Set(); + private readonly unknownUpdateTypes = new Set(); + + constructor( + private readonly params: Omit, + readonly environmentKey: string + ) { + this.aliases.add(params.sessionId); + } + + get sessionId(): string | undefined { + return this.nativeSessionId; + } + + get isAlive(): boolean { + return !!this.child && this.child.exitCode === null && this.child.signalCode === null; + } + + get negotiated(): { protocolVersion: string; capabilities: OdeAgentCapabilities } { + return { + protocolVersion: this.protocolVersion, + capabilities: mapAcpCapabilities(this.capabilities), + }; + } + + private publish(event: unknown): void { + for (const alias of this.aliases) { + this.params.publisher.publishSessionEvent(alias, scopeAcpSessionEvent(event, alias)); + } + } + + private publishRaw(notification: SessionNotification): void { + const updateType = typeof (notification.update as { sessionUpdate?: unknown }).sessionUpdate === "string" + ? String((notification.update as { sessionUpdate: string }).sessionUpdate) + : "unknown"; + const protocolKnown = KNOWN_ACP_SESSION_UPDATES.has(updateType); + if (!protocolKnown && !this.unknownUpdateTypes.has(updateType)) { + this.unknownUpdateTypes.add(updateType); + log.warn("Unknown ACP session update", { + provider: this.params.providerId, + updateType, + protocolVersion: this.protocolVersion, + }); + } + this.publish({ + type: `${this.params.providerId}.acp.${updateType}`, + properties: { + notification, + update: notification.update, + protocolKnown, + protocolLabel: `ACP ${updateType}`, + }, + }); + } + + private requestPermission(request: RequestPermissionRequest): Promise { + if (request.options.length === 0) { + return Promise.resolve({ outcome: { outcome: "cancelled" } }); + } + const requestId = [ + "acp", + this.params.providerId, + request.sessionId, + request.toolCall.toolCallId, + crypto.randomUUID(), + ].join(":"); + this.publish(buildAcpPermissionQuestion({ + providerName: this.params.providerName, + requestId, + sessionId: this.nativeSessionId ?? this.params.sessionId, + request, + })); + this.publish({ + type: "session.status", + properties: { status: `Waiting for approval: ${request.toolCall.title || request.toolCall.name || "tool"}` }, + }); + + return new Promise((resolve) => { + let settled = false; + const settle = (outcome: RequestPermissionOutcome) => { + if (settled) return; + settled = true; + pendingAcpPermissions.delete(requestId); + this.pendingPermissionIds.delete(requestId); + this.publish({ + type: outcome.outcome === "cancelled" ? "question.rejected" : "question.replied", + properties: { id: requestId, sessionID: this.nativeSessionId ?? this.params.sessionId }, + }); + resolve({ outcome }); + }; + this.pendingPermissionIds.add(requestId); + pendingAcpPermissions.set(requestId, { + providerId: this.params.providerId, + sessionIds: this.aliases, + options: request.options, + settle, + }); + }); + } + + private cancelPendingPermissions(): void { + for (const requestId of [...this.pendingPermissionIds]) { + pendingAcpPermissions.get(requestId)?.settle({ outcome: "cancelled" }); + } + } + + private publishText(type: "text" | "reasoning", text: string): void { + this.publish({ + type: "message.part.updated", + properties: { + part: { + id: type === "text" ? "acp-assistant" : "acp-reasoning", + sessionID: this.nativeSessionId, + type, + text, + }, + }, + }); + } + + private publishTool(tool: ToolSnapshot): void { + this.publish({ + type: "message.part.updated", + properties: { + part: { + id: tool.id, + sessionID: this.nativeSessionId, + type: "tool", + tool: tool.name, + state: { + status: tool.status, + title: tool.title, + input: tool.input, + output: tool.output, + error: tool.error, + metadata: tool.metadata, + }, + }, + }, + }); + } + + private applyUpdate(update: SessionUpdate): void { + if (update.sessionUpdate === "agent_message_chunk") { + const next = appendAcpContentChunk({ + messageId: this.assistantMessageId, + text: this.assistantText, + }, update); + if (next.text !== this.assistantText) { + this.assistantMessageId = next.messageId; + this.assistantText = next.text; + this.publishText("text", this.assistantText); + } + return; + } + if (update.sessionUpdate === "agent_thought_chunk") { + const next = appendAcpContentChunk({ + messageId: this.reasoningMessageId, + text: this.reasoningText, + }, update); + if (next.text !== this.reasoningText) { + this.reasoningMessageId = next.messageId; + this.reasoningText = next.text; + this.publishText("reasoning", this.reasoningText); + } + return; + } + if (update.sessionUpdate === "tool_call") { + const contentOutput = stringifyAcpToolContent(update.content); + const tool: ToolSnapshot = { + id: update.toolCallId, + name: update.name || update.title || update.kind || "tool", + title: update.title, + status: normalizeToolStatus(update.status), + input: update.rawInput && typeof update.rawInput === "object" + ? update.rawInput as Record + : undefined, + output: stringifyUnknown(update.rawOutput) ?? contentOutput, + metadata: update.locations || update.content + ? { locations: update.locations, kind: update.kind, content: update.content } + : undefined, + }; + this.tools.set(tool.id, tool); + this.publishTool(tool); + return; + } + if (update.sessionUpdate === "tool_call_update") { + const previous = this.tools.get(update.toolCallId); + const output = stringifyUnknown(update.rawOutput) ?? stringifyAcpToolContent(update.content); + const status = update.status ? normalizeToolStatus(update.status) : previous?.status ?? "pending"; + const tool: ToolSnapshot = { + id: update.toolCallId, + name: update.name || previous?.name || update.title || update.kind || "tool", + title: update.title ?? previous?.title, + status, + input: update.rawInput && typeof update.rawInput === "object" + ? update.rawInput as Record + : previous?.input, + output: status === "error" ? previous?.output : output ?? previous?.output, + error: status === "error" ? output ?? previous?.error : previous?.error, + metadata: update.locations || update.content + ? { ...previous?.metadata, locations: update.locations, kind: update.kind, content: update.content } + : previous?.metadata, + }; + this.tools.set(tool.id, tool); + this.publishTool(tool); + return; + } + if (update.sessionUpdate === "plan") { + this.publish({ type: "todo.updated", properties: { items: update.entries } }); + return; + } + if (update.sessionUpdate === "plan_update") { + if (update.plan.type === "items") { + this.publish({ type: "todo.updated", properties: { items: update.plan.entries } }); + } else if (update.plan.type === "markdown") { + this.publish({ + type: "message.part.updated", + properties: { + part: { + id: `acp-plan-${update.plan.planId}`, + sessionID: this.nativeSessionId, + type: "reasoning", + text: update.plan.content, + }, + }, + }); + } else { + this.publish({ type: "session.updated", properties: { plan: update.plan } }); + } + return; + } + if (update.sessionUpdate === "plan_removed") { + this.publish({ type: "todo.updated", properties: { items: [], planId: update.planId } }); + return; + } + if (update.sessionUpdate === "available_commands_update") { + this.publish({ + type: "session.updated", + properties: { availableCommands: update.availableCommands }, + }); + return; + } + if (update.sessionUpdate === "current_mode_update") { + this.publish({ + type: "session.updated", + properties: { mode: update.currentModeId }, + }); + return; + } + if (update.sessionUpdate === "config_option_update") { + this.publish({ + type: "session.updated", + properties: { configOptions: update.configOptions }, + }); + return; + } + if (update.sessionUpdate === "usage_update") { + this.publish({ + type: "message.updated", + properties: { info: { usage: update } }, + }); + return; + } + if (update.sessionUpdate === "session_info_update") { + this.publish({ type: "session.updated", properties: update }); + return; + } + + const updateType = typeof (update as { sessionUpdate?: unknown }).sessionUpdate === "string" + ? String((update as { sessionUpdate: string }).sessionUpdate) + : "unknown"; + if (!KNOWN_ACP_SESSION_UPDATES.has(updateType)) { + this.publish({ + type: "session.status", + properties: { status: `${this.params.providerName} integration update required: ACP ${updateType}` }, + }); + } + } + + private async spawnAndConnect(): Promise { + const child = spawn(this.params.launch.command, this.params.launch.args, { + cwd: this.params.workingPath, + env: { + ...process.env, + ...this.params.environment, + PWD: this.params.workingPath, + NO_COLOR: "1", + }, + stdio: ["pipe", "pipe", "pipe"], + }); + this.child = child; + child.stderr.on("data", (chunk) => { + this.stderr = `${this.stderr}${Buffer.from(chunk).toString("utf8")}`.slice(-16_000); + }); + + await new Promise((resolve, reject) => { + const onSpawn = () => { + child.off("error", onError); + resolve(); + }; + const onError = (error: Error) => { + child.off("spawn", onSpawn); + reject(error); + }; + child.once("spawn", onSpawn); + child.once("error", onError); + }); + + const app = client({ name: "Ode" }) + .onRequest(methods.client.session.requestPermission, ({ params }) => this.requestPermission(params)) + .onNotification(methods.client.session.update, ({ params }) => { + this.publishRaw(params); + this.applyUpdate(params.update); + }); + + const stream = ndJsonStream( + Writable.toWeb(child.stdin) as WritableStream, + Readable.toWeb(child.stdout) as unknown as ReadableStream + ); + this.connection = app.connect(stream); + this.context = this.connection.agent; + } + + async initialize(): Promise { + try { + await this.spawnAndConnect(); + const timeout = withTimeoutSignal(ACP_SETUP_TIMEOUT_MS); + try { + const response = await this.context!.request(methods.agent.initialize, { + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + terminal: false, + plan: {}, + }, + clientInfo: { name: "Ode", version: ODE_CLIENT_VERSION }, + }, { cancellationSignal: timeout.signal }); + this.capabilities = response.agentCapabilities; + this.protocolVersion = String(response.protocolVersion); + } finally { + timeout.dispose(); + } + } catch (error) { + this.close(); + throw new AcpUnavailableError(this.params.providerName, this.stderr.trim() || error); + } + } + + async startOrLoad(): Promise<{ + nativeSessionId: string; + configOptions?: SessionConfigOption[] | null; + modes?: SessionModeState | null; + }> { + if (!this.context) throw new AcpUnavailableError(this.params.providerName, "not initialized"); + try { + const timeout = withTimeoutSignal(ACP_SETUP_TIMEOUT_MS); + try { + if (this.params.isNewSession) { + const response = await this.context.request(methods.agent.session.new, { + cwd: this.params.workingPath, + mcpServers: [], + }, { cancellationSignal: timeout.signal }); + this.nativeSessionId = response.sessionId; + this.aliases.add(response.sessionId); + return { + nativeSessionId: response.sessionId, + configOptions: response.configOptions, + modes: response.modes, + }; + } + + if (!this.capabilities?.loadSession) { + throw new Error("agent does not advertise session/load"); + } + const response = await this.context.request(methods.agent.session.load, { + sessionId: this.params.sessionId, + cwd: this.params.workingPath, + mcpServers: [], + }, { cancellationSignal: timeout.signal }); + this.nativeSessionId = this.params.sessionId; + return { + nativeSessionId: this.params.sessionId, + configOptions: response?.configOptions, + modes: response?.modes, + }; + } finally { + timeout.dispose(); + } + } catch (error) { + this.close(); + throw new AcpUnavailableError(this.params.providerName, this.stderr.trim() || error); + } + } + + async configure( + options: OpenCodeOptions | undefined, + configOptions: SessionConfigOption[] | null | undefined, + modes: SessionModeState | null | undefined + ): Promise { + if (!this.context || !this.nativeSessionId) return; + + const model = findConfigValue(configOptions, "model", options?.model?.modelID); + if (model) { + await this.context.request(methods.agent.session.setConfigOption, { + sessionId: this.nativeSessionId, + configId: model.configId, + value: model.value, + }).catch((error) => { + log.warn(`${this.params.providerName} ACP could not select requested model`, { + model: options?.model?.modelID, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + + const effort = findConfigValue(configOptions, "thought_level", options?.reasoningEffort); + if (effort) { + await this.context.request(methods.agent.session.setConfigOption, { + sessionId: this.nativeSessionId, + configId: effort.configId, + value: effort.value, + }).catch(() => {}); + } + + if (options?.agent?.trim().toLowerCase() === "plan") { + // ACP modes are agent-defined, so discover the plan mode instead of + // assuming a provider-specific identifier. + const planMode = modes?.availableModes.find((mode) => + mode.id.toLowerCase() === "plan" || mode.name.toLowerCase() === "plan" + ); + if (planMode) { + await this.context.request(methods.agent.session.setMode, { + sessionId: this.nativeSessionId, + modeId: planMode.id, + }).catch(() => {}); + } else { + const planConfig = findConfigValue(configOptions, "mode", "plan"); + if (planConfig) { + await this.context.request(methods.agent.session.setConfigOption, { + sessionId: this.nativeSessionId, + configId: planConfig.configId, + value: planConfig.value, + }).catch(() => {}); + } + } + } + } + + async prompt(parts: readonly AgentInputPart[]): Promise { + if (!this.context || !this.nativeSessionId) { + throw new Error(`${this.params.providerName} ACP session is not ready`); + } + this.assistantText = ""; + this.assistantMessageId = undefined; + this.reasoningText = ""; + this.reasoningMessageId = undefined; + this.tools.clear(); + this.publish({ type: "session.status", properties: { status: { type: "busy" } } }); + try { + const prompt = await buildAcpPrompt(parts, this.capabilities); + const response = await this.context.request(methods.agent.session.prompt, { + sessionId: this.nativeSessionId, + prompt, + }); + if (response.usage) { + this.publish({ type: "message.updated", properties: { info: { usage: response.usage } } }); + } + const text = this.assistantText.trim(); + if (!text && response.stopReason !== "cancelled") { + return [{ + text: `${this.params.providerName} completed without textual output.`, + messageType: "assistant", + }]; + } + return text ? [{ text, messageType: "assistant" }] : []; + } finally { + this.publish({ type: "session.status", properties: { status: { type: "idle" } } }); + } + } + + async cancel(): Promise { + if (!this.context || !this.nativeSessionId || !this.isAlive) return false; + this.cancelPendingPermissions(); + await this.context.notify(methods.agent.session.cancel, { sessionId: this.nativeSessionId }); + return true; + } + + close(): void { + this.cancelPendingPermissions(); + this.connection?.close(); + this.connection = undefined; + this.context = undefined; + if (this.child) { + const child = this.child; + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGTERM"); + const forceKill = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + }, 2_000); + forceKill.unref(); + } + } + this.child = undefined; + } +} + +class AcpConnectionPool { + private readonly connections = new Map(); + private readonly locks = new Map>(); + + private key(providerId: AgentProviderId, sessionId: string): string { + return `${providerId}:${sessionId}`; + } + + private find(providerId: AgentProviderId, sessionId: string): AcpAgentConnection | undefined { + return this.connections.get(this.key(providerId, sessionId)); + } + + private register(providerId: AgentProviderId, connection: AcpAgentConnection): void { + for (const alias of connection.aliases) { + this.connections.set(this.key(providerId, alias), connection); + } + } + + async send(params: Omit): Promise { + const lockKey = this.key(params.providerId, params.sessionId); + const previous = this.locks.get(lockKey); + if (previous) await previous.catch(() => {}); + + const operation = this.sendUnlocked(params); + this.locks.set(lockKey, operation); + try { + return await operation; + } finally { + if (this.locks.get(lockKey) === operation) this.locks.delete(lockKey); + } + } + + private async sendUnlocked(params: Omit): Promise { + const environmentKey = normalizeEnvironment(params.environment); + let connection = this.find(params.providerId, params.sessionId); + if (connection && (!connection.isAlive || connection.environmentKey !== environmentKey)) { + connection.close(); + connection = undefined; + } + + if (!connection) { + connection = new AcpAgentConnection(params, environmentKey); + await connection.initialize(); + const session = await connection.startOrLoad(); + await connection.configure(params.options, session.configOptions, session.modes); + this.register(params.providerId, connection); + params.onNativeSessionId?.(session.nativeSessionId); + params.onNegotiated?.(connection.negotiated); + } + + return connection.prompt(params.parts); + } + + async cancel(providerId: AgentProviderId, sessionId: string): Promise { + return await this.find(providerId, sessionId)?.cancel() ?? false; + } + + close(providerId?: AgentProviderId): void { + const unique = new Set(); + for (const [key, connection] of this.connections) { + if (!providerId || key.startsWith(`${providerId}:`)) unique.add(connection); + } + for (const connection of unique) connection.close(); + for (const key of [...this.connections.keys()]) { + if (!providerId || key.startsWith(`${providerId}:`)) this.connections.delete(key); + } + } +} + +const pool = new AcpConnectionPool(); + +export async function sendMessageViaAcp(params: AcpSendParams): Promise { + const providerFlag = `ODE_${params.providerId.toUpperCase()}_LEGACY_CLI`; + if (isEnabled(process.env.ODE_ACP_DISABLED) || isEnabled(process.env[providerFlag])) { + params.onFallback?.(); + return params.fallback(); + } + + try { + return await pool.send(params); + } catch (error) { + if (!(error instanceof AcpUnavailableError)) throw error; + log.warn(`${params.providerName} ACP setup failed; using the existing CLI transport`, { + error: error.message, + }); + params.onFallback?.(); + return params.fallback(); + } +} + +export async function cancelAcpSession(providerId: AgentProviderId, sessionId: string): Promise { + return pool.cancel(providerId, sessionId); +} + +export function stopAcpProvider(providerId?: AgentProviderId): void { + pool.close(providerId); +} diff --git a/packages/agents/runtime/base-client.ts b/packages/agents/runtime/base-client.ts index 4e7fbac7..33dcb639 100644 --- a/packages/agents/runtime/base-client.ts +++ b/packages/agents/runtime/base-client.ts @@ -1,5 +1,5 @@ import { CliAgentRuntime, runCliJsonCommand, type SessionEnvironment } from "./base"; -import type { OpenCodeMessage, OpenCodeMessageContext, OpenCodeOptions } from "../types"; +import type { AgentInput, OpenCodeMessage, OpenCodeMessageContext, OpenCodeOptions } from "../types"; import { log } from "@/utils"; export abstract class AgentBaseClient { @@ -72,7 +72,7 @@ export abstract class AgentBaseClient { abstract sendMessage( channelId: string, sessionId: string, - message: string, + input: AgentInput, workingPath: string, options?: OpenCodeOptions, context?: OpenCodeMessageContext diff --git a/packages/agents/runtime/protocol-drift.ts b/packages/agents/runtime/protocol-drift.ts new file mode 100644 index 00000000..39903537 --- /dev/null +++ b/packages/agents/runtime/protocol-drift.ts @@ -0,0 +1,35 @@ +import { BoundedSet, log } from "@/utils"; + +const KNOWN_ANTHROPIC_STREAM_EVENTS = new Set([ + "message_start", + "content_block_start", + "content_block_delta", + "content_block_stop", + "message_delta", + "message_stop", + "ping", +]); + +const unknownProtocolLabels = new BoundedSet(500); + +export function inspectCliProtocol(params: { + providerName: string; + recordType: string; + streamEventType?: string; + knownRecordTypes: readonly string[]; + anthropicStyleStream?: boolean; +}): { protocolKnown: boolean; protocolLabel: string } { + const knownRecord = params.knownRecordTypes.includes(params.recordType); + const unknownNestedStream = params.anthropicStyleStream === true + && params.recordType === "stream_event" + && (!params.streamEventType || !KNOWN_ANTHROPIC_STREAM_EVENTS.has(params.streamEventType)); + const protocolKnown = knownRecord && !unknownNestedStream; + const protocolLabel = unknownNestedStream + ? `${params.providerName} stream ${params.streamEventType ?? "unknown"}` + : `${params.providerName} record ${params.recordType}`; + if (!protocolKnown && !unknownProtocolLabels.has(protocolLabel)) { + unknownProtocolLabels.add(protocolLabel); + log.warn("Unknown coding CLI protocol event", { protocolLabel }); + } + return { protocolKnown, protocolLabel }; +} diff --git a/packages/agents/session-state/shared.ts b/packages/agents/session-state/shared.ts index 96ed5316..8071c0a7 100644 --- a/packages/agents/session-state/shared.ts +++ b/packages/agents/session-state/shared.ts @@ -16,10 +16,22 @@ type ToolBlock = { name?: string; input?: Record; tool_use_id?: string; - content?: string; + content?: unknown; is_error?: boolean; }; +function extractToolResultText(content: unknown): string | undefined { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return undefined; + const text = content + .filter((entry): entry is Record => !!entry && typeof entry === "object" && !Array.isArray(entry)) + .filter((entry) => entry.type === "text" && typeof entry.text === "string") + .map((entry) => String(entry.text)) + .join("\n") + .trim(); + return text || undefined; +} + export type StreamToolState = SessionTool & { inputBuffer?: string; }; @@ -162,7 +174,7 @@ export function buildToolTitle( return firstStringValue(input, ["pattern", "path", "glob", "directory"]); } - if (normalized === "agent" || normalized.includes("task")) { + if (normalized === "agent" || normalized === "subagent" || normalized.includes("task")) { const description = firstStringValue(input, ["description", "prompt", "task", "message"]); return description ? compactSingleLine(description) : undefined; } @@ -182,6 +194,9 @@ export function extractSessionTitle(value: unknown): string | undefined { const trimmed = candidate.trim(); if (!trimmed) return undefined; if (trimmed.startsWith("New session")) return undefined; + if (trimmed.startsWith("") || trimmed.startsWith("ODE RUNTIME CONTEXT:")) { + return undefined; + } return trimmed; }; @@ -233,7 +248,8 @@ export function applyAssistantBlocks( state: SessionMessageState, blocks: ToolBlock[], streamState: Pick, "toolById">, - toolPrefix: string + toolPrefix: string, + options: { startedAtMs?: number } = {} ): void { const { toolById } = streamState; const text = blocks @@ -269,8 +285,13 @@ export function applyAssistantBlocks( output: existing?.output, error: existing?.error, title: buildToolTitle(toolName, input ?? existing?.input) ?? existing?.title, - metadata: existing?.metadata, - } as TTool; + metadata: { + ...(existing?.metadata ?? {}), + startedAtMs: typeof existing?.metadata?.startedAtMs === "number" + ? existing.metadata.startedAtMs + : options.startedAtMs ?? Date.now(), + }, + } as unknown as TTool; const parsedTodos = parseTodosFromToolInput(toolName, input); if (parsedTodos) { state.todos = parsedTodos; @@ -301,7 +322,7 @@ export function applyUserToolResults( if (!existing) continue; const hasError = block.is_error === true; - const output = typeof block.content === "string" ? block.content : undefined; + const output = extractToolResultText(block.content); const updated = { ...existing, status: hasError ? "error" : "completed", @@ -319,7 +340,8 @@ export function applyAnthropicStyleStreamEvent( state: SessionMessageState, record: StreamEventRecord, streamState: StreamStateMaps, - toolPrefix: string + toolPrefix: string, + options: { completeToolOnContentBlockStop?: boolean; startedAtMs?: number } = {} ): boolean { if (!record || !record.event?.type) return false; const { textByIndex, thinkingByIndex, toolByIndex, toolById } = streamState; @@ -328,6 +350,15 @@ export function applyAnthropicStyleStreamEvent( switch (eventType) { case "message_start": { + // Content block indexes are scoped to one assistant message. Claude + // restarts them at zero after a tool result, so retaining the previous + // message's index maps can incorrectly resurrect a completed tool when + // the final text block stops. + textByIndex.clear(); + thinkingByIndex.clear(); + toolByIndex.clear(); + state.currentText = ""; + state.thinkingText = undefined; state.phaseStatus = "Thinking"; return true; } @@ -351,7 +382,8 @@ export function applyAnthropicStyleStreamEvent( status: "running", input, title: buildToolTitle(toolName, input), - } as TTool; + metadata: { startedAtMs: options.startedAtMs ?? Date.now() }, + } as unknown as TTool; const parsedTodos = parseTodosFromToolInput(toolName, input); if (parsedTodos) { state.todos = parsedTodos; @@ -451,6 +483,14 @@ export function applyAnthropicStyleStreamEvent( state.phaseStatus = "Finished step"; return true; } + if (options.completeToolOnContentBlockStop === false) { + tool.status = "running"; + toolById.set(tool.id, tool); + toolByIndex.set(index, tool); + updateTool(state, tool); + state.phaseStatus = tool.title ? `Running tool: ${tool.name} - ${tool.title}` : `Running tool: ${tool.name}`; + return true; + } tool.status = "completed"; toolById.set(tool.id, tool); toolByIndex.set(index, tool); @@ -459,7 +499,15 @@ export function applyAnthropicStyleStreamEvent( return true; } case "message_stop": { - state.phaseStatus = "Finalizing response"; + const runningTool = [...state.tools] + .reverse() + .find((tool) => tool.status === "running" || tool.status === "pending"); + if (runningTool) { + const detail = runningTool.title ? `${runningTool.name} - ${runningTool.title}` : runningTool.name; + state.phaseStatus = `Running tool: ${detail}`; + } else { + state.phaseStatus = "Finalizing response"; + } return true; } default: diff --git a/packages/agents/shared.ts b/packages/agents/shared.ts index 027a260f..b19c2067 100644 --- a/packages/agents/shared.ts +++ b/packages/agents/shared.ts @@ -1,4 +1,5 @@ -import type { OpenCodeMessageContext, OpenCodeOptions, PromptPart, SlackContext } from "./types"; +import type { AgentInput, OpenCodeMessageContext, OpenCodeOptions, PromptPart, SlackContext } from "./types"; +import { renderAgentInputAsText } from "@/shared/agent-protocol"; export function buildSystemPrompt(slack?: SlackContext): string { if (!slack) return ""; @@ -33,7 +34,7 @@ export function buildSystemPrompt(slack?: SlackContext): string { export function buildPromptParts( _channelId: string, - message: string, + input: AgentInput, _options?: OpenCodeOptions, context?: OpenCodeMessageContext ): PromptPart[] { @@ -46,13 +47,13 @@ export function buildPromptParts( }); } - parts.push({ type: "text", text: message }); + parts.push(...input.parts); return parts; } export function buildPromptText(parts: PromptPart[]): string { - return parts.map((part) => part.text).join("\n\n"); + return renderAgentInputAsText({ parts }); } export function buildSystemWrappedPrompt(systemPrompt: string, prompt: string): string { diff --git a/packages/agents/test/acp-client.test.ts b/packages/agents/test/acp-client.test.ts new file mode 100644 index 00000000..f42ff2dd --- /dev/null +++ b/packages/agents/test/acp-client.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "bun:test"; +import type { AgentCapabilities as AcpAgentCapabilities } from "@agentclientprotocol/sdk"; +import { + appendAcpContentChunk, + buildAcpPermissionQuestion, + buildAcpPrompt, + mapAcpCapabilities, + prependSystemPrompt, + scopeAcpSessionEvent, + selectAcpPermissionOutcome, + stringifyAcpToolContent, +} from "../runtime/acp-client"; + +describe("ACP client bridge", () => { + it("maps negotiated lifecycle and attachment capabilities conservatively", () => { + const capabilities: AcpAgentCapabilities = { + loadSession: true, + promptCapabilities: { image: true, embeddedContext: true }, + sessionCapabilities: { resume: {}, close: {}, list: {} }, + }; + const mapped = mapAcpCapabilities(capabilities); + + expect(mapped.sessions).toEqual({ + create: true, + resume: true, + load: true, + list: true, + delete: false, + close: true, + fork: false, + }); + expect(mapped.input.image).toBe(true); + expect(mapped.interaction.question).toBe(false); + }); + + it("uses resource links when an ACP agent does not advertise image input", async () => { + const blocks = await buildAcpPrompt([{ + type: "image", + path: "/tmp/example.png", + filename: "example.png", + mimeType: "image/png", + size: 10, + }], {}); + + expect(blocks).toEqual([{ + type: "resource_link", + uri: "file:///tmp/example.png", + name: "example.png", + mimeType: "image/png", + size: 10, + }]); + }); + + it("keeps system instructions separate from binary prompt parts", () => { + const parts = prependSystemPrompt([{ + type: "fileRef", + path: "/tmp/archive.zip", + filename: "archive.zip", + mimeType: "application/zip", + size: 100, + }], "Follow Ode runtime rules."); + + expect(parts[0]).toEqual({ + type: "text", + text: "\nFollow Ode runtime rules.\n", + }); + expect(parts[1]?.type).toBe("fileRef"); + }); + + it("routes ACP permission requests through a visible Ode question", () => { + const event = buildAcpPermissionQuestion({ + providerName: "Kilo", + requestId: "permission-1", + sessionId: "native-session", + request: { + sessionId: "native-session", + toolCall: { + toolCallId: "tool-1", + title: "run a shell command", + rawInput: { command: "git status" }, + }, + options: [ + { optionId: "once", name: "Allow once", kind: "allow_once" }, + { optionId: "reject", name: "Reject", kind: "reject_once" }, + ], + }, + }) as { type: string; properties: { questions: Array<{ question: string; custom: boolean }> } }; + + expect(event.type).toBe("question.asked"); + expect(event.properties.questions[0]?.question).toContain("git status"); + expect(event.properties.questions[0]?.custom).toBe(false); + expect(selectAcpPermissionOutcome([ + { optionId: "once", name: "Allow once", kind: "allow_once" }, + ], [["Allow once"]])).toEqual({ outcome: "selected", optionId: "once" }); + expect(selectAcpPermissionOutcome([ + { optionId: "once", name: "Allow once", kind: "allow_once" }, + ], [["an unrecognized answer"]])).toEqual({ outcome: "cancelled" }); + }); + + it("scopes canonical ACP events to every public session alias", () => { + const scoped = scopeAcpSessionEvent({ + type: "message.part.updated", + properties: { + part: { id: "text", type: "text", sessionID: "native-session", text: "hello" }, + }, + }, "public-session") as { properties: { part: { sessionID: string } } }; + const question = scopeAcpSessionEvent({ + type: "question.asked", + properties: { id: "permission-1", sessionID: "native-session", questions: [] }, + }, "public-session") as { properties: { sessionID: string } }; + + expect(scoped.properties.part.sessionID).toBe("public-session"); + expect(question.properties.sessionID).toBe("public-session"); + }); + + it("keeps ACP tool progress content and file changes readable", () => { + expect(stringifyAcpToolContent([ + { type: "content", content: { type: "text", text: "Reading the runtime" } }, + { type: "diff", path: "/tmp/repo/app.ts", oldText: "a", newText: "b" }, + { type: "terminal", terminalId: "terminal-1" }, + ])).toBe("Reading the runtime\nChanged /tmp/repo/app.ts\nTerminal: terminal-1"); + }); + + it("accumulates chunks within a message and resets on a new ACP message id", () => { + const first = appendAcpContentChunk({ text: "" }, { + messageId: "message-1", + content: { type: "text", text: "First " }, + }); + const completed = appendAcpContentChunk(first, { + messageId: "message-1", + content: { type: "text", text: "message" }, + }); + const second = appendAcpContentChunk(completed, { + messageId: "message-2", + content: { type: "text", text: "Second message" }, + }); + + expect(completed.text).toBe("First message"); + expect(second).toEqual({ messageId: "message-2", text: "Second message" }); + }); +}); diff --git a/packages/agents/test/agent-registry.test.ts b/packages/agents/test/agent-registry.test.ts index 59756b34..8276d352 100644 --- a/packages/agents/test/agent-registry.test.ts +++ b/packages/agents/test/agent-registry.test.ts @@ -28,11 +28,6 @@ describe("agent registry", () => { expect(getSelectedAgentProviderId()).toBe("kimi"); }); - it("selects kiro from env", () => { - process.env.ODE_AGENT_PROVIDER = "kiro"; - expect(getSelectedAgentProviderId()).toBe("kiro"); - }); - it("selects kilo from env", () => { process.env.ODE_AGENT_PROVIDER = "kilo"; expect(getSelectedAgentProviderId()).toBe("kilo"); @@ -48,11 +43,6 @@ describe("agent registry", () => { expect(getSelectedAgentProviderId()).toBe("goose"); }); - it("selects gemini from env", () => { - process.env.ODE_AGENT_PROVIDER = "gemini"; - expect(getSelectedAgentProviderId()).toBe("gemini"); - }); - for (const provider of ["pi", "openhands", "codebuddy", "crush"] as const) { it(`selects ${provider} from env`, () => { process.env.ODE_AGENT_PROVIDER = provider; @@ -64,11 +54,9 @@ describe("agent registry", () => { const opencode = getAgentProvider("opencode"); const claudecode = getAgentProvider("claudecode"); const kimi = getAgentProvider("kimi"); - const kiro = getAgentProvider("kiro"); const kilo = getAgentProvider("kilo"); const qwen = getAgentProvider("qwen"); const goose = getAgentProvider("goose"); - const gemini = getAgentProvider("gemini"); const pi = getAgentProvider("pi"); const openhands = getAgentProvider("openhands"); const codebuddy = getAgentProvider("codebuddy"); @@ -76,11 +64,9 @@ describe("agent registry", () => { expect(opencode.supportsEventStream).toBe(true); expect(claudecode.supportsEventStream).toBe(false); expect(kimi.supportsEventStream).toBe(false); - expect(kiro.supportsEventStream).toBe(false); expect(kilo.supportsEventStream).toBe(false); expect(qwen.supportsEventStream).toBe(false); expect(goose.supportsEventStream).toBe(false); - expect(gemini.supportsEventStream).toBe(false); expect(pi.supportsEventStream).toBe(false); expect(openhands.supportsEventStream).toBe(false); expect(codebuddy.supportsEventStream).toBe(false); diff --git a/packages/agents/test/claude-ask-user-question.test.ts b/packages/agents/test/claude-ask-user-question.test.ts index 86142b1a..22378926 100644 --- a/packages/agents/test/claude-ask-user-question.test.ts +++ b/packages/agents/test/claude-ask-user-question.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "bun:test"; -import { didClaudeDenyAskUserQuestion, extractAskUserQuestionToolUse, replyToQuestion } from "../claude/client"; +import { + CLAUDE_SDK_ALLOWED_TOOLS, + CLAUDE_SDK_PERMISSION_TOOLS, + CLAUDE_SDK_SETTINGS, + createClaudeSdkCanUseTool, + didClaudeDenyAskUserQuestion, + extractAskUserQuestionToolUse, + replyToQuestion, +} from "../claude/client"; import { createAgentAdapter } from "../adapter"; const ASSISTANT_WITH_ASK = JSON.stringify({ @@ -241,3 +249,82 @@ describe("Claude replyToQuestion guards", () => { ).rejects.toThrow(/No pending Claude question/); }); }); + +describe("Claude Agent SDK AskUserQuestion permission routing", () => { + it("keeps AskUserQuestion out of auto-allowed tools and forces an ask rule", () => { + expect(CLAUDE_SDK_ALLOWED_TOOLS).toEqual([]); + expect(CLAUDE_SDK_PERMISSION_TOOLS).toContain("AskUserQuestion"); + expect(CLAUDE_SDK_SETTINGS.permissions.ask).toContain("AskUserQuestion"); + }); + + it("allows known tools in the callback and fails closed for future tools", async () => { + const canUseTool = createClaudeSdkCanUseTool("claude-sdk-tool-policy"); + const control = { + signal: new AbortController().signal, + toolUseID: "toolu_policy", + requestId: "request_policy", + }; + await expect(canUseTool("Read", { file_path: "/tmp/readme" }, control)).resolves.toEqual({ + behavior: "allow", + updatedInput: { file_path: "/tmp/readme" }, + }); + await expect(canUseTool("FutureDangerousTool", {}, control)).resolves.toEqual({ + behavior: "deny", + message: "Ode has not approved the Claude tool FutureDangerousTool.", + }); + }); + + it("blocks in canUseTool until Ode supplies the user's answer", async () => { + const sessionId = "claude-sdk-question-session"; + const canUseTool = createClaudeSdkCanUseTool(sessionId); + const decisionPromise = canUseTool( + "AskUserQuestion", + { + questions: [{ + question: "Which database?", + header: "Database", + multiSelect: false, + options: [ + { label: "PostgreSQL", description: "Use Postgres" }, + { label: "SQLite", description: "Use SQLite" }, + ], + }], + }, + { + signal: new AbortController().signal, + toolUseID: "toolu_question", + requestId: "request_question", + } + ); + + await Promise.resolve(); + await replyToQuestion({ + sessionId, + requestId: "request_question", + answers: [["PostgreSQL"]], + }); + + await expect(decisionPromise).resolves.toMatchObject({ + behavior: "allow", + updatedInput: { + answers: { "Which database?": "PostgreSQL" }, + }, + }); + }); + + it("denies malformed AskUserQuestion calls instead of letting the CLI dialog hang", async () => { + const decision = await createClaudeSdkCanUseTool("claude-sdk-malformed-question")( + "AskUserQuestion", + { questions: [] }, + { + signal: new AbortController().signal, + toolUseID: "toolu_empty", + requestId: "request_empty", + } + ); + expect(decision).toEqual({ + behavior: "deny", + message: "AskUserQuestion contained no valid questions.", + }); + }); +}); diff --git a/packages/agents/test/claude-stream-status.test.ts b/packages/agents/test/claude-stream-status.test.ts index b6b84aa9..23e2cdb9 100644 --- a/packages/agents/test/claude-stream-status.test.ts +++ b/packages/agents/test/claude-stream-status.test.ts @@ -64,7 +64,7 @@ describe("claude stream status parsing", () => { expect(state.currentText).toBe("Hello world"); }); - it("tracks tool lifecycle and parsed input from raw events", () => { + it("keeps a tool running when only its streamed input block has ended", () => { const now = Date.now(); const state = buildSessionMessageState([ rawEvent(now, { @@ -97,13 +97,65 @@ describe("claude stream status parsing", () => { }), ]); - expect(state.phaseStatus).toBe("Finished tool: Read"); + expect(state.phaseStatus).toBe("Running tool: Read"); expect(state.tools.length).toBe(1); expect(state.tools[0]?.name).toBe("Read"); - expect(state.tools[0]?.status).toBe("completed"); + expect(state.tools[0]?.status).toBe("running"); expect(state.tools[0]?.input).toEqual({ filePath: "README.md" }); }); + it("does not resurrect a completed tool when the next message reuses its block index", () => { + const now = Date.now(); + const state = buildSessionMessageState([ + rawEvent(now, { + type: "stream_event", + event: { type: "message_start", message: { id: "message_tool" } }, + }), + rawEvent(now + 1, { + type: "stream_event", + event: { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "tool_read", name: "Read" }, + }, + }), + rawEvent(now + 2, { + type: "user", + message: { + content: [{ type: "tool_result", tool_use_id: "tool_read", content: "done" }], + }, + }), + rawEvent(now + 3, { + type: "stream_event", + event: { type: "message_start", message: { id: "message_text" } }, + }), + rawEvent(now + 4, { + type: "stream_event", + event: { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + }, + }), + rawEvent(now + 5, { + type: "stream_event", + event: { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "finished" }, + }, + }), + rawEvent(now + 6, { + type: "stream_event", + event: { type: "content_block_stop", index: 0 }, + }), + ], { provider: "claudecode" }); + + expect(state.currentText).toBe("finished"); + expect(state.tools.find((tool) => tool.id === "tool_read")?.status).toBe("completed"); + expect(state.phaseStatus).toBe("Finished step"); + }); + it("tracks tool lifecycle from assistant tool_use and user tool_result records", () => { const now = Date.now(); const state = buildSessionMessageState([ @@ -365,7 +417,117 @@ describe("claude stream status parsing", () => { expect(text).toContain("`Read` packages/core/index.ts"); expect(text).toContain("`Bash` ls -la"); - expect(text).toContain("`Task`"); + expect(text).toContain("`subagent`"); + }); + + it("renders real Claude task lifecycle events as one subagent", () => { + const now = Date.now() - 35_000; + const events = [ + rawEvent(now, { + type: "assistant", + message: { + content: [{ + type: "tool_use", + id: "call_agent_1", + name: "Agent", + input: { description: "Read package metadata", prompt: "Read package.json" }, + }], + }, + }), + rawEvent(now + 1, { + type: "system", + subtype: "task_started", + task_id: "task_1", + tool_use_id: "call_agent_1", + description: "Read package metadata", + subagent_type: "general-purpose", + }), + rawEvent(now + 2, { + type: "assistant", + parent_tool_use_id: "call_agent_1", + task_description: "Read package metadata", + message: { + content: [{ + type: "tool_use", + id: "child_read", + name: "Read", + input: { file_path: "/tmp/repo/package.json" }, + }], + }, + }), + rawEvent(now + 3, { + type: "system", + subtype: "task_progress", + task_id: "task_1", + tool_use_id: "call_agent_1", + description: "Reading package.json", + summary: "Checking package metadata", + last_tool_name: "Read", + usage: { total_tokens: 20, tool_uses: 1, duration_ms: 3000 }, + }), + ]; + const running = buildSessionMessageState(events); + + expect(running.tools).toHaveLength(1); + expect(running.tools[0]?.name).toBe("subagent"); + expect(running.tools[0]?.status).toBe("running"); + expect(running.tools[0]?.metadata?.lastTool).toBe("Read"); + expect(running.phaseStatus).toBe("Subagent Read package metadata: Checking package metadata"); + + const statusText = buildStatusMessageByProvider( + "claudecode", + { + channelId: "C1", + threadId: "T1", + statusMessageTs: "S1", + startedAt: now, + currentText: "", + }, + "/tmp/repo", + running, + "medium" + ); + expect(statusText).toContain("Waiting for subagent: Read package metadata"); + expect(statusText).not.toContain("`Read`"); + + const completed = buildSessionMessageState([ + ...events, + rawEvent(now + 4, { + type: "system", + subtype: "task_notification", + task_id: "task_1", + tool_use_id: "call_agent_1", + status: "completed", + summary: "ode 0.2.0", + }), + ]); + expect(completed.tools[0]?.status).toBe("completed"); + expect(completed.tools[0]?.output).toBe("ode 0.2.0"); + expect(completed.phaseStatus).toBe("Finished subagent: Read package metadata"); + }); + + it("shows Claude retry and precise result errors", () => { + const now = Date.now(); + const retrying = buildSessionMessageState([ + rawEvent(now, { + type: "system", + subtype: "api_retry", + attempt: 2, + max_retries: 4, + retry_delay_ms: 2500, + }), + ]); + expect(retrying.phaseStatus).toBe("Retrying Claude request 2/4 in 3s"); + + const failed = buildSessionMessageState([ + rawEvent(now, { + type: "result", + subtype: "error_during_execution", + is_error: true, + errors: ["upstream disconnected"], + }), + ]); + expect(failed.phaseStatus).toBe("Claude error: upstream disconnected"); }); it("uses frequency config for latest actions and shows last-N header", () => { @@ -407,7 +569,7 @@ describe("claude stream status parsing", () => { expect(text).toContain("`Read` file-9.ts"); }); - it("uses shared renderer format without inline response body", () => { + it("uses shared renderer format with a latest-output preview", () => { const now = Date.now(); const longResponse = `${"A".repeat(180)}\n\n${"B".repeat(180)}`; const state = buildSessionMessageState([ @@ -439,7 +601,8 @@ describe("claude stream status parsing", () => { ); expect(text).toContain("Drafting response"); - expect(text).not.toContain(longResponse); + expect(text).toContain("**Latest output**"); + expect(text).toContain(longResponse); }); it("falls back to claude header when title is unavailable", () => { diff --git a/packages/agents/test/cli-command.test.ts b/packages/agents/test/cli-command.test.ts index 1001b787..e9eb917d 100644 --- a/packages/agents/test/cli-command.test.ts +++ b/packages/agents/test/cli-command.test.ts @@ -4,20 +4,19 @@ import { buildOpenCodeCommand } from "../opencode/client"; import { buildClaudeCommand, buildClaudeCommandArgs } from "../claude/client"; import { buildCodexCommand, buildCodexCommandArgs } from "../codex/client"; import { buildKimiCommand, buildKimiCommandArgs } from "../kimi/client"; -import { buildKiroCommand, buildKiroCommandArgs } from "../kiro/client"; import { buildKiloCommand, buildKiloCommandArgs } from "../kilo/client"; import { buildQwenCommand, buildQwenCommandArgs } from "../qwen/client"; import { buildGooseCommand, buildGooseCommandArgs } from "../goose/client"; -import { buildGeminiCommand, buildGeminiCommandArgs } from "../gemini/client"; import { buildPiCommand, buildPiCommandArgs, parsePiResponse } from "../pi/client"; import { buildOpenHandsCommand, buildOpenHandsCommandArgs, parseOpenHandsResponse } from "../openhands/client"; import { buildCodeBuddyCommand, buildCodeBuddyCommandArgs, parseCodeBuddyResponse } from "../codebuddy/client"; import { buildCrushCommand, buildCrushCommandArgs, parseCrushResponse } from "../crush/client"; +import { createAgentInput } from "@/shared/agent-protocol"; describe("agent cli command formatting", () => { it("builds the final Claude CLI command", () => { const message = "hello world"; - const parts = buildPromptParts("C123", message); + const parts = buildPromptParts("C123", createAgentInput(message)); const prompt = buildPromptText(parts); const systemPrompt = buildSystemPrompt({ channelId: "C123", @@ -180,7 +179,7 @@ describe("agent cli command formatting", () => { expect(command).toContain("codex exec --json --skip-git-repo-check"); expect(command).toContain("--json"); - expect(command).toContain("--yolo"); + expect(command).toContain("--dangerously-bypass-approvals-and-sandbox"); expect(command).toContain("--model gpt-5-codex"); expect(command).toContain("session-3"); expect(command).toContain("'hello from codex'"); @@ -199,7 +198,7 @@ describe("agent cli command formatting", () => { expect(command).toContain("codex exec --json --skip-git-repo-check"); expect(command).toContain("--json"); expect(command).toContain("--sandbox read-only"); - expect(command).not.toContain("--yolo"); + expect(command).not.toContain("--dangerously-bypass-approvals-and-sandbox"); expect(command).toContain("session-3"); expect(command).toContain("'plan this change'"); }); @@ -260,22 +259,6 @@ describe("agent cli command formatting", () => { expect(command).toContain("-p 'hello from kimi'"); }); - it("builds the Kiro non-interactive command", () => { - const args = buildKiroCommandArgs({ - isNewSession: false, - prompt: "hello from kiro", - agent: "plan", - }); - const command = buildKiroCommand("kiro-cli", args); - - expect(command).toContain("kiro-cli chat"); - expect(command).toContain("--no-interactive"); - expect(command).toContain("--trust-all-tools"); - expect(command).toContain("--resume"); - expect(command).toContain("--agent plan"); - expect(command).toContain("'hello from kiro'"); - }); - it("builds the Kilo run command", () => { const args = buildKiloCommandArgs({ sessionId: "session-7", @@ -285,7 +268,8 @@ describe("agent cli command formatting", () => { }); const command = buildKiloCommand(args); - expect(command).toContain("kilo run --auto --format json"); + expect(command).toContain("kilo run --format json"); + expect(command).not.toContain("--auto"); expect(command).toContain("--session session-7"); expect(command).toContain("--agent plan"); expect(command).toContain("--model openai/gpt-4"); @@ -302,7 +286,7 @@ describe("agent cli command formatting", () => { const command = buildQwenCommand(args); expect(command).toContain("--approval-mode plan"); - expect(command).not.toContain("--yolo"); + expect(command).not.toContain("--approval-mode yolo"); expect(command).toContain("--resume session-5"); expect(command).toContain("-p 'plan migration'"); }); @@ -315,8 +299,10 @@ describe("agent cli command formatting", () => { }); const command = buildQwenCommand(args); - expect(command).toContain("--yolo"); + expect(command).toContain("--approval-mode auto"); + expect(command).not.toContain("--approval-mode yolo"); expect(command).not.toContain("--approval-mode plan"); + expect(command).toContain("--max-wall-time 10m --max-tool-calls 100"); }); it("builds the Goose run command", () => { @@ -345,36 +331,6 @@ describe("agent cli command formatting", () => { expect(command).toContain("--name session-9"); }); - it("builds the Gemini plan-mode command", () => { - const args = buildGeminiCommandArgs({ - sessionId: "session-10", - isNewSession: false, - prompt: "plan migration", - approvalMode: "plan", - model: "gemini-3.1-flash-lite", - }); - const command = buildGeminiCommand(args); - - expect(command).toContain("gemini"); - expect(command).toContain("--output-format stream-json"); - expect(command).toContain("--approval-mode plan"); - expect(command).toContain("--model gemini-3.1-flash-lite"); - expect(command).toContain("--resume session-10"); - expect(command).toContain("-p 'plan migration'"); - }); - - it("builds the Gemini default automation command", () => { - const args = buildGeminiCommandArgs({ - sessionId: "session-11", - isNewSession: true, - prompt: "implement feature", - }); - const command = buildGeminiCommand(args); - - expect(command).toContain("--approval-mode yolo"); - expect(command).not.toContain("--resume"); - }); - it("builds the Pi json command", () => { const args = buildPiCommandArgs({ sessionId: "session-12", @@ -415,7 +371,8 @@ describe("agent cli command formatting", () => { expect(command).toContain("--include-partial-messages"); expect(command).toContain("--session-id session-13"); expect(command).toContain("--model gpt-5.1"); - expect(command).toContain("--permission-mode bypassPermissions"); + expect(command).toContain("--permission-mode dontAsk"); + expect(command).not.toContain("bypassPermissions"); }); it("builds the Crush run command", () => { @@ -433,6 +390,17 @@ describe("agent cli command formatting", () => { expect(command).toContain("'hello from crush'"); }); + it("lets Crush use its own configured default model", () => { + const command = buildCrushCommand(buildCrushCommandArgs({ + sessionId: "session-14", + prompt: "hello from crush", + isNewSession: true, + })); + + expect(command).toContain("crush run --verbose"); + expect(command).not.toContain("--model"); + }); + it("parses new provider final responses", () => { expect(parsePiResponse([ JSON.stringify({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "OK" }] } }), diff --git a/packages/agents/test/codex-app-events.test.ts b/packages/agents/test/codex-app-events.test.ts new file mode 100644 index 00000000..206e7849 --- /dev/null +++ b/packages/agents/test/codex-app-events.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from "bun:test"; +import { + createCodexAppEventState, + isKnownCodexAppNotificationMethod, + normalizeCodexAppNotification, + type CodexAppSessionEvent, +} from "../codex/app-events"; +import { + CODEX_SERVER_REQUEST_METHODS, + getCodexServerRequestFallback, + isKnownCodexServerRequestMethod, +} from "../codex/app-server"; +import { buildSessionMessageState, type SessionEvent } from "../../utils/session-inspector"; + +function sessionEvents(events: CodexAppSessionEvent[], startedAt = Date.now()): SessionEvent[] { + return events.map((event, index) => ({ + timestamp: startedAt + index, + type: event.type, + data: { + properties: event.type === "message.part.updated" + ? { + ...event.properties, + part: { + ...(event.properties.part as Record), + sessionID: "ode-session", + }, + } + : event.properties, + }, + })); +} + +describe("Codex app-server event normalization", () => { + it("keeps child thread output and completion out of the parent conversation", () => { + const state = createCodexAppEventState(); + state.rootThreadId = "thread_root"; + const normalized: CodexAppSessionEvent[] = []; + + normalized.push(...normalizeCodexAppNotification(state, { + method: "item/agentMessage/delta", + params: { threadId: "thread_root", turnId: "turn_root", itemId: "root_msg", delta: "Delegating now." }, + })); + normalized.push(...normalizeCodexAppNotification(state, { + method: "item/completed", + params: { + threadId: "thread_root", + turnId: "turn_root", + item: { + type: "subAgentActivity", + id: "spawn_1", + kind: "started", + agentThreadId: "thread_child", + agentPath: "/root/package_identity", + }, + }, + })); + + const childText = normalizeCodexAppNotification(state, { + method: "item/agentMessage/delta", + params: { threadId: "thread_child", turnId: "turn_child", itemId: "child_msg", delta: "ode 0.2.0" }, + }); + const childTurn = normalizeCodexAppNotification(state, { + method: "turn/completed", + params: { threadId: "thread_child", turn: { id: "turn_child", status: "completed" } }, + }); + normalized.push(...childText, ...childTurn); + + expect(childText).toEqual([]); + expect(childTurn.map((event) => event.type)).toEqual(["message.part.updated"]); + expect(childTurn.some((event) => event.type === "session.status")).toBe(false); + + const renderedState = buildSessionMessageState(sessionEvents(normalized), { provider: "codex" }); + expect(renderedState.currentText).toBe("Delegating now."); + expect(renderedState.phaseStatus).not.toBe("Waiting"); + expect(renderedState.tools).toHaveLength(1); + expect(renderedState.tools[0]?.name).toBe("subagent"); + expect(renderedState.tools[0]?.title).toBe("package_identity"); + expect(renderedState.tools[0]?.status).toBe("completed"); + }); + + it("normalizes root status, usage, retries, command deltas, plans, and diffs", () => { + const state = createCodexAppEventState(); + state.rootThreadId = "thread_root"; + const events: CodexAppSessionEvent[] = []; + events.push(...normalizeCodexAppNotification(state, { + method: "thread/status/changed", + params: { threadId: "thread_root", status: { type: "active", activeFlags: [] } }, + })); + events.push(...normalizeCodexAppNotification(state, { + method: "thread/tokenUsage/updated", + params: { + threadId: "thread_root", + turnId: "turn_root", + tokenUsage: { + total: { + totalTokens: 120, + inputTokens: 80, + cachedInputTokens: 10, + outputTokens: 30, + reasoningOutputTokens: 5, + }, + }, + }, + })); + events.push(...normalizeCodexAppNotification(state, { + method: "error", + params: { threadId: "thread_root", turnId: "turn_root", willRetry: true, error: { message: "stream reset" } }, + })); + events.push(...normalizeCodexAppNotification(state, { + method: "item/started", + params: { + threadId: "thread_root", + turnId: "turn_root", + item: { type: "commandExecution", id: "cmd_1", command: "bun test", cwd: "/tmp/repo", status: "inProgress" }, + }, + })); + events.push(...normalizeCodexAppNotification(state, { + method: "item/commandExecution/outputDelta", + params: { threadId: "thread_root", turnId: "turn_root", itemId: "cmd_1", delta: "1 pass\n" }, + })); + events.push(...normalizeCodexAppNotification(state, { + method: "item/plan/delta", + params: { threadId: "thread_root", turnId: "turn_root", itemId: "plan_1", delta: "Inspect tests" }, + })); + events.push(...normalizeCodexAppNotification(state, { + method: "turn/diff/updated", + params: { threadId: "thread_root", turnId: "turn_root", diff: "diff --git a/a.ts b/a.ts" }, + })); + + const renderedState = buildSessionMessageState(sessionEvents(events), { provider: "codex" }); + expect(renderedState.tokenUsage?.total).toBe(120); + expect(renderedState.tools.find((tool) => tool.id === "cmd_1")?.output).toBe("1 pass\n"); + expect(renderedState.tools.some((tool) => tool.id === "codex-diff:turn_root")).toBe(true); + expect(events.some((event) => event.type === "session.status" + && JSON.stringify(event.properties).includes("Retrying"))).toBe(false); + expect(events.some((event) => event.type === "session.status" + && JSON.stringify(event.properties).includes("retry"))).toBe(true); + }); + + it("detects future notification drift", () => { + expect(isKnownCodexAppNotificationMethod("thread/tokenUsage/updated")).toBe(true); + expect(isKnownCodexAppNotificationMethod("thread/futureProtocol/changed")).toBe(false); + }); + + it("renders unsupported server requests as an actionable integration status", () => { + const state = createCodexAppEventState(); + state.rootThreadId = "thread_root"; + const events = normalizeCodexAppNotification(state, { + method: "ode/serverRequest/failed", + params: { + threadId: "thread_root", + requestMethod: "future/request", + protocolKnown: false, + message: "Ode does not support server request future/request", + }, + }); + expect(events).toEqual([{ + type: "session.status", + properties: { status: "Codex integration update required: future/request" }, + }]); + }); +}); + +describe("Codex app-server request fallbacks", () => { + it("declines interactive MCP elicitation without breaking the protocol", () => { + expect(getCodexServerRequestFallback("mcpServer/elicitation/request")).toEqual({ + kind: "result", + result: { action: "decline", content: null, _meta: null }, + }); + }); + + it("returns a failed dynamic tool result instead of method-not-found", () => { + const fallback = getCodexServerRequestFallback("item/tool/call"); + expect(fallback.kind).toBe("result"); + if (fallback.kind === "result") { + expect(fallback.result.success).toBe(false); + } + }); + + it("never auto-approves command or file changes", () => { + expect(getCodexServerRequestFallback("item/commandExecution/requestApproval")).toEqual({ + kind: "result", + result: { decision: "decline" }, + }); + expect(getCodexServerRequestFallback("item/fileChange/requestApproval")).toEqual({ + kind: "result", + result: { decision: "decline" }, + }); + expect(getCodexServerRequestFallback("execCommandApproval")).toEqual({ + kind: "result", + result: { decision: { denied: { rejection: "Ode did not receive explicit user approval." } } }, + }); + }); + + it("handles every request in the currently generated Codex protocol surface", () => { + for (const method of CODEX_SERVER_REQUEST_METHODS) { + expect(isKnownCodexServerRequestMethod(method)).toBe(true); + const fallback = getCodexServerRequestFallback(method, 1_785_665_000_000); + if (fallback.kind === "error") { + expect(fallback.error.code).not.toBe(-32601); + } + } + expect(getCodexServerRequestFallback("currentTime/read", 1_785_665_000_000)).toEqual({ + kind: "result", + result: { currentTimeAt: 1_785_665_000 }, + }); + }); + + it("keeps unknown requests explicit", () => { + expect(isKnownCodexServerRequestMethod("future/request")).toBe(false); + expect(getCodexServerRequestFallback("future/request")).toEqual({ + kind: "error", + error: { code: -32601, message: "Ode does not support server request future/request" }, + }); + }); +}); diff --git a/packages/agents/test/gemini-stream-status.test.ts b/packages/agents/test/gemini-stream-status.test.ts deleted file mode 100644 index 8c5d4172..00000000 --- a/packages/agents/test/gemini-stream-status.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { buildSessionMessageState } from "../../utils/session-inspector"; -import { buildStatusMessageByProvider } from "../../utils/status"; - -function rawEvent(timestamp: number, record: Record) { - return { - timestamp, - type: `gemini.raw.${String(record.type ?? "unknown")}`, - data: { - properties: { - record, - }, - }, - }; -} - -describe("gemini stream status parsing", () => { - it("tracks tool lifecycle and assistant deltas", () => { - const now = Date.now(); - const state = buildSessionMessageState([ - rawEvent(now, { - type: "init", - session_id: "s1", - }), - rawEvent(now + 1, { - type: "tool_use", - tool_name: "read_file", - tool_id: "tool-1", - parameters: { file_path: "README.md" }, - }), - rawEvent(now + 2, { - type: "tool_result", - tool_id: "tool-1", - status: "success", - output: "ok", - }), - rawEvent(now + 3, { - type: "message", - role: "assistant", - content: "Hello", - delta: true, - }), - rawEvent(now + 4, { - type: "message", - role: "assistant", - content: " world", - delta: true, - }), - ]); - - expect(state.tools[0]?.name).toBe("read_file"); - expect(state.tools[0]?.status).toBe("completed"); - expect(state.currentText).toBe("Hello world"); - expect(state.phaseStatus).toBe("Drafting response"); - }); - - it("uses gemini fallback header when title is missing", () => { - const now = Date.now(); - const state = buildSessionMessageState([ - rawEvent(now, { - type: "init", - session_id: "s2", - }), - ]); - - const text = buildStatusMessageByProvider( - "gemini", - { - channelId: "C1", - threadId: "T1", - statusMessageTs: "S1", - startedAt: now, - currentText: "", - }, - "/tmp/repo", - state, - "medium" - ); - - expect(text).toContain("*Gemini is running...*"); - }); - - it("renders Gemini CLI errors as live status content", () => { - const now = Date.now(); - const state = buildSessionMessageState([ - rawEvent(now, { - type: "init", - session_id: "s3", - }), - rawEvent(now + 1, { - type: "error", - error: { - message: "Gemini CLI timed out", - }, - }), - ]); - - const text = buildStatusMessageByProvider( - "gemini", - { - channelId: "C1", - threadId: "T1", - statusMessageTs: "S1", - startedAt: now, - currentText: "", - }, - "/tmp/repo", - state, - "medium" - ); - - expect(text).toContain("Gemini error: Gemini CLI timed out"); - }); -}); diff --git a/packages/agents/test/goose-stream-status.test.ts b/packages/agents/test/goose-stream-status.test.ts index 9763b054..125b2d7a 100644 --- a/packages/agents/test/goose-stream-status.test.ts +++ b/packages/agents/test/goose-stream-status.test.ts @@ -262,7 +262,7 @@ describe("goose stream status parsing", () => { { content: "Read README", status: "completed" }, { content: "Write report", status: "in_progress" }, ]); - expect(text).toContain("*Tasks*"); + expect(text).toContain("**Plan**"); expect(text).toContain("- [ ] Inspect repository"); expect(text).toContain("- [x] Read README"); expect(text).toContain("- [~] Write report"); diff --git a/packages/agents/test/kilo-stream-status.test.ts b/packages/agents/test/kilo-stream-status.test.ts index 1ac12413..495291bb 100644 --- a/packages/agents/test/kilo-stream-status.test.ts +++ b/packages/agents/test/kilo-stream-status.test.ts @@ -55,7 +55,7 @@ describe("kilo stream status parsing", () => { { content: "Inspect workspace", status: "in_progress" }, { content: "Write report", status: "pending" }, ]); - expect(text).toContain("*Tasks*"); + expect(text).toContain("**Plan**"); expect(text).toContain("- [~] Inspect workspace"); expect(text).toContain("- [ ] Write report"); }); diff --git a/packages/agents/test/kiro-client.test.ts b/packages/agents/test/kiro-client.test.ts deleted file mode 100644 index 234b4e3a..00000000 --- a/packages/agents/test/kiro-client.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { extractKiroFinalResponse, sanitizeKiroOutput } from "../kiro/client"; - -describe("kiro client output parsing", () => { - it("strips ANSI sequences from kiro output", () => { - const output = "\u001b[38;5;141m> Hello\u001b[0m\n\u001b[38;5;10m ✓ \u001b[0mDone"; - const text = sanitizeKiroOutput(output); - - expect(text).toContain("> Hello"); - expect(text).toContain("✓ Done"); - expect(text).not.toContain("\u001b["); - }); - - it("extracts final assistant section from kiro transcript", () => { - const output = [ - "> I will scan the repository first.", - "Searching for: TODO (using tool: grep)", - "✓ Successfully found 3 matches", - "> Most important issue is inconsistent error handling.", - "## Plan", - "1. Add shared error wrapper", - ].join("\n"); - - const text = extractKiroFinalResponse(output); - expect(text).toContain("Most important issue is inconsistent error handling."); - expect(text).toContain("## Plan"); - expect(text).not.toContain("using tool:"); - expect(text).not.toContain("Successfully found"); - }); -}); diff --git a/packages/agents/test/kiro-stream-status.test.ts b/packages/agents/test/kiro-stream-status.test.ts deleted file mode 100644 index a5f5344e..00000000 --- a/packages/agents/test/kiro-stream-status.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { buildSessionMessageState } from "../../utils/session-inspector"; -import { buildLiveStatusMessage, buildStatusMessageByProvider } from "../../utils/status"; - -describe("kiro stream status parsing", () => { - it("renders status from normalized session events", () => { - const now = Date.now(); - const state = buildSessionMessageState([ - { - timestamp: now, - type: "session.status", - data: { - properties: { - status: { - type: "busy", - }, - }, - }, - }, - { - timestamp: now + 1, - type: "message.part.updated", - data: { - properties: { - part: { - id: "kiro-text", - type: "text", - text: "Investigating the codebase now.", - }, - }, - }, - }, - ]); - - expect(state.phaseStatus).toBe("Working"); - expect(state.currentText).toBe("Investigating the codebase now."); - - const text = buildLiveStatusMessage( - { - channelId: "C1", - threadId: "T1", - statusMessageTs: "S1", - startedAt: now, - currentText: "", - }, - "/tmp/repo", - state, - "medium" - ); - - expect(text).toContain("Working"); - }); - - it("renders tool execution details for kiro tool events", () => { - const now = Date.now(); - const state = buildSessionMessageState([ - { - timestamp: now, - type: "message.part.updated", - data: { - properties: { - part: { - id: "kiro-tool-1", - type: "tool", - tool: "Grep", - state: { - status: "completed", - title: "Search TODO", - input: { - pattern: "TODO|FIXME", - path: "/tmp/repo", - }, - }, - }, - }, - }, - }, - ]); - - const text = buildLiveStatusMessage( - { - channelId: "C1", - threadId: "T1", - statusMessageTs: "S1", - startedAt: now, - currentText: "", - }, - "/tmp/repo", - state, - "medium" - ); - - expect(text).toContain("Tool execution"); - expect(text).toContain("`Grep` TODO|FIXME in tmp/repo"); - }); - - it("moves task tools into todos and keeps bash details", () => { - const now = Date.now(); - const state = buildSessionMessageState([ - { - timestamp: now, - type: "message.part.updated", - data: { - properties: { - part: { - id: "kiro-tool-1", - type: "tool", - tool: "Task", - state: { - status: "running", - title: "Outline plan", - }, - }, - }, - }, - }, - { - timestamp: now + 1, - type: "message.part.updated", - data: { - properties: { - part: { - id: "kiro-tool-2", - type: "tool", - tool: "Bash", - state: { - status: "completed", - input: { - command: "rm -rf /tmp/work", - }, - }, - }, - }, - }, - }, - ]); - - const text = buildStatusMessageByProvider( - "kiro", - { - channelId: "C1", - threadId: "T1", - statusMessageTs: "S1", - startedAt: now, - currentText: "", - }, - "/tmp/repo", - state, - "medium" - ); - - expect(text).toContain("*Tasks*"); - expect(text).toContain("- [~] Outline plan"); - expect(text).toContain("`Bash`"); - expect(text).not.toContain("`Task`"); - expect(text).toContain("rm -rf /tmp/work"); - }); - -}); diff --git a/packages/agents/test/opencode-events.test.ts b/packages/agents/test/opencode-events.test.ts new file mode 100644 index 00000000..338253e9 --- /dev/null +++ b/packages/agents/test/opencode-events.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "bun:test"; +import { + extractOpenCodeChildSession, + getOpenCodeEventContext, + getOpenCodeEventFingerprint, + normalizeOpenCodeGlobalEvent, + normalizeOpenCodePermissionQuestion, + parseOpenCodePermissionReply, +} from "../opencode/events"; +import { extractEventRootSessionId, extractEventSessionId } from "@/utils/session-id"; + +describe("OpenCode global event normalization", () => { + it("unwraps sync events and preserves root/child session context", () => { + const normalized = normalizeOpenCodeGlobalEvent({ + directory: "/tmp/repo", + payload: { + type: "sync", + id: "outer-1", + syncEvent: { + id: "event-1", + type: "message.part.updated.1", + seq: 42, + aggregateID: "child-1", + data: { + sessionID: "child-1", + part: { + id: "part-1", + sessionID: "child-1", + messageID: "message-1", + type: "text", + text: "Auditing routes", + }, + }, + }, + }, + }, { + rootSessionId: "root-1", + childTitle: () => "Repository audit", + }); + + expect(normalized?.payload.type).toBe("message.part.updated"); + expect(extractEventSessionId(normalized?.payload)).toBe("child-1"); + expect(extractEventRootSessionId(normalized?.payload)).toBe("root-1"); + expect(getOpenCodeEventContext(normalized?.payload)).toEqual({ + rootSessionID: "root-1", + sourceSessionID: "child-1", + childSession: true, + childTitle: "Repository audit", + transportType: "sync", + syncSequence: 42, + }); + }); + + it("discovers an OpenCode task child session", () => { + expect(extractOpenCodeChildSession({ + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "task", + state: { + title: "Audit docs", + metadata: { parentSessionId: "root-1", sessionId: "child-1" }, + }, + }, + }, + })).toEqual({ + sessionId: "child-1", + parentSessionId: "root-1", + title: "Audit docs", + }); + }); + + it("gives direct and sync copies the same semantic fingerprint", () => { + const part = { + id: "part-1", + sessionID: "root-1", + type: "tool", + tool: "read", + state: { status: "completed", title: "README.md", output: "done" }, + }; + const direct = normalizeOpenCodeGlobalEvent({ + payload: { type: "message.part.updated", properties: { sessionID: "root-1", part } }, + }, { rootSessionId: "root-1" }); + const sync = normalizeOpenCodeGlobalEvent({ + payload: { + type: "sync", + syncEvent: { + id: "event-2", + type: "message.part.updated.1", + seq: 9, + aggregateID: "root-1", + data: { sessionID: "root-1", part }, + }, + }, + }, { rootSessionId: "root-1" }); + + expect(getOpenCodeEventFingerprint(direct?.payload)).toBe( + getOpenCodeEventFingerprint(sync?.payload) + ); + }); + + it("turns permission requests into explicit user questions", () => { + const question = normalizeOpenCodePermissionQuestion({ + type: "permission.asked", + properties: { + id: "permission-1", + sessionID: "session-1", + permission: "run shell commands", + patterns: ["git status"], + metadata: { cwd: "/tmp/repo" }, + }, + }); + + expect(question?.type).toBe("question.asked"); + expect(question?.properties).toMatchObject({ + id: "permission-1", + sessionID: "session-1", + questions: [{ + custom: false, + options: [ + { label: "Allow once" }, + { label: "Always allow" }, + { label: "Reject" }, + ], + }], + }); + expect(parseOpenCodePermissionReply([["Allow once"]])).toBe("once"); + expect(parseOpenCodePermissionReply([["Always allow"]])).toBe("always"); + expect(parseOpenCodePermissionReply([["unexpected free-form reply"]])).toBe("reject"); + }); +}); diff --git a/packages/agents/test/opencode-prompt-monitor.test.ts b/packages/agents/test/opencode-prompt-monitor.test.ts new file mode 100644 index 00000000..b997239f --- /dev/null +++ b/packages/agents/test/opencode-prompt-monitor.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "bun:test"; +import { + isOpenCodePromptIdleTimedOut, + monitorOpenCodePrompt, + OpenCodeIdlePromptError, +} from "../opencode/prompt-monitor"; + +describe("OpenCode prompt idle recovery", () => { + it("does not time out while a related child session is busy", () => { + expect(isOpenCodePromptIdleTimedOut({ + now: 10_000, + timeoutMs: 1_000, + health: { + relatedSessionIds: ["root", "child"], + lastMeaningfulEventAt: 0, + awaitingInteraction: false, + statuses: { child: { type: "busy" } }, + }, + })).toBe(false); + }); + + it("does not time out while waiting for a user answer", () => { + expect(isOpenCodePromptIdleTimedOut({ + now: 10_000, + timeoutMs: 1_000, + health: { + relatedSessionIds: ["root"], + lastMeaningfulEventAt: 0, + awaitingInteraction: true, + statuses: {}, + }, + })).toBe(false); + }); + + it("aborts an idle prompt that never resolves", async () => { + let aborted = false; + const never = new Promise(() => {}); + await expect(monitorOpenCodePrompt({ + prompt: never, + timeoutMs: 1, + pollIntervalMs: 1, + readHealth: async () => ({ + relatedSessionIds: ["root"], + lastMeaningfulEventAt: 0, + awaitingInteraction: false, + statuses: {}, + }), + abort: async () => { + aborted = true; + }, + })).rejects.toBeInstanceOf(OpenCodeIdlePromptError); + expect(aborted).toBe(true); + }); +}); diff --git a/packages/agents/test/openhands-stream-status.test.ts b/packages/agents/test/openhands-stream-status.test.ts index bb403b91..f55ac591 100644 --- a/packages/agents/test/openhands-stream-status.test.ts +++ b/packages/agents/test/openhands-stream-status.test.ts @@ -15,6 +15,15 @@ function rawEvent(timestamp: number, record: Record) { } describe("openhands stream status parsing", () => { + it("recognizes OpenHands system prompt setup records", () => { + const state = buildSessionMessageState([rawEvent(Date.now(), { + kind: "SystemPromptEvent", + source: "agent", + })]); + + expect(state.phaseStatus).toBe("Preparing OpenHands context"); + }); + it("renders startup progress while the CLI buffers JSON output", () => { const now = Date.now(); const state = buildSessionMessageState([ diff --git a/packages/agents/test/pi-stream-status.test.ts b/packages/agents/test/pi-stream-status.test.ts index aabb59d3..260d9df0 100644 --- a/packages/agents/test/pi-stream-status.test.ts +++ b/packages/agents/test/pi-stream-status.test.ts @@ -96,4 +96,31 @@ describe("pi stream status parsing", () => { expect(text).toContain("Tool execution"); expect(text).toContain("`find` *.ts"); }); + + it("tracks Pi 0.83 tool execution lifecycle records", () => { + const now = Date.now(); + const state = buildSessionMessageState([ + rawEvent(now, { + type: "tool_execution_start", + toolCallId: "tool-3", + toolName: "read", + args: { path: "/tmp/repo/package.json" }, + }), + rawEvent(now + 1, { + type: "tool_execution_end", + toolCallId: "tool-3", + toolName: "read", + result: { content: [{ type: "text", text: "version 0.2.0" }], isError: false }, + }), + ]); + + expect(state.tools[0]).toMatchObject({ + id: "tool-3", + name: "read", + status: "completed", + title: "/tmp/repo/package.json", + output: "version 0.2.0", + }); + expect(state.phaseStatus).toBe("Finished tool: read - /tmp/repo/package.json"); + }); }); diff --git a/packages/agents/test/protocol-drift.test.ts b/packages/agents/test/protocol-drift.test.ts new file mode 100644 index 00000000..1ce79088 --- /dev/null +++ b/packages/agents/test/protocol-drift.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "bun:test"; +import { inspectCliProtocol } from "../runtime/protocol-drift"; + +describe("CLI protocol drift detection", () => { + it("accepts known nested stream events and flags new ones", () => { + expect(inspectCliProtocol({ + providerName: "Qwen", + recordType: "stream_event", + streamEventType: "content_block_delta", + knownRecordTypes: ["stream_event"], + anthropicStyleStream: true, + }).protocolKnown).toBe(true); + + expect(inspectCliProtocol({ + providerName: "Qwen", + recordType: "stream_event", + streamEventType: "new_protocol_event", + knownRecordTypes: ["stream_event"], + anthropicStyleStream: true, + })).toEqual({ + protocolKnown: false, + protocolLabel: "Qwen stream new_protocol_event", + }); + }); +}); diff --git a/packages/agents/test/session-inspector.test.ts b/packages/agents/test/session-inspector.test.ts index 7539c853..50f84199 100644 --- a/packages/agents/test/session-inspector.test.ts +++ b/packages/agents/test/session-inspector.test.ts @@ -3,6 +3,50 @@ import { buildSessionMessageState } from "../../utils/session-inspector"; import { buildLiveStatusMessage } from "../../utils/status"; describe("session inspector", () => { + it("hydrates child-session tool metadata from normalized OpenCode events", () => { + const startedAt = Date.now() - 40_000; + const state = buildSessionMessageState([{ + timestamp: startedAt, + type: "message.part.updated", + data: { + type: "message.part.updated", + odeContext: { + rootSessionID: "root-1", + sourceSessionID: "child-1", + childSession: true, + childTitle: "Audit docs", + }, + properties: { + sessionID: "child-1", + odeContext: { + rootSessionID: "root-1", + sourceSessionID: "child-1", + childSession: true, + childTitle: "Audit docs", + }, + part: { + id: "tool-child", + sessionID: "child-1", + type: "tool", + tool: "read", + state: { + status: "running", + title: "README.md", + time: { start: startedAt }, + }, + }, + }, + }, + }], { provider: "opencode" }); + + expect(state.tools[0]?.metadata).toMatchObject({ + startedAtMs: startedAt, + sourceSessionId: "child-1", + childSession: true, + childTitle: "Audit docs", + }); + }); + it("parses wrapped OpenCode payload events", () => { const now = Date.now(); const state = buildSessionMessageState([ @@ -261,6 +305,20 @@ describe("session inspector", () => { expect(state.sessionTitle).toBeUndefined(); }); + it("does not expose injected Ode system context as the session title", () => { + const state = buildSessionMessageState([{ + timestamp: Date.now(), + type: "session.updated", + data: { + properties: { + title: "\nODE RUNTIME CONTEXT:\n- Platform: slack", + }, + }, + }], { provider: "codebuddy" }); + + expect(state.sessionTitle).toBeUndefined(); + }); + it("prefers summarized title over sibling slug", () => { const startedAt = Date.now(); const state = buildSessionMessageState([ @@ -483,6 +541,52 @@ describe("session inspector", () => { expect(state.tokenUsage?.total).toBe(300); }); + it("keeps Claude cumulative token usage monotonic across nested progress records", () => { + const startedAt = Date.now(); + const state = buildSessionMessageState([ + { + timestamp: startedAt, + type: "claude.raw.assistant", + data: { + payload: { + type: "claude.raw.assistant", + properties: { + record: { + type: "assistant", + usage: { + input_tokens: 20_000, + output_tokens: 3_000, + }, + }, + }, + }, + }, + }, + { + timestamp: startedAt + 1, + type: "claude.raw.tool_progress", + data: { + payload: { + type: "claude.raw.tool_progress", + properties: { + record: { + type: "tool_progress", + usage: { + input_tokens: 1_000, + output_tokens: 200, + }, + }, + }, + }, + }, + }, + ], { provider: "claudecode" }); + + expect(state.tokenUsage?.input).toBe(20_000); + expect(state.tokenUsage?.output).toBe(3_000); + expect(state.tokenUsage?.total).toBe(23_000); + }); + it("hydrates OpenCode model when info.model is an object", () => { const startedAt = Date.now(); const state = buildSessionMessageState([ @@ -757,4 +861,22 @@ describe("session inspector", () => { expect(state.currentText).toBe("Here is the summary of changes"); }); + + it("keeps unknown provider protocol events visible in live status", () => { + const state = buildSessionMessageState([{ + timestamp: Date.now(), + type: "qwen.raw.stream_event", + data: { + properties: { + record: { type: "stream_event", event: { type: "future_event" } }, + recordType: "stream_event", + streamEventType: "future_event", + protocolKnown: false, + protocolLabel: "Qwen stream future_event", + }, + }, + }], { provider: "qwen" }); + + expect(state.phaseStatus).toBe("Qwen Code integration update required: Qwen stream future_event"); + }); }); diff --git a/packages/agents/types.ts b/packages/agents/types.ts index c0a330be..b20c7011 100644 --- a/packages/agents/types.ts +++ b/packages/agents/types.ts @@ -1,3 +1,11 @@ +import type { + AgentCapabilities, + AgentInput, + AgentInputPart, + AgentSessionBinding, + AgentTransport, +} from "@/shared/agent-protocol"; + export interface OpenCodeMessage { text: string; messageType: "assistant" | "result" | "system" | "user" | "notify"; @@ -33,6 +41,15 @@ export interface OpenCodeMessageContext { export interface OpenCodeSessionInfo { sessionId: string; created: boolean; + binding?: AgentSessionBinding; } -export type PromptPart = { type: "text"; text: string }; +export type PromptPart = AgentInputPart; + +export type { + AgentCapabilities, + AgentInput, + AgentInputPart, + AgentSessionBinding, + AgentTransport, +} from "@/shared/agent-protocol"; diff --git a/packages/config/dashboard-config.ts b/packages/config/dashboard-config.ts index b568d715..012d2089 100644 --- a/packages/config/dashboard-config.ts +++ b/packages/config/dashboard-config.ts @@ -45,9 +45,6 @@ export type DashboardConfig = { kimi: { enabled: boolean; }; - kiro: { - enabled: boolean; - }; kilo: { enabled: boolean; models: string[]; @@ -58,9 +55,6 @@ export type DashboardConfig = { goose: { enabled: boolean; }; - gemini: { - enabled: boolean; - }; pi: { enabled: boolean; models: string[]; @@ -84,7 +78,6 @@ export type DashboardConfig = { name: string; domain: string; status: "active" | "paused"; - slackStatusMode?: "ai_card" | "legacy"; channels: number; members: number; lastSync: string; @@ -112,7 +105,6 @@ const defaultWorkspace: DashboardConfig["workspaces"][number] = { name: "Workspace 1", domain: "", status: "active", - slackStatusMode: "ai_card", channels: 0, members: 0, lastSync: "", @@ -170,9 +162,6 @@ const asGitStrategy = ( const asStatus = (value: unknown): DashboardConfig["workspaces"][number]["status"] => value === "paused" ? "paused" : "active"; -const asSlackStatusMode = (value: unknown): NonNullable => - value === "legacy" ? "legacy" : "ai_card"; - const asAgentProvider = ( value: unknown ): DashboardConfig["workspaces"][number]["channelDetails"][number]["agentProvider"] => @@ -268,7 +257,6 @@ const sanitizeWorkspace = ( name: asString(workspace.name) || fallbackName, domain: asString(workspace.domain), status: asStatus(workspace.status), - slackStatusMode: asSlackStatusMode(workspace.slackStatusMode), channels: asNumber(workspace.channels), members: asNumber(workspace.members), lastSync: asString(workspace.lastSync), diff --git a/packages/config/index.ts b/packages/config/index.ts index 800414c0..26ce3a21 100644 --- a/packages/config/index.ts +++ b/packages/config/index.ts @@ -30,7 +30,6 @@ export { getSlackAppTokens, getSlackBotTokens, getSlackTargetChannels, - getSlackStatusModeForChannel, getDiscordBotTokens, getDiscordTargetChannels, getLarkAppCredentials, diff --git a/packages/config/local/inbox.test.ts b/packages/config/local/inbox.test.ts index a7b80985..930c1fb9 100644 --- a/packages/config/local/inbox.test.ts +++ b/packages/config/local/inbox.test.ts @@ -10,13 +10,16 @@ import { completeAgentResult, ensureMessageThread, failAgentResult, + getOdeRunEvents, getMessageThreadById, getMessageThreadPage, recordAgentQuestion, + recordOdeRunEvents, recordQuestionReply, recordUserPrompt, startAgentResult, } from "@/config/local/inbox"; +import { createOdeRunEvent } from "@/core/runtime/ode-run-events"; const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ode-inbox-test-")); const inboxDbFile = path.join(tempDir, "inbox.db"); @@ -94,6 +97,34 @@ describe("local inbox store", () => { expect(detail?.context?.isFirstMessageInThread).toBe(true); }); + it("persists ordered canonical run events with raw provider payloads", () => { + const threadKey = buildThreadKey("C-events", "T-events"); + ensureMessageThread({ + platform: "discord", + channelId: "C-events", + threadId: "T-events", + replyThreadId: "T-events", + sessionId: "session-events", + providerId: "kimi", + }); + const started = createOdeRunEvent( + { providerId: "kimi", sessionId: "session-events", runId: "run-1", timestamp: 10 }, + "run.started", + { transport: "acp" } + ); + const raw = createOdeRunEvent( + { providerId: "kimi", sessionId: "session-events", runId: "run-1", timestamp: 20 }, + "provider.raw", + { providerType: "agent_message_chunk" }, + { rawEvent: { type: "kimi.acp.agent_message_chunk" } } + ); + recordOdeRunEvents(threadKey, [raw, started]); + + const events = getOdeRunEvents({ threadKey, runId: "run-1" }); + expect(events.map((event) => event.type)).toEqual(["run.started", "provider.raw"]); + expect(events[1]?.rawEvent).toEqual({ type: "kimi.acp.agent_message_chunk" }); + }); + it("records cron job source metadata at the thread level", () => { const threadKey = buildThreadKey("C-cron", "cron-job:job-1"); ensureMessageThread({ diff --git a/packages/config/local/inbox.ts b/packages/config/local/inbox.ts index 5d3c2c06..da59c2e1 100644 --- a/packages/config/local/inbox.ts +++ b/packages/config/local/inbox.ts @@ -3,6 +3,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { loadOdeConfig } from "./ode-store"; +import type { OdeRunEvent } from "@/shared/agent-protocol"; // --------------------------------------------------------------------------- // Schema @@ -107,6 +108,12 @@ export interface MessageThreadPage { totalPages: number; } +export interface OdeRunEventPage { + items: OdeRunEvent[]; + total: number; + limit: number; +} + export interface EnsureMessageThreadParams { platform: PlatformId; channelId: string; @@ -336,11 +343,28 @@ function initializeDatabase(db: Database): void { updated_at INTEGER NOT NULL ); `); + db.exec(` + CREATE TABLE IF NOT EXISTS ode_run_event ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES message_thread(id) ON DELETE CASCADE, + schema_version INTEGER NOT NULL, + timestamp INTEGER NOT NULL, + type TEXT NOT NULL, + provider_id TEXT NOT NULL, + session_id TEXT NOT NULL, + run_id TEXT, + item_id TEXT, + data_json TEXT NOT NULL, + raw_event_json TEXT + ); + `); db.exec("CREATE INDEX IF NOT EXISTS idx_message_thread_last_at ON message_thread(last_message_at DESC);"); db.exec("CREATE INDEX IF NOT EXISTS idx_message_thread_source ON message_thread(source_kind, last_message_at DESC);"); db.exec("CREATE INDEX IF NOT EXISTS idx_message_detail_thread_seq ON message_detail(thread_id, seq);"); db.exec("CREATE INDEX IF NOT EXISTS idx_message_detail_question ON message_detail(question_source_id);"); db.exec("CREATE INDEX IF NOT EXISTS idx_message_detail_status ON message_detail(status);"); + db.exec("CREATE INDEX IF NOT EXISTS idx_ode_run_event_thread_time ON ode_run_event(thread_id, timestamp);"); + db.exec("CREATE INDEX IF NOT EXISTS idx_ode_run_event_run ON ode_run_event(run_id, timestamp);"); // Lightweight migrations for DBs created before the columns existed. // sqlite's `ALTER TABLE ... ADD COLUMN` is idempotent-friendly if we first @@ -1018,13 +1042,107 @@ export function getMessageDetailById(detailId: string): MessageDetail | null { return row ? mapDetailRow(row) : null; } +export function recordOdeRunEvents(threadKey: string, events: readonly OdeRunEvent[]): void { + if (events.length === 0) return; + const db = getDatabase(); + const threadExists = db.query("SELECT 1 AS present FROM message_thread WHERE id = ?").get(threadKey); + if (!threadExists) return; + const insert = db.query( + `INSERT OR IGNORE INTO ode_run_event ( + id, thread_id, schema_version, timestamp, type, + provider_id, session_id, run_id, item_id, data_json, raw_event_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + db.transaction((batch: readonly OdeRunEvent[]) => { + for (const event of batch) { + insert.run( + event.id, + threadKey, + event.schemaVersion, + event.timestamp, + event.type, + event.providerId, + event.sessionId, + event.runId ?? null, + event.itemId ?? null, + toJsonText(event.data), + toJsonText(event.rawEvent ?? null), + ); + } + })(events); +} + +function mapOdeRunEventRow(row: Record): OdeRunEvent { + return { + id: String(row.id), + schemaVersion: Number(row.schema_version) as OdeRunEvent["schemaVersion"], + timestamp: Number(row.timestamp), + type: String(row.type) as OdeRunEvent["type"], + providerId: String(row.provider_id) as OdeRunEvent["providerId"], + sessionId: String(row.session_id), + runId: typeof row.run_id === "string" ? row.run_id : undefined, + itemId: typeof row.item_id === "string" ? row.item_id : undefined, + data: safeJsonParse>(String(row.data_json)) ?? {}, + rawEvent: typeof row.raw_event_json === "string" + ? safeJsonParse>(row.raw_event_json) ?? undefined + : undefined, + }; +} + +export function getOdeRunEvents(params: { + threadKey: string; + runId?: string; +}): OdeRunEvent[] { + const db = getDatabase(); + const rows = params.runId + ? db.query( + `SELECT * FROM ode_run_event + WHERE thread_id = ? AND run_id = ? + ORDER BY timestamp ASC, rowid ASC` + ).all(params.threadKey, params.runId) + : db.query( + `SELECT * FROM ode_run_event + WHERE thread_id = ? + ORDER BY timestamp ASC, rowid ASC` + ).all(params.threadKey); + return (rows as Array>).map(mapOdeRunEventRow); +} + +export function getOdeRunEventPage( + threadKey: string, + params?: { limit?: number; includeRaw?: boolean } +): OdeRunEventPage | null { + const db = getDatabase(); + const threadExists = db.query("SELECT 1 AS present FROM message_thread WHERE id = ?").get(threadKey); + if (!threadExists) return null; + const limit = Math.max(1, Math.min(500, Math.floor(params?.limit ?? 100))); + const includeRaw = params?.includeRaw === true; + const filter = includeRaw ? "" : "AND type != 'provider.raw'"; + const totalRow = db.query( + `SELECT COUNT(*) AS count FROM ode_run_event WHERE thread_id = ? ${filter}` + ).get(threadKey) as { count: number } | null; + const rows = db.query( + `SELECT * FROM ( + SELECT * FROM ode_run_event + WHERE thread_id = ? ${filter} + ORDER BY timestamp DESC, rowid DESC + LIMIT ? + ) ORDER BY timestamp ASC` + ).all(threadKey, limit) as Array>; + return { + items: rows.map(mapOdeRunEventRow), + total: totalRow?.count ?? 0, + limit, + }; +} + // --------------------------------------------------------------------------- // Test utilities // --------------------------------------------------------------------------- export function clearMessageStoreForTests(): void { const db = getDatabase(); - db.exec("DELETE FROM message_detail; DELETE FROM message_thread;"); + db.exec("DELETE FROM ode_run_event; DELETE FROM message_detail; DELETE FROM message_thread;"); } export function closeMessageDatabaseForTests(): void { diff --git a/packages/config/local/ode-schema.ts b/packages/config/local/ode-schema.ts index e339611a..a9277ca1 100644 --- a/packages/config/local/ode-schema.ts +++ b/packages/config/local/ode-schema.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { AGENT_PROVIDERS } from "@/shared/agent-provider"; +import { AGENT_PROVIDERS, normalizeAgentProviderId } from "@/shared/agent-provider"; import { DEFAULT_STATUS_MESSAGE_FREQUENCY_MS } from "../status-message-frequency"; import { GIT_STRATEGY_VALUES, STATUS_MESSAGE_FORMAT_VALUES } from "../baseConfig"; @@ -32,9 +32,6 @@ const agentsSchema = z.object({ kimi: z.object({ enabled: z.boolean().optional().default(true), }).optional().default({ enabled: true }), - kiro: z.object({ - enabled: z.boolean().optional().default(true), - }).optional().default({ enabled: true }), kilo: z.object({ enabled: z.boolean().optional().default(true), models: z.array(z.string()).optional().default([]), @@ -45,9 +42,6 @@ const agentsSchema = z.object({ goose: z.object({ enabled: z.boolean().optional().default(true), }).optional().default({ enabled: true }), - gemini: z.object({ - enabled: z.boolean().optional().default(true), - }).optional().default({ enabled: true }), pi: z.object({ enabled: z.boolean().optional().default(true), models: z.array(z.string()).optional().default([]), @@ -69,11 +63,9 @@ const agentsSchema = z.object({ claudecode: { enabled: true }, codex: { enabled: true, models: [] }, kimi: { enabled: true }, - kiro: { enabled: true }, kilo: { enabled: true, models: [] }, qwen: { enabled: true }, goose: { enabled: true }, - gemini: { enabled: true }, pi: { enabled: true, models: [] }, openhands: { enabled: true, models: [] }, codebuddy: { enabled: true, models: [] }, @@ -84,7 +76,7 @@ const channelDetailSchema = z.object({ id: z.string(), name: z.string(), agentProvider: z.preprocess( - (value) => (value === "claude" ? "claudecode" : value), + (value) => normalizeAgentProviderId(value), agentProviderSchema.optional().default("opencode") ), model: z.string().optional().default(""), @@ -104,7 +96,6 @@ const workspaceSchema = z.object({ name: z.string().optional().default(""), domain: z.string().optional().default(""), status: z.enum(["active", "paused"]).optional().default("active"), - slackStatusMode: z.enum(["ai_card", "legacy"]).optional().default("ai_card"), channels: z.number().optional().default(0), members: z.number().optional().default(0), lastSync: z.string().optional().default(""), diff --git a/packages/config/local/ode-store.ts b/packages/config/local/ode-store.ts index 518d2d04..c99964f2 100644 --- a/packages/config/local/ode-store.ts +++ b/packages/config/local/ode-store.ts @@ -42,11 +42,9 @@ const EMPTY_TEMPLATE: OdeConfig = { claudecode: { enabled: true }, codex: { enabled: true, models: [] }, kimi: { enabled: true }, - kiro: { enabled: true }, kilo: { enabled: true, models: [] }, qwen: { enabled: true }, goose: { enabled: true }, - gemini: { enabled: true }, pi: { enabled: true, models: [] }, openhands: { enabled: true, models: [] }, codebuddy: { enabled: true, models: [] }, @@ -154,9 +152,6 @@ function normalizeConfig(config: OdeConfig): OdeConfig { kimi: { enabled: config.agents?.kimi?.enabled ?? true, }, - kiro: { - enabled: config.agents?.kiro?.enabled ?? true, - }, kilo: { enabled: config.agents?.kilo?.enabled ?? true, models: kiloModels, @@ -167,9 +162,6 @@ function normalizeConfig(config: OdeConfig): OdeConfig { goose: { enabled: config.agents?.goose?.enabled ?? true, }, - gemini: { - enabled: config.agents?.gemini?.enabled ?? true, - }, pi: { enabled: config.agents?.pi?.enabled ?? true, models: piModels, diff --git a/packages/config/local/ode.ts b/packages/config/local/ode.ts index 00be5041..176917f4 100644 --- a/packages/config/local/ode.ts +++ b/packages/config/local/ode.ts @@ -86,7 +86,6 @@ function mergeDashboardConfig(config: OdeConfig, dashboardConfig: DashboardConfi } = dashboardConfig.user; const workspaces: WorkspaceConfig[] = dashboardConfig.workspaces.map((workspace) => ({ ...workspace, - slackStatusMode: workspace.slackStatusMode === "legacy" ? "legacy" : "ai_card", slackAppToken: workspace.slackAppToken ?? "", slackBotToken: workspace.slackBotToken ?? "", discordBotToken: workspace.discordBotToken ?? "", @@ -259,13 +258,6 @@ export function getSlackTargetChannels(): string[] | null { return ids.length > 0 ? ids : null; } -export function getSlackStatusModeForChannel(channelId: string): WorkspaceConfig["slackStatusMode"] { - const workspace = getWorkspaces() - .filter((item) => item.type === "slack") - .find((item) => item.channelDetails.some((channel) => channel.id === channelId)); - return workspace?.slackStatusMode === "legacy" ? "legacy" : "ai_card"; -} - export function getDiscordBotTokens(): Array<{ token: string; workspaceId: string; workspaceName?: string }> { const active = getWorkspaces().filter((workspace) => workspace.type === "discord" && workspace.status === "active"); const candidates = active.length > 0 ? active : getWorkspaces().filter((workspace) => workspace.type === "discord"); diff --git a/packages/config/local/redis.ts b/packages/config/local/redis.ts index 86e7e50d..514dab9d 100644 --- a/packages/config/local/redis.ts +++ b/packages/config/local/redis.ts @@ -223,9 +223,7 @@ export async function getSessionMeta(sessionId: string): Promise> +): void { + const session = loadSession(channelId, threadId); + if (!session?.binding) return; + session.binding = { + ...session.binding, + ...update, + updatedAt: Date.now(), + }; saveSession(session); } diff --git a/packages/core/cli-handlers/task.ts b/packages/core/cli-handlers/task.ts index eeac099d..897cf8ad 100644 --- a/packages/core/cli-handlers/task.ts +++ b/packages/core/cli-handlers/task.ts @@ -146,7 +146,7 @@ function printTaskHelp(): void { " --time accepts ISO 8601, e.g. 2026-04-18T23:30:00+08:00", " --thread is optional. When set, the task reuses the thread's session; when omitted, it posts as a fresh channel message.", " --channel accepts either a raw channel id or a \"workspaceId::channelId\" value.", - " --agent is optional; accepts a CLI provider id: opencode | claudecode | codex | kimi | kiro | kilo | qwen | goose | gemini.", + " --agent is optional; accepts a CLI provider id: opencode | claudecode | codex | kimi | kilo | qwen | goose.", " When omitted, the task uses the channel's default agent.", ].join("\n"), ); diff --git a/packages/core/cron/scheduler.ts b/packages/core/cron/scheduler.ts index 3eb9efa3..77638253 100644 --- a/packages/core/cron/scheduler.ts +++ b/packages/core/cron/scheduler.ts @@ -38,6 +38,7 @@ import { buildSessionEnvironment, prepareSessionWorkspace } from "@/core/session import { sendChannelMessage as sendDiscordChannelMessage } from "@/ims/discord/client"; import { sendChannelMessage as sendLarkChannelMessage } from "@/ims/lark/client"; import { log } from "@/utils"; +import { createAgentInput } from "@/shared/agent-protocol"; const CRON_POLL_INTERVAL_MS = 15_000; @@ -340,7 +341,7 @@ async function runCronJob(job: CronJobRecord, minuteStartMs: number): Promise 1 ? `(${nextIndex + 1}/${totalQuestions}) ` : ""; - if (typeof deps.im.sendQuestion === "function") { - await deps.im.sendQuestion( - context.channelId, - context.replyThreadId, - question.question, - question.options, - prefix - ); - } else { - const nextPrompt = formatSingleQuestionPrompt(question, nextIndex, totalQuestions); - await deps.im.sendMessage(context.channelId, context.replyThreadId, nextPrompt); - } + const nextPrompt = formatSingleQuestionPrompt(question, nextIndex, totalQuestions); + await deps.im.sendMessage(context.channelId, context.replyThreadId, nextPrompt); } } catch (err) { log.warn("Failed to send follow-up question", { diff --git a/packages/core/kernel/recovery.ts b/packages/core/kernel/recovery.ts index 76dcbd18..b4d928ab 100644 --- a/packages/core/kernel/recovery.ts +++ b/packages/core/kernel/recovery.ts @@ -1,21 +1,7 @@ -import { clearActiveRequest, getSessionsWithPendingRequests, type ActiveRequest } from "@/config/local/sessions"; +import { clearActiveRequest, getSessionsWithPendingRequests } from "@/config/local/sessions"; import type { IMAdapter } from "@/core/types"; import { log } from "@/utils"; -async function stopRecoveredStatusStream(im: IMAdapter, request: ActiveRequest): Promise { - if (!request.statusStreamActive || !request.statusStreamTs || !im.stopStatusStream) return; - - try { - await im.stopStatusStream(request.channelId, request.statusStreamTs); - } catch (err) { - log.warn("Failed to stop recovered status stream", { - channelId: request.channelId, - statusTs: request.statusStreamTs, - error: String(err), - }); - } -} - export async function recoverPendingRequests( im: IMAdapter, platform?: "slack" | "discord" | "lark", @@ -45,8 +31,6 @@ export async function recoverPendingRequests( } const age = Date.now() - request.startedAt; - await stopRecoveredStatusStream(im, request); - if (age > 10 * 60 * 1000) { log.debug("Clearing stale request", { channelId: session.channelId, diff --git a/packages/core/kernel/request-run.ts b/packages/core/kernel/request-run.ts index d06826c8..23c47438 100644 --- a/packages/core/kernel/request-run.ts +++ b/packages/core/kernel/request-run.ts @@ -1,15 +1,18 @@ import type { OpenCodeMessage } from "@/agents"; import type { OpenCodeOptions } from "@/agents"; +import { randomUUID } from "node:crypto"; import { clearPendingQuestion, completeActiveRequest, createActiveRequest, failActiveRequest, getPendingQuestion, + loadSession, saveSession, setPendingQuestion, updateActiveRequest, type ActiveRequest, + type PendingQuestion, type PersistedSession, type TrackedTodo, type TrackedTool, @@ -19,43 +22,40 @@ import { failAgentResult, recordAgentQuestion, completeAgentQuestion, + recordOdeRunEvents, } from "@/config/local/inbox"; -import { getMessageUpdateIntervalMs, getSlackStatusModeForChannel, getUserGeneralSettings } from "@/config"; +import { getMessageUpdateIntervalMs, getUserGeneralSettings } from "@/config"; import { buildFinalResponseText, categorizeRuntimeError, createDeferred } from "@/core/runtime/helpers"; import { buildStatusMessageForAgent } from "@/core/runtime/status-message"; import { maybeGenerateSessionTitle } from "@/core/runtime/session-title"; -import type { AgentAdapter, IMAdapter, StatusStreamChunk } from "@/core/types"; +import type { AgentAdapter, IMAdapter } from "@/core/types"; import type { RuntimeRequestContext } from "@/core/kernel/request-context"; import { formatSingleQuestionPrompt } from "@/core/runtime/helpers"; +import { isSyntheticOwner } from "@/ims/shared/synthetic-owner"; +import { getAgentInputText, type AgentInput } from "@/shared/agent-protocol"; +import type { OdeRunEvent } from "@/shared/agent-protocol"; +import type { AgentProviderId } from "@/shared/agent-provider"; +import { + createOdeRunEvent, + deriveOdeRunEventsFromState, +} from "@/core/runtime/ode-run-events"; +import { + appendCoalescedSessionEvent, + getSessionEventCoalesceKey, + orderSessionEventsChronologically, + SampledRawEventBuffer, +} from "@/core/runtime/session-event-buffer"; import { buildSessionMessageState, - createStatusStreamDiffer, + extractEventRootSessionId, extractEventSessionId, getStatusMessageKey, truncateEventPayload, type SessionEvent, type SessionMessageState, - type StatusStreamDiffer, log, } from "@/utils"; -/** - * Slack defaults to AI card status streams when the IM adapter supports the - * Slack-style streaming API (chat.startStream/appendStream/stopStream). The - * per-workspace setting can switch Slack back to legacy chat.update status - * messages, while ODE_SLACK_STATUS_STREAMING remains a debug override. - */ -function isStatusStreamingEnabled( - platform: "slack" | "discord" | "lark" | undefined, - channelId: string -): boolean { - const override = process.env.ODE_SLACK_STATUS_STREAMING?.toLowerCase(); - if (override === "1" || override === "true") return true; - if (override === "0" || override === "false") return false; - if (platform && platform !== "slack") return false; - return getSlackStatusModeForChannel(channelId) !== "legacy"; -} - /** * Guard against publishing the user's own prompt as the bot's final reply. * @@ -72,56 +72,33 @@ function isPromptEcho(candidate: string | undefined, prompt: string | undefined) return c === p; } -function compactFinalResultForStream(text: string): string | undefined { - const compact = text - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .join(" "); - if (!compact) return undefined; - return compact.length > 220 ? `${compact.slice(0, 219)}…` : compact; -} - -function buildFinalResultStreamChunk(text: string): StatusStreamChunk | undefined { - const output = compactFinalResultForStream(text); - if (!output) return undefined; - return { - type: "task_update", - id: "result", - title: "Result", - status: "complete", - output, - }; -} - -const STREAM_STATUS_HEARTBEAT_MS = 1_000; - -function migrateLiveStatusMessageKey(params: { - liveEventHistory: Map; - liveParsedState: Map; - oldKey: string; - newKey: string; - eventHistory?: SessionEvent[]; +export function mirrorPendingQuestionToRealThread(params: { + channelId: string; + syntheticThreadId: string; + realThreadId: string | undefined; + pendingQuestion: PendingQuestion; }): void { - const { liveEventHistory, liveParsedState, oldKey, newKey, eventHistory } = params; - if (newKey === oldKey) return; - - const history = eventHistory ?? liveEventHistory.get(oldKey); - if (history) { - liveEventHistory.delete(oldKey); - liveEventHistory.set(newKey, history); + const { channelId, syntheticThreadId, realThreadId, pendingQuestion } = params; + if (!realThreadId || realThreadId === syntheticThreadId || !isSyntheticOwner(syntheticThreadId)) { + return; } - const parsed = liveParsedState.get(oldKey); - if (parsed) { - liveParsedState.delete(oldKey); - liveParsedState.set(newKey, parsed); + const syntheticSession = loadSession(channelId, syntheticThreadId); + if (!syntheticSession) return; + + const existingRealSession = loadSession(channelId, realThreadId); + if (existingRealSession) { + existingRealSession.pendingQuestion = pendingQuestion; + saveSession(existingRealSession); + return; } -} -function isNonRecoverableStreamStateError(error: unknown): boolean { - const message = String(error); - return /message_not_in_streaming_state|streaming_state_conflict/i.test(message); + const { activeRequest: _activeRequest, ...syntheticRest } = syntheticSession; + saveSession({ + ...syntheticRest, + threadId: realThreadId, + pendingQuestion, + }); } type RunnerDeps = { @@ -136,7 +113,7 @@ type RunOpenRequestParams = { context: RuntimeRequestContext; sessionId: string; cwd: string; - message: string; + input: AgentInput; agentContext: Awaited>; options?: OpenCodeOptions; agentResultDetailId: string | null; @@ -144,6 +121,7 @@ type RunOpenRequestParams = { isFirstMessageInThread: boolean; liveEventHistory: Map; liveParsedState: Map; + liveRunEvents: Map; publishFinalText: (params: { channelId: string; threadId: string; @@ -158,25 +136,19 @@ export type RunTrackedRequestParams = { workingPath: string; liveEventHistory: Map; liveParsedState: Map; + liveRunEvents?: Map; sendPrompt: () => Promise; onProgressTick: () => Promise; onComplete: () => void; onFail: (message: string) => void; publishFinalText: (text: string) => Promise; - progressIntervalMs?: number; - /** - * Optional. When the runner used the streaming API for live status, the - * failure path needs to stop the stream before chat.update would 409 with - * `streaming_state_conflict`. The kernel passes a closure that knows how - * to terminate the stream and preserve the full markdown error status. - */ - publishErrorStatus?: (errorStatusText: string) => Promise; failureLogLabel: string; agentResultDetailId: string | null; threadKey: string; sessionId: string; providerId: string; model: string | null; + runId?: string; }; export type RunTrackedRequestResult = { @@ -189,6 +161,7 @@ function isExternallySettled(request: ActiveRequest): boolean { } const EVENT_STATE_MERGE_INTERVAL_MS = 1000; +const MAX_RAW_PROVIDER_EVENTS_PER_RUN = 500; function tryCompleteAgentResult(params: { detailId: string | null; @@ -251,8 +224,10 @@ async function startKernelEventStreamWatcher(params: { workingPath: string; liveEventHistory: Map; liveParsedState: Map; + liveRunEvents: Map; threadKey: string | null; model: string | null; + runId: string; onUpdate: () => void; onStop?: () => void; }): Promise<() => void> { @@ -264,9 +239,11 @@ async function startKernelEventStreamWatcher(params: { liveParsedState, threadKey, model, + runId, onUpdate, onStop, } = params; + const liveRunEvents = params.liveRunEvents ?? new Map(); if (!deps.agent.supportsEventStream) { return () => {}; @@ -278,9 +255,68 @@ async function startKernelEventStreamWatcher(params: { let messageKey = getStatusMessageKey(request); const eventHistory = liveEventHistory.get(messageKey) ?? []; + const runEvents = liveRunEvents.get(messageKey) ?? []; + let persistedRunEventCount = 0; + const rawEventBuffer = new SampledRawEventBuffer(MAX_RAW_PROVIDER_EVENTS_PER_RUN); + const eventIndexByKey = new Map(); + for (let index = 0; index < eventHistory.length; index += 1) { + const existing = eventHistory[index]; + if (!existing) continue; + const key = getSessionEventCoalesceKey(existing); + if (key) eventIndexByKey.set(key, index); + } if (!liveEventHistory.has(messageKey)) { liveEventHistory.set(messageKey, eventHistory); } + if (!liveRunEvents.has(messageKey)) liveRunEvents.set(messageKey, runEvents); + + function persistRunEvents(): void { + if (!threadKey || persistedRunEventCount >= runEvents.length) return; + const pending = runEvents.slice(persistedRunEventCount); + try { + recordOdeRunEvents(threadKey, pending); + persistedRunEventCount = runEvents.length; + } catch (error) { + log.warn("Failed to persist Ode run events", { + threadKey, + count: pending.length, + error: String(error), + }); + } + } + + function queueRawProviderEvent(event: SessionEvent): void { + rawEventBuffer.enqueue(event); + } + + function flushRawProviderEvents(force: boolean): void { + const drained = rawEventBuffer.drain(force); + for (const sessionEvent of drained.events) { + runEvents.push(createOdeRunEvent( + { + providerId, + sessionId: request.sessionId, + runId, + timestamp: sessionEvent.timestamp, + }, + "provider.raw", + { providerType: sessionEvent.type }, + { rawEvent: sessionEvent.data } + )); + } + + if (drained.summary) { + runEvents.push(createOdeRunEvent( + { providerId, sessionId: request.sessionId, runId }, + "provider.raw", + { + providerType: "ode.raw_events.sampled", + dropped: drained.summary.dropped, + retained: drained.summary.retained, + } + )); + } + } /** * Re-home the live-state buffers to a new key after the status message @@ -289,19 +325,21 @@ async function startKernelEventStreamWatcher(params: { * object as before — only the Map keys move. */ function migrateMessageKey(newKey: string): void { - migrateLiveStatusMessageKey({ - liveEventHistory, - liveParsedState, - oldKey: messageKey, - newKey, - eventHistory, - }); + if (newKey === messageKey) return; + liveEventHistory.delete(messageKey); + liveEventHistory.set(newKey, eventHistory); + const parsed = liveParsedState.get(messageKey); + liveParsedState.delete(messageKey); + if (parsed) liveParsedState.set(newKey, parsed); + liveRunEvents.delete(messageKey); + liveRunEvents.set(newKey, runEvents); messageKey = newKey; } - function applyStateFromEvents(): void { + function applyStateFromEvents(forceRaw = false): void { + flushRawProviderEvents(forceRaw); const existingState = liveParsedState.get(messageKey); - const parsedState = buildSessionMessageState(eventHistory, { + const parsedState = buildSessionMessageState(orderSessionEventsChronologically(eventHistory), { workingDirectory: workingPath, provider: providerId, baseState: { @@ -309,6 +347,13 @@ async function startKernelEventStreamWatcher(params: { sessionTitle: existingState?.sessionTitle, }, }); + const canonicalEvents = deriveOdeRunEventsFromState({ + previous: existingState, + next: parsedState, + context: { providerId, sessionId: request.sessionId, runId }, + }); + runEvents.push(...canonicalEvents); + persistRunEvents(); liveParsedState.set(messageKey, parsedState); request.currentText = parsedState.currentText; request.tools = parsedState.tools.map((tool) => ({ @@ -328,12 +373,12 @@ async function startKernelEventStreamWatcher(params: { let stopNotified = false; let flushTimer: ReturnType | null = null; - function flushStateUpdates(emitUpdate: boolean): void { + function flushStateUpdates(emitUpdate: boolean, forceRaw = false): void { if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } - applyStateFromEvents(); + applyStateFromEvents(forceRaw); if (emitUpdate) { onUpdate(); } @@ -352,7 +397,14 @@ async function startKernelEventStreamWatcher(params: { const event = (globalEvent as any).payload ?? globalEvent; let shouldNotifyStop = false; const eventSessionId = extractEventSessionId(event as Record | undefined); - if (eventSessionId && eventSessionId !== request.sessionId) { + const eventRootSessionId = extractEventRootSessionId( + event as Record | undefined + ); + if ( + eventRootSessionId + ? eventRootSessionId !== request.sessionId + : eventSessionId && eventSessionId !== request.sessionId + ) { return; } @@ -399,7 +451,8 @@ async function startKernelEventStreamWatcher(params: { preserveStringAtPath, }), }; - eventHistory.push(sessionEvent); + appendCoalescedSessionEvent(eventHistory, eventIndexByKey, sessionEvent); + queueRawProviderEvent(sessionEvent); if (shouldNotifyStop) { flushStateUpdates(true); @@ -439,24 +492,6 @@ async function startKernelEventStreamWatcher(params: { if (!statusRateLimited) { try { - if (request.statusStreamActive && request.statusStreamTs === oldStatusTs && deps.im.stopStatusStream) { - try { - await deps.im.stopStatusStream(request.channelId, oldStatusTs); - } catch (err) { - log.warn("Failed to stop stale stream after question reply", { - channelId: request.channelId, - threadId: request.threadId, - statusTs: oldStatusTs, - error: String(err), - }); - } - request.statusStreamActive = false; - request.statusStreamTs = undefined; - updateActiveRequest(request.channelId, request.threadId, { - statusStreamActive: false, - statusStreamTs: undefined, - }, { immediate: true }); - } await deps.im.deleteMessage(request.channelId, oldStatusTs); } catch (err) { log.warn("Failed to delete stale status message after question reply", { @@ -497,8 +532,6 @@ async function startKernelEventStreamWatcher(params: { request.statusMessageTs = newStatusTs; updateActiveRequest(request.channelId, request.threadId, { statusMessageTs: newStatusTs, - statusStreamActive: false, - statusStreamTs: undefined, }); // Move the live-state buffers to the new ts key so the // subscription handler and the progress tick keep reading @@ -568,21 +601,15 @@ async function startKernelEventStreamWatcher(params: { } void (async () => { + let questionMessageTs: string | undefined; try { const first = normalized[0]!; - const prefix = normalized.length > 1 ? `(1/${normalized.length}) ` : ""; - if (typeof deps.im.sendQuestion === "function") { - await deps.im.sendQuestion( - request.channelId, - request.replyThreadId, - first.question, - first.options, - prefix - ); - } else { - const promptText = formatSingleQuestionPrompt(first, 0, normalized.length); - await deps.im.sendMessage(request.channelId, request.replyThreadId, promptText); - } + const promptText = formatSingleQuestionPrompt(first, 0, normalized.length); + questionMessageTs = await deps.im.sendMessage( + request.channelId, + request.replyThreadId, + promptText + ); } catch (err) { log.warn("Failed to post ask_user question", { channelId: request.channelId, @@ -592,14 +619,21 @@ async function startKernelEventStreamWatcher(params: { }); } - setPendingQuestion(request.channelId, request.threadId, { + const pendingQuestion: PendingQuestion = { requestId, sessionId: properties.sessionID ?? request.sessionId, askedAt: Date.now(), questions: normalized, - messageTs: request.statusMessageTs, + messageTs: questionMessageTs ?? request.statusMessageTs, collectedAnswers: [], questionDetailId, + }; + setPendingQuestion(request.channelId, request.threadId, pendingQuestion); + mirrorPendingQuestionToRealThread({ + channelId: request.channelId, + syntheticThreadId: request.threadId, + realThreadId: questionMessageTs, + pendingQuestion, }); })(); return; @@ -609,7 +643,8 @@ async function startKernelEventStreamWatcher(params: { }); return () => { - flushStateUpdates(false); + flushStateUpdates(false, true); + persistRunEvents(); unsubscribe(); }; } @@ -623,7 +658,7 @@ export async function runOpenRequest( context, sessionId, cwd, - message, + input, agentContext, options, agentResultDetailId, @@ -631,79 +666,21 @@ export async function runOpenRequest( isFirstMessageInThread, liveEventHistory, liveParsedState, + liveRunEvents, publishFinalText, } = params; - const providerLabel = deps.agent.getDisplayNameForSession(sessionId); + const message = getAgentInputText(input); - // Streaming-API path is enabled by Slack workspace config by default, - // requires live agent events, and requires the adapter to implement the - // stream lifecycle methods. When unavailable we silently fall back to the - // chat.postMessage + chat.update path. - const streamingRequested = isStatusStreamingEnabled(deps.platform, context.channelId) - && deps.agent.supportsEventStream - && typeof deps.im.startStatusStream === "function" - && typeof deps.im.appendStatusStream === "function"; - - // Tracks whether the *current* statusTs is a live stream. Set to true - // only after startStatusStream returns a TS; cleared if we fall back to - // sendMessage at startup or if a status rotation later replaces the TS - // with a chat.postMessage-issued one. - let useStreaming = false; - // The TS that we actually started a stream against. Used to detect - // status-message rotation (post-question / 429-fallback), which posts a - // brand-new chat.postMessage message — appendStream against that TS - // would fail with `message_not_in_streaming_state`. - let streamingStatusTs: string | undefined; + const providerLabel = deps.agent.getDisplayNameForSession(sessionId); let initialStatusTs: string | undefined; try { - if (streamingRequested && deps.im.startStatusStream) { - try { - initialStatusTs = await deps.im.startStatusStream( - context.channelId, - context.replyThreadId, - { - recipientUserId: context.userId, - seedPlanTitle: `${providerLabel} is running...`, - } - ); - } catch (err) { - // startStream itself threw — log and fall through to the - // chat.postMessage path below. Common causes: missing - // recipient_team_id, the workspace hasn't opted into the streaming - // API, network blip on the very first request. - log.warn("startStatusStream threw; falling back to chat.postMessage", { - channelId: context.channelId, - threadId: context.replyThreadId, - error: String(err), - }); - initialStatusTs = undefined; - } - if (initialStatusTs) { - useStreaming = true; - streamingStatusTs = initialStatusTs; - } else { - // Adapter returned undefined (e.g. resolveWorkspaceAuth couldn't - // produce a team id). Fall back to plain chat.postMessage instead - // of aborting the whole request. - log.warn("startStatusStream returned no ts; falling back to chat.postMessage", { - channelId: context.channelId, - threadId: context.replyThreadId, - }); - initialStatusTs = await deps.im.sendMessage( - context.channelId, - context.replyThreadId, - `${providerLabel} is running...` - ); - } - } else { - initialStatusTs = await deps.im.sendMessage( - context.channelId, - context.replyThreadId, - `${providerLabel} is running...` - ); - } + initialStatusTs = await deps.im.sendMessage( + context.channelId, + context.replyThreadId, + `${providerLabel} is running...` + ); } catch (err) { // Swallow initial-status send failure so the request lifecycle below never // gets skipped by an unhandled rejection. A transient Slack error on the @@ -731,10 +708,6 @@ export async function runOpenRequest( statusTs, message ); - if (useStreaming && streamingStatusTs) { - request.statusStreamActive = true; - request.statusStreamTs = streamingStatusTs; - } session.activeRequest = request; saveSession(session); @@ -755,285 +728,41 @@ export async function runOpenRequest( }, }); - const legacyProgressIntervalMs = getMessageUpdateIntervalMs(); - const progressIntervalMs = useStreaming ? STREAM_STATUS_HEARTBEAT_MS : legacyProgressIntervalMs; + const progressIntervalMs = getMessageUpdateIntervalMs(); let lastHeartbeat = Date.now(); - let lastLegacyStatusUpdateAt = useStreaming ? Date.now() : 0; const resolvedModel = options?.model?.providerID && options.model.modelID ? `${options.model.providerID}/${options.model.modelID}` : null; const providerId = deps.agent.getProviderForSession(sessionId); - const runMode = options?.agent === "plan" ? "plan mode" : "build mode"; - const statusMessageFormat = getUserGeneralSettings().defaultStatusMessageFormat; - - // One differ instance per run; keeps last-seen fingerprints so we only - // send chunks for tools whose shape actually changed. - let streamDiffer: StatusStreamDiffer | null = useStreaming ? createStatusStreamDiffer() : null; - - async function stopTrackedStatusStream(reason: string, appendChunks?: StatusStreamChunk[]): Promise { - const streamTs = streamingStatusTs ?? request.statusStreamTs; - if (!streamTs || !deps.im.stopStatusStream) { - useStreaming = false; - streamingStatusTs = undefined; - request.statusStreamActive = false; - request.statusStreamTs = undefined; - updateActiveRequest(context.channelId, context.threadId, { - statusStreamActive: false, - statusStreamTs: undefined, - }, { immediate: true }); - return; - } - - try { - if (appendChunks && appendChunks.length > 0 && deps.im.appendStatusStream) { - try { - await deps.im.appendStatusStream(context.channelId, streamTs, appendChunks); - } catch (err) { - log.debug("Final stream append failed before stop", { - reason, - channelId: context.channelId, - statusTs: streamTs, - error: String(err), - }); - } - } - await deps.im.stopStatusStream(context.channelId, streamTs); - useStreaming = false; - streamingStatusTs = undefined; - request.statusStreamActive = false; - request.statusStreamTs = undefined; - updateActiveRequest(context.channelId, context.threadId, { - statusStreamActive: false, - statusStreamTs: undefined, - }, { immediate: true }); - } catch (err) { - if (isNonRecoverableStreamStateError(err)) { - useStreaming = false; - streamingStatusTs = undefined; - request.statusStreamActive = false; - request.statusStreamTs = undefined; - updateActiveRequest(context.channelId, context.threadId, { - statusStreamActive: false, - statusStreamTs: undefined, - }, { immediate: true }); - log.warn("Slack status stream was already non-streaming; cleared active stream state", { - reason, - channelId: context.channelId, - statusTs: streamTs, - error: String(err), - }); - return; - } - useStreaming = true; - streamingStatusTs = streamTs; - request.statusStreamActive = true; - request.statusStreamTs = streamTs; - updateActiveRequest(context.channelId, context.threadId, { - statusStreamActive: true, - statusStreamTs: streamTs, - }, { immediate: true }); - log.warn("Slack stopStatusStream failed", { - reason, - channelId: context.channelId, - statusTs: streamTs, - error: String(err), - }); - } - } - - async function switchToLegacyStatusFromSnapshot( - currentState: SessionMessageState, - extra?: { - failedStreamTs?: string; - oldStatusTs?: string; - oldStreamTs?: string; - } - ): Promise { - const oldStatusKey = getStatusMessageKey(request); - useStreaming = false; - streamingStatusTs = undefined; - streamDiffer = null; - request.statusStreamActive = false; - request.statusStreamTs = undefined; - - const statusText = buildStatusMessageForAgent({ - agent: deps.agent, - request, - workingPath: cwd, - state: currentState, - statusMessageFormat, - }); - const legacyStatusTs = await deps.im.sendMessage( - context.channelId, - context.replyThreadId, - statusText - ); - if (typeof legacyStatusTs === "string" && legacyStatusTs.length > 0) { - statusTs = legacyStatusTs; - request.statusMessageTs = legacyStatusTs; - lastLegacyStatusUpdateAt = Date.now(); - migrateLiveStatusMessageKey({ - liveEventHistory, - liveParsedState, - oldKey: oldStatusKey, - newKey: getStatusMessageKey(request), - }); - updateActiveRequest(context.channelId, context.threadId, { - statusMessageTs: legacyStatusTs, - statusStreamActive: false, - statusStreamTs: undefined, - }, { immediate: true }); - log.info("Switched Slack status to legacy message after stream recreation failed", { - channelId: context.channelId, - statusTs: legacyStatusTs, - ...extra, - }); - return; - } - - updateActiveRequest(context.channelId, context.threadId, { - statusStreamActive: false, - statusStreamTs: undefined, - }, { immediate: true }); + const runId = agentResultDetailId ?? randomUUID(); + const statusKey = getStatusMessageKey(request); + const runEvents = liveRunEvents.get(statusKey) ?? []; + if (!liveRunEvents.has(statusKey)) liveRunEvents.set(statusKey, runEvents); + runEvents.push(createOdeRunEvent( + { providerId, sessionId, runId }, + "run.started", + { transport: deps.agent.getTransportForSession(sessionId) } + )); + for (const part of input.parts) { + if (part.type === "text") continue; + runEvents.push(createOdeRunEvent( + { providerId, sessionId, runId }, + "attachment.received", + { filename: part.filename, mimeType: part.mimeType, size: part.size, kind: part.type } + )); } - - async function recreateStatusStreamFromSnapshot(currentState: SessionMessageState): Promise { - if (!streamingRequested || !deps.im.startStatusStream || !deps.im.appendStatusStream) return false; - - const oldStatusTs = request.statusMessageTs; - const oldStreamTs = streamingStatusTs ?? request.statusStreamTs; - const oldStatusKey = getStatusMessageKey(request); - - try { - await deps.im.deleteMessage(context.channelId, oldStatusTs); - } catch (err) { - log.warn("Failed to delete stale Slack AI status card before recreation", { - channelId: context.channelId, - statusTs: oldStatusTs, - error: String(err), - }); - } - - let nextStatusTs: string | undefined; - try { - nextStatusTs = await deps.im.startStatusStream( - context.channelId, - context.replyThreadId, - { - recipientUserId: context.userId, - seedPlanTitle: `${providerLabel} is running...`, - } - ); - } catch (err) { - log.warn("Failed to start replacement Slack AI status card; falling back to legacy status updates", { - channelId: context.channelId, - oldStatusTs, - oldStreamTs, - error: String(err), - }); - await switchToLegacyStatusFromSnapshot(currentState, { oldStatusTs, oldStreamTs }); - return false; - } - if (!nextStatusTs) { - log.warn("Failed to recreate Slack AI status card; falling back to legacy status updates", { - channelId: context.channelId, - oldStatusTs, - oldStreamTs, - }); - await switchToLegacyStatusFromSnapshot(currentState, { oldStatusTs, oldStreamTs }); - return false; - } - - const nextDiffer = createStatusStreamDiffer(); - const { chunks, commit } = nextDiffer.diff({ - state: currentState, - workingPath: cwd, - startedAt: request.startedAt, - agentLabel: providerLabel, - runMode, - statusMessageFormat, - }); - try { - if (chunks.length > 0) { - await deps.im.appendStatusStream(context.channelId, nextStatusTs, chunks); - } - commit(); - } catch (err) { - log.warn("Failed to seed replacement Slack AI status card; falling back to legacy status updates", { - channelId: context.channelId, - oldStatusTs, - oldStreamTs, - newStatusTs: nextStatusTs, - error: String(err), - }); - if (deps.im.stopStatusStream) { - try { - await deps.im.stopStatusStream(context.channelId, nextStatusTs); - } catch (stopErr) { - log.debug("Failed to stop unseeded replacement Slack AI status card", { - channelId: context.channelId, - statusTs: nextStatusTs, - error: String(stopErr), - }); - } - } - try { - await deps.im.deleteMessage(context.channelId, nextStatusTs); - } catch (deleteErr) { - log.debug("Failed to delete unseeded replacement Slack AI status card", { - channelId: context.channelId, - statusTs: nextStatusTs, - error: String(deleteErr), - }); - } - await switchToLegacyStatusFromSnapshot(currentState, { - failedStreamTs: nextStatusTs, - oldStatusTs, - oldStreamTs, - }); - return false; - } - - statusTs = nextStatusTs; - request.statusMessageTs = nextStatusTs; - request.statusStreamActive = true; - request.statusStreamTs = nextStatusTs; - useStreaming = true; - streamingStatusTs = nextStatusTs; - streamDiffer = nextDiffer; - migrateLiveStatusMessageKey({ - liveEventHistory, - liveParsedState, - oldKey: oldStatusKey, - newKey: getStatusMessageKey(request), - }); - - updateActiveRequest(context.channelId, context.threadId, { - statusMessageTs: nextStatusTs, - statusStreamActive: true, - statusStreamTs: nextStatusTs, - }, { immediate: true }); - - log.info("Recreated Slack AI status card after stale stream state", { - channelId: context.channelId, - oldStatusTs, - newStatusTs: nextStatusTs, - oldStreamTs, - }); - return true; - } - const result = await runTrackedRequest({ deps, request, workingPath: cwd, liveEventHistory, liveParsedState, + liveRunEvents, sendPrompt: () => deps.agent.sendMessage( context.channelId, sessionId, - message, + input, cwd, options, agentContext @@ -1052,117 +781,15 @@ export async function runOpenRequest( // progress updates pointed at the actual live message. statusTs = request.statusMessageTs; const currentStatusKey = getStatusMessageKey(request); - const currentState = liveParsedState.get(currentStatusKey); - - // If a status-message rotation replaced our stream TS with a fresh - // chat.postMessage TS (happens after a question.replied flow, or - // after a 429-fallback elsewhere in this tick), the streaming-API - // path no longer applies: appendStream against the new TS would - // fail with `message_not_in_streaming_state`. Downgrade to plain - // chat.update for the rest of the run. We do NOT try to re-start a - // fresh stream — that would post a second message in the thread for - // the same turn, which is more confusing than a continuation card. - if (useStreaming && streamingStatusTs && statusTs !== streamingStatusTs) { - log.info("Status TS rotated; disabling Slack streaming for the rest of the run", { - channelId: context.channelId, - previousStreamingTs: streamingStatusTs, - newStatusTs: statusTs, - }); - if (request.statusStreamActive && request.statusStreamTs === streamingStatusTs) { - await stopTrackedStatusStream("status message rotated"); - } else { - useStreaming = false; - streamingStatusTs = undefined; - } - lastLegacyStatusUpdateAt = now; - } - - // Streaming path: diff state -> chunks -> chat.appendStream. - // Falls back to plain-text chat.update when the differ produced no - // chunks (nothing changed) so we don't waste a Tier-4 round-trip. - if (streamDiffer && currentState && deps.im.appendStatusStream && useStreaming && !request.statusFrozen) { - const { chunks, commit } = streamDiffer.diff({ - state: currentState, - workingPath: cwd, - startedAt: request.startedAt, - agentLabel: providerLabel, - runMode, - statusMessageFormat, - }); - if (chunks.length > 0) { - try { - await deps.im.appendStatusStream(context.channelId, statusTs, chunks); - // Only advance the differ's fingerprint cache after the - // network call confirms. If commit() is skipped due to a - // throw above, the next tick re-emits the same chunks - // (idempotent for Slack's task_update / plan_update). - commit(); - } catch (err) { - if (isNonRecoverableStreamStateError(err)) { - log.warn("Slack AI status stream is no longer active; recreating card", { - channelId: context.channelId, - statusTs, - error: String(err), - }); - try { - if (await recreateStatusStreamFromSnapshot(currentState) || !useStreaming) { - return; - } - } catch (recreateErr) { - log.warn("Slack AI status card recreation failed; falling back to legacy status updates", { - channelId: context.channelId, - statusTs, - error: String(recreateErr), - }); - useStreaming = false; - streamingStatusTs = undefined; - request.statusStreamActive = false; - request.statusStreamTs = undefined; - updateActiveRequest(context.channelId, context.threadId, { - statusStreamActive: false, - statusStreamTs: undefined, - }, { immediate: true }); - } - } - // appendStream failed (rate limit, streaming_state_conflict, - // network blip…). Don't crash the tick and don't commit() — - // the next tick will re-emit the still-pending delta. - log.warn("Slack appendStatusStream failed; will retry next tick", { - channelId: context.channelId, - statusTs, - chunkCount: chunks.length, - error: String(err), - }); - } - } - updateActiveRequest(context.channelId, context.threadId, { - statusMessageTs: request.statusMessageTs, - currentText: request.currentText, - todos: request.todos, - statusFrozen: request.statusFrozen, - }); - return; - } const statusText = buildStatusMessageForAgent({ agent: deps.agent, request, workingPath: cwd, - state: currentState, - statusMessageFormat, + state: liveParsedState.get(currentStatusKey), + statusMessageFormat: getUserGeneralSettings().defaultStatusMessageFormat, }); if (!request.statusFrozen) { - if (now - lastLegacyStatusUpdateAt < legacyProgressIntervalMs) { - updateActiveRequest(context.channelId, context.threadId, { - statusMessageTs: request.statusMessageTs, - currentText: request.currentText, - todos: request.todos, - statusFrozen: request.statusFrozen, - }); - return; - } - lastLegacyStatusUpdateAt = now; - const updatedStatusTs = await deps.im.updateMessage(context.channelId, statusTs, statusText); if (typeof updatedStatusTs === "string" && updatedStatusTs !== statusTs) { statusTs = updatedStatusTs; @@ -1193,18 +820,13 @@ export async function runOpenRequest( if (typeof replacementStatusTs === "string" && replacementStatusTs.length > 0) { statusTs = replacementStatusTs; request.statusMessageTs = replacementStatusTs; - lastLegacyStatusUpdateAt = Date.now(); // Persist the new statusTs immediately so a crash before the // next debounced save doesn't leave disk pointing at the old // rate-limited TS (which would mis-route recovery edits). updateActiveRequest( context.channelId, context.threadId, - { - statusMessageTs: replacementStatusTs, - statusStreamActive: false, - statusStreamTs: undefined, - }, + { statusMessageTs: replacementStatusTs }, { immediate: true } ); } @@ -1233,31 +855,7 @@ export async function runOpenRequest( onFail: (failureMessage) => { failActiveRequest(context.channelId, context.threadId, failureMessage); }, - progressIntervalMs, publishFinalText: async (text) => { - // If we were rendering status via the streaming API, terminate the - // stream first. This converts the live plan card to a static block - // (no more spinner), prevents `streaming_state_conflict` on the - // subsequent chat.update/delete in publishFinalText, and gives us a - // place to record a one-line "done in 12s" summary via a final - // plan_update chunk (chunks-mode streams can't accept markdown_text - // on stop, so we use an appendStream first if we need a summary). - if ((useStreaming || request.statusStreamActive) && streamDiffer && deps.im.stopStatusStream) { - const currentState = liveParsedState.get(getStatusMessageKey(request)); - if (currentState) { - const summary = streamDiffer.finalize({ - state: currentState, - workingPath: cwd, - startedAt: request.startedAt, - }, text); - const finalChunks: StatusStreamChunk[] = [{ type: "plan_update", title: summary }]; - const resultChunk = buildFinalResultStreamChunk(text); - if (resultChunk) finalChunks.push(resultChunk); - await stopTrackedStatusStream("final text", finalChunks); - } else { - await stopTrackedStatusStream("final text"); - } - } await publishFinalText({ channelId: context.channelId, threadId: context.replyThreadId, @@ -1265,62 +863,13 @@ export async function runOpenRequest( text, }); }, - publishErrorStatus: useStreaming && deps.im.stopStatusStream - ? async (errorStatusText: string) => { - if (!useStreaming && !request.statusStreamActive && !request.statusStreamTs) { - await deps.im.updateMessage(context.channelId, request.statusMessageTs, errorStatusText); - return; - } - // Append the error as a final plan_update chunk so the streamed - // card surfaces the failure inline, then stop the stream. Chunks- - // mode streams can't carry markdown_text on stop, so we publish - // the full error text as a normal message after the stream is no - // longer active. - try { - await stopTrackedStatusStream("error status", [ - { type: "plan_update", title: `Error: ${errorStatusText.split("\n")[0]?.slice(0, 200) ?? ""}` }, - ]); - const errorStatusTs = await deps.im.sendMessage( - context.channelId, - context.replyThreadId, - errorStatusText - ); - if (typeof errorStatusTs === "string" && errorStatusTs.length > 0) { - request.statusMessageTs = errorStatusTs; - updateActiveRequest(context.channelId, context.threadId, { - statusMessageTs: errorStatusTs, - statusStreamActive: false, - statusStreamTs: undefined, - }, { immediate: true }); - } - } catch (err) { - log.warn("Streaming error status publish failed; falling back to chat.update", { - channelId: context.channelId, - statusTs: request.statusMessageTs, - error: String(err), - }); - // Best-effort: still try a plain update so the user sees something. - try { - await deps.im.updateMessage( - context.channelId, - request.statusMessageTs, - errorStatusText - ); - } catch (updateErr) { - log.warn("Fallback chat.update after stopStream failure also failed", { - channelId: context.channelId, - error: String(updateErr), - }); - } - } - } - : undefined, failureLogLabel: "Request failed", agentResultDetailId, threadKey, sessionId, providerId, model: resolvedModel, + runId, }); if (result.responses === null) return null; @@ -1345,16 +894,19 @@ export async function runTrackedRequest( onComplete, onFail, publishFinalText, - progressIntervalMs: requestedProgressIntervalMs, failureLogLabel, agentResultDetailId, threadKey, sessionId, providerId, model, + runId: requestedRunId, } = params; - const progressIntervalMs = requestedProgressIntervalMs ?? getMessageUpdateIntervalMs(); + const liveRunEvents = params.liveRunEvents ?? new Map(); + const runId = requestedRunId ?? request.statusMessageTs; + + const progressIntervalMs = getMessageUpdateIntervalMs(); let progressInFlight = false; let progressTimer: ReturnType | null = null; let stopWatcher: (() => void) | null = null; @@ -1399,8 +951,10 @@ export async function runTrackedRequest( workingPath, liveEventHistory, liveParsedState, + liveRunEvents, threadKey, model, + runId, onUpdate: () => {}, onStop: () => { stopSignal.resolve(); @@ -1419,11 +973,7 @@ export async function runTrackedRequest( } await waitForProgressDrain(); - if (isExternallySettled(request)) { - liveEventHistory.delete(getStatusMessageKey(request)); - liveParsedState.delete(getStatusMessageKey(request)); - return { responses: [] }; - } + if (isExternallySettled(request)) return { responses: [] }; request.state = "completed"; request.statusFrozen = true; @@ -1433,8 +983,11 @@ export async function runTrackedRequest( const safeFallback = isPromptEcho(fallbackText, request.prompt) ? undefined : fallbackText; const finalText = safeFallback || "_Done_"; await publishFinalText(finalText); - liveEventHistory.delete(getStatusMessageKey(request)); - liveParsedState.delete(getStatusMessageKey(request)); + liveRunEvents.get(getStatusMessageKey(request))?.push(createOdeRunEvent( + { providerId: providerId as AgentProviderId, sessionId, runId }, + "run.completed", + { reason: "stop", text: finalText } + )); tryCompleteAgentResult({ detailId: agentResultDetailId, resultText: finalText, @@ -1466,8 +1019,11 @@ export async function runTrackedRequest( const safeFallback = isPromptEcho(rawFallback, request.prompt) ? undefined : rawFallback; const finalText = builtText ?? (safeFallback || "_Done_"); await publishFinalText(finalText); - liveEventHistory.delete(getStatusMessageKey(request)); - liveParsedState.delete(getStatusMessageKey(request)); + liveRunEvents.get(getStatusMessageKey(request))?.push(createOdeRunEvent( + { providerId: providerId as AgentProviderId, sessionId, runId }, + "run.completed", + { text: finalText } + )); tryCompleteAgentResult({ detailId: agentResultDetailId, resultText: finalText, @@ -1480,8 +1036,6 @@ export async function runTrackedRequest( return { responses: result.responses }; } catch (err) { if (isExternallySettled(request)) { - liveEventHistory.delete(getStatusMessageKey(request)); - liveParsedState.delete(getStatusMessageKey(request)); return { responses: [] }; } @@ -1492,6 +1046,11 @@ export async function runTrackedRequest( request.error = message; const errorStatus = `Error: ${message}\n_${suggestion}_`; + liveRunEvents.get(getStatusMessageKey(request))?.push(createOdeRunEvent( + { providerId: providerId as AgentProviderId, sessionId, runId }, + "run.failed", + { message, suggestion } + )); tryFailAgentResult({ detailId: agentResultDetailId, errorText: message, @@ -1501,27 +1060,8 @@ export async function runTrackedRequest( workingDirectory: workingPath, }); deps.im.cancelPendingUpdates?.(request.channelId, request.statusMessageTs); - if (params.publishErrorStatus) { - // Streaming path: caller-supplied closure terminates the live stream - // (via chat.stopStream) and posts a new message with the error text, - // because chat.update against a streaming message returns - // `streaming_state_conflict`. - try { - await params.publishErrorStatus(errorStatus); - } catch (err) { - log.warn("publishErrorStatus failed; falling back to chat.update", { - channelId: request.channelId, - statusTs: request.statusMessageTs, - error: String(err), - }); - await deps.im.updateMessage(request.channelId, request.statusMessageTs, errorStatus); - } - } else { - await deps.im.updateMessage(request.channelId, request.statusMessageTs, errorStatus); - } + await deps.im.updateMessage(request.channelId, request.statusMessageTs, errorStatus); deps.im.markMessageFinalized?.(request.channelId, request.statusMessageTs); - liveEventHistory.delete(getStatusMessageKey(request)); - liveParsedState.delete(getStatusMessageKey(request)); onFail(message); return { responses: null }; } finally { @@ -1533,5 +1073,9 @@ export async function runTrackedRequest( stopWatcher(); stopWatcher = null; } + const statusKey = getStatusMessageKey(request); + liveEventHistory.delete(statusKey); + liveParsedState.delete(statusKey); + liveRunEvents.delete(statusKey); } } diff --git a/packages/core/kernel/runtime-facade.ts b/packages/core/kernel/runtime-facade.ts index 21a5320c..2cf8c446 100644 --- a/packages/core/kernel/runtime-facade.ts +++ b/packages/core/kernel/runtime-facade.ts @@ -31,6 +31,13 @@ import { import type { InboundAdapter } from "@/ims/shared/inbound-adapter"; import type { RawInboundEvent } from "@/core/model/raw-inbound-event"; import type { RuntimeRequestContext } from "@/core/kernel/request-context"; +import { + getAgentInputText, + renderAgentInputAsText, + type AgentInput, + type InboundAttachment, + type OdeRunEvent, +} from "@/shared/agent-protocol"; export type RuntimeDeps = { platform: "slack" | "discord" | "lark"; @@ -41,12 +48,14 @@ export type RuntimeDeps = { type RuntimeState = { liveEventHistory: Map; liveParsedState: Map; + liveRunEvents: Map; }; function createRuntimeState(): RuntimeState { return { liveEventHistory: new Map(), liveParsedState: new Map(), + liveRunEvents: new Map(), }; } @@ -143,7 +152,7 @@ export class KernelRuntimeFacade { messageId: event.messageId, botToken: event.botId, }, - decision.text + decision.input ); }, }); @@ -157,6 +166,7 @@ export class KernelRuntimeFacade { mentionedBot: event.mentionedBot, activeThread: event.activeThread, normalizedText: event.normalizedText, + attachments: event.attachments, }), }; @@ -177,6 +187,7 @@ export class KernelRuntimeFacade { mentionedBot: event.mentionedBot, activeThread: event.activeThread, normalizedText: event.normalizedText, + attachments: event.attachments, }); if (decision.kind === "ignore") { @@ -206,7 +217,8 @@ export class KernelRuntimeFacade { messageId: event.messageId, botToken: event.botId, }, - decision.text + decision.text, + event.attachments ); } @@ -245,7 +257,9 @@ export class KernelRuntimeFacade { await recoverPendingRequestsInternal(this.runtimeDeps.im, this.deps.platform, options); } - private async handleUserMessageInternal(context: RuntimeRequestContext, text: string): Promise { + private async handleUserMessageInternal(context: RuntimeRequestContext, input: AgentInput): Promise { + const text = getAgentInputText(input); + const promptText = renderAgentInputAsText(input); const { channelId, replyThreadId, threadId } = context; const rawChannelId = context.rawChannelId ?? channelId; const prepared = await prepareRuntimeSession({ @@ -308,7 +322,7 @@ export class KernelRuntimeFacade { threadKey, messageId: context.messageId, userId: context.userId, - promptText: text, + promptText, }); const agentDetail = startAgentResult({ threadKey, @@ -337,7 +351,7 @@ export class KernelRuntimeFacade { context, sessionId, cwd, - message: text, + input, isFirstMessageInThread: created, agentContext, options, @@ -345,6 +359,7 @@ export class KernelRuntimeFacade { threadKey, liveEventHistory: this.state.liveEventHistory, liveParsedState: this.state.liveParsedState, + liveRunEvents: this.state.liveRunEvents, publishFinalText: async (params) => { await publishFinalText({ im: this.runtimeDeps.im, @@ -356,7 +371,11 @@ export class KernelRuntimeFacade { if (!responses) return; } - private async dispatchCoreMessage(context: RuntimeRequestContext, text: string): Promise { + private async dispatchCoreMessage( + context: RuntimeRequestContext, + text: string, + attachments: readonly InboundAttachment[] = [] + ): Promise { if (isMessageProcessed(context.channelId, context.threadId, context.messageId)) { log.debug("Skipping duplicate message", { messageId: context.messageId }); return; @@ -393,6 +412,7 @@ export class KernelRuntimeFacade { activeThread: true, rawText: text, normalizedText: text, + attachments, receivedAtMs: Date.now(), }); } diff --git a/packages/core/kernel/session-bootstrap.ts b/packages/core/kernel/session-bootstrap.ts index 4e5c480e..3f9a953c 100644 --- a/packages/core/kernel/session-bootstrap.ts +++ b/packages/core/kernel/session-bootstrap.ts @@ -7,6 +7,7 @@ import type { RuntimeRequestContext } from "@/core/kernel/request-context"; import { isSyntheticOwner } from "@/ims/shared/synthetic-owner"; import { log } from "@/utils"; import { createHash } from "crypto"; +import { randomUUID } from "node:crypto"; function isCiEnvironment(): boolean { const value = process.env.CI?.trim().toLowerCase(); @@ -139,6 +140,17 @@ export async function prepareRuntimeSession(params: { session.providerId = providerId; } + const now = Date.now(); + session.binding = { + odeSessionId: session.binding?.odeSessionId ?? randomUUID(), + providerId, + transport: deps.agent.getTransportForSession(sessionId), + nativeSessionId: sessionId, + capabilities: deps.agent.getCapabilitiesForSession(sessionId), + createdAt: session.binding?.createdAt ?? now, + updatedAt: now, + }; + if (session.platform !== deps.platform) { session.platform = deps.platform; } diff --git a/packages/core/kernel/stop-command.ts b/packages/core/kernel/stop-command.ts index 2ec59f9b..029a69a0 100644 --- a/packages/core/kernel/stop-command.ts +++ b/packages/core/kernel/stop-command.ts @@ -49,21 +49,6 @@ export async function handleStopCommand(params: { request.state = "failed"; request.error = "Stopped by user"; - if (request.statusStreamActive && request.statusStreamTs && deps.im.stopStatusStream) { - try { - await deps.im.stopStatusStream(request.channelId, request.statusStreamTs); - } catch (err) { - log.warn("Failed to stop active status stream on stop command", { - channelId: request.channelId, - threadId: request.threadId, - statusTs: request.statusStreamTs, - error: String(err), - }); - } - request.statusStreamActive = false; - request.statusStreamTs = undefined; - } - try { await deps.im.deleteMessage(request.channelId, request.statusMessageTs); } catch (err) { diff --git a/packages/core/model/inbound-decision.ts b/packages/core/model/inbound-decision.ts index 446e8d62..323cc12f 100644 --- a/packages/core/model/inbound-decision.ts +++ b/packages/core/model/inbound-decision.ts @@ -7,4 +7,5 @@ export type InboundIgnoreReason = export type InboundDecision = | { kind: "ignore"; reason: InboundIgnoreReason } | { kind: "stop" } - | { kind: "message"; text: string }; + | { kind: "message"; text: string; input: AgentInput }; +import type { AgentInput } from "@/shared/agent-protocol"; diff --git a/packages/core/model/raw-inbound-event.ts b/packages/core/model/raw-inbound-event.ts index 0fe95be4..9cb288ae 100644 --- a/packages/core/model/raw-inbound-event.ts +++ b/packages/core/model/raw-inbound-event.ts @@ -1,4 +1,5 @@ import type { BotPlatform } from "@/core/model/bot-key"; +import type { InboundAttachment } from "@/shared/agent-protocol"; export type RawInboundEvent = Readonly<{ platform: BotPlatform; @@ -17,5 +18,6 @@ export type RawInboundEvent = Readonly<{ activeThread: boolean; rawText: string; normalizedText: string; + attachments?: readonly InboundAttachment[]; receivedAtMs: number; }>; diff --git a/packages/core/onboarding.ts b/packages/core/onboarding.ts index c8ecff7e..80f58241 100644 --- a/packages/core/onboarding.ts +++ b/packages/core/onboarding.ts @@ -307,7 +307,6 @@ async function setupWorkspaces(rl: Interface, config: OdeConfig): Promise, + options: { itemId?: string; rawEvent?: Record } = {} +): OdeRunEvent { + return { + id: randomUUID(), + schemaVersion: ODE_RUN_EVENT_SCHEMA_VERSION, + timestamp: context.timestamp ?? Date.now(), + type, + providerId: context.providerId, + sessionId: context.sessionId, + runId: context.runId, + itemId: options.itemId, + data, + rawEvent: options.rawEvent, + }; +} + +export function deriveOdeRunEventsFromState(params: { + previous?: SessionMessageState; + next: SessionMessageState; + context: EventContext; +}): OdeRunEvent[] { + const { previous, next, context } = params; + const events: OdeRunEvent[] = []; + if (next.phaseStatus && next.phaseStatus !== previous?.phaseStatus) { + events.push(createOdeRunEvent(context, "run.progress", { phase: next.phaseStatus })); + } + if (next.thinkingText && next.thinkingText !== previous?.thinkingText) { + events.push(createOdeRunEvent(context, "reasoning.summary.delta", { + text: next.thinkingText, + snapshot: true, + })); + } + if (next.currentText && next.currentText !== previous?.currentText) { + events.push(createOdeRunEvent(context, "message.delta", { + text: next.currentText, + snapshot: true, + })); + } + + const previousTools = new Map((previous?.tools ?? []).map((tool) => [tool.id, tool])); + for (const tool of next.tools) { + const old = previousTools.get(tool.id); + if (old && JSON.stringify(old) === JSON.stringify(tool)) continue; + const type = tool.status === "error" + ? "tool.failed" + : tool.status === "completed" + ? "tool.completed" + : old + ? "tool.progress" + : "tool.started"; + events.push(createOdeRunEvent(context, type, { + name: tool.name, + title: tool.title, + status: tool.status, + input: tool.input, + output: tool.output, + error: tool.error, + metadata: tool.metadata, + }, { itemId: tool.id })); + } + + if (JSON.stringify(next.todos) !== JSON.stringify(previous?.todos ?? [])) { + events.push(createOdeRunEvent(context, "plan.updated", { + items: next.todos.map((todo) => ({ content: todo.content, status: todo.status })), + })); + } + if (next.tokenUsage && JSON.stringify(next.tokenUsage) !== JSON.stringify(previous?.tokenUsage)) { + events.push(createOdeRunEvent(context, "usage.updated", { ...next.tokenUsage })); + } + return events; +} diff --git a/packages/core/runtime/session-event-buffer.ts b/packages/core/runtime/session-event-buffer.ts new file mode 100644 index 00000000..879db37c --- /dev/null +++ b/packages/core/runtime/session-event-buffer.ts @@ -0,0 +1,156 @@ +import type { SessionEvent } from "@/utils/session-inspector"; + +type UnknownRecord = Record; + +const NOISY_EVENT_TYPES = new Set([ + "server.connected", + "server.heartbeat", + "plugin.added", + "catalog.updated", + "reference.updated", + "integration.updated", + "project.directories.updated", + "file.watcher.updated", +]); + +function asRecord(value: unknown): UnknownRecord | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? value as UnknownRecord + : undefined; +} + +function eventData(event: SessionEvent): UnknownRecord { + const payload = asRecord(event.data.payload); + return payload ?? event.data; +} + +function eventProperties(event: SessionEvent): UnknownRecord { + const data = eventData(event); + return asRecord(data.properties) ?? data; +} + +function sourceSessionId(event: SessionEvent): string { + const data = eventData(event); + const context = asRecord(data.odeContext); + const source = context?.sourceSessionID; + if (typeof source === "string" && source) return source; + const properties = eventProperties(event); + if (typeof properties.sessionID === "string") return properties.sessionID; + const part = asRecord(properties.part); + if (typeof part?.sessionID === "string") return part.sessionID; + const info = asRecord(properties.info); + if (typeof info?.sessionID === "string") return info.sessionID; + return "global"; +} + +/** + * Returns a stable slot for snapshot-style events. Replacing the prior value + * prevents an O(n²) replay of every intermediate OpenCode text/tool snapshot. + */ +export function getSessionEventCoalesceKey(event: SessionEvent): string | null { + const properties = eventProperties(event); + const source = sourceSessionId(event); + const part = asRecord(properties.part); + const partId = typeof part?.id === "string" + ? part.id + : typeof properties.partID === "string" + ? properties.partID + : undefined; + if (partId && (event.type.startsWith("message.part.") || event.type.startsWith("session.next."))) { + return `${source}:${event.type}:part:${partId}`; + } + + const info = asRecord(properties.info) ?? asRecord(properties.message); + const messageId = typeof info?.id === "string" ? info.id : undefined; + if (messageId && event.type === "message.updated") { + return `${source}:${event.type}:message:${messageId}`; + } + + if ( + event.type === "session.updated" + || event.type === "session.status" + || event.type === "session.diff" + || event.type === "todo.updated" + ) { + return `${source}:${event.type}`; + } + + return null; +} + +export function appendCoalescedSessionEvent( + history: SessionEvent[], + indexByKey: Map, + event: SessionEvent +): void { + const key = getSessionEventCoalesceKey(event); + if (!key) { + history.push(event); + return; + } + const existing = indexByKey.get(key); + if (existing === undefined) { + indexByKey.set(key, history.length); + history.push(event); + return; + } + history[existing] = event; +} + +/** + * Snapshot replacement keeps a stable array slot for O(1) writes. Replay by + * timestamp so a late idle/completion snapshot is not applied before newer + * tool events merely because it reused an older slot. + */ +export function orderSessionEventsChronologically(events: readonly SessionEvent[]): SessionEvent[] { + return events.slice().sort((left, right) => left.timestamp - right.timestamp); +} + +/** A sampling key for provider.raw persistence; null means preserve the event. */ +export function getRawProviderEventSamplingKey(event: SessionEvent): string | null { + const coalesceKey = getSessionEventCoalesceKey(event); + if (coalesceKey) return `snapshot:${coalesceKey}`; + if (NOISY_EVENT_TYPES.has(event.type)) return `noise:${event.type}`; + return null; +} + +export class SampledRawEventBuffer { + private readonly pending = new Map(); + private sequence = 0; + private retained = 0; + private dropped = 0; + private summaryEmitted = false; + + constructor(private readonly maxEvents: number) { + if (!Number.isInteger(maxEvents) || maxEvents < 1) { + throw new Error("maxEvents must be a positive integer"); + } + } + + enqueue(event: SessionEvent): void { + const samplingKey = getRawProviderEventSamplingKey(event); + this.pending.set(samplingKey ?? `event:${this.sequence++}`, event); + } + + drain(force = false): { + events: SessionEvent[]; + summary?: { dropped: number; retained: number }; + } { + const events: SessionEvent[] = []; + for (const event of this.pending.values()) { + if (this.retained >= this.maxEvents) { + this.dropped += 1; + } else { + events.push(event); + this.retained += 1; + } + } + this.pending.clear(); + + const summary = force && this.dropped > 0 && !this.summaryEmitted + ? { dropped: this.dropped, retained: this.retained } + : undefined; + if (summary) this.summaryEmitted = true; + return { events, summary }; + } +} diff --git a/packages/core/tasks/scheduler.test.ts b/packages/core/tasks/scheduler.test.ts index bfefeafa..b870128d 100644 --- a/packages/core/tasks/scheduler.test.ts +++ b/packages/core/tasks/scheduler.test.ts @@ -72,7 +72,7 @@ function makeTask(overrides: Partial = {}): TaskRecord { function seedSession( channelId: string, threadId: string, - providerId: "opencode" | "claudecode" | "codex" | "kimi" | "kiro" | "kilo" | "qwen" | "goose" | "gemini", + providerId: "opencode" | "claudecode" | "codex" | "kimi" | "kilo" | "qwen" | "goose", ): void { const now = Date.now(); saveSession( diff --git a/packages/core/tasks/scheduler.ts b/packages/core/tasks/scheduler.ts index 58d2d7b4..73fc4f50 100644 --- a/packages/core/tasks/scheduler.ts +++ b/packages/core/tasks/scheduler.ts @@ -41,6 +41,7 @@ import { sendChannelMessage as sendDiscordChannelMessage } from "@/ims/discord/c import { sendChannelMessage as sendLarkChannelMessage } from "@/ims/lark/client"; import { type AgentProviderId, isAgentProviderId } from "@/shared/agent-provider"; import { log } from "@/utils"; +import { createAgentInput } from "@/shared/agent-protocol"; // --------------------------------------------------------------------------- // One-time task scheduler. @@ -419,7 +420,7 @@ async function runTask(task: TaskRecord): Promise { agent.sendMessage( task.channelId, sessionId, - task.messageText, + createAgentInput(task.messageText), cwd, options, buildTaskAgentContext(task), diff --git a/packages/core/test/adapter-contracts.test.ts b/packages/core/test/adapter-contracts.test.ts index d525734e..9548b9bd 100644 --- a/packages/core/test/adapter-contracts.test.ts +++ b/packages/core/test/adapter-contracts.test.ts @@ -1,12 +1,15 @@ import type { AgentAdapter, IMAdapter } from "@/core/types"; import { runAgentAdapterContractSuite } from "./contracts/agent-adapter-contract"; import { runImAdapterContractSuite } from "./contracts/im-adapter-contract"; +import { LEGACY_AGENT_CAPABILITIES } from "@/shared/agent-protocol"; function makeFakeAgentAdapter(): AgentAdapter { return { supportsEventStream: false, getProviderForSession: () => "opencode", getDisplayNameForSession: () => "OpenCode", + getTransportForSession: () => "cli-json", + getCapabilitiesForSession: () => LEGACY_AGENT_CAPABILITIES, getOrCreateSession: async () => ({ sessionId: "s1", created: true }), sendMessage: async () => [{ text: "ok", messageType: "assistant" }], abortSession: async () => {}, diff --git a/packages/core/test/contracts/agent-adapter-contract.ts b/packages/core/test/contracts/agent-adapter-contract.ts index cd21cec3..e4c539f5 100644 --- a/packages/core/test/contracts/agent-adapter-contract.ts +++ b/packages/core/test/contracts/agent-adapter-contract.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import type { AgentAdapter } from "@/core/types"; +import { createAgentInput } from "@/shared/agent-protocol"; export function runAgentAdapterContractSuite(name: string, makeAdapter: () => AgentAdapter): void { describe(`AgentAdapter contract: ${name}`, () => { @@ -21,7 +22,12 @@ export function runAgentAdapterContractSuite(name: string, makeAdapter: () => Ag expect(typeof session.sessionId).toBe("string"); expect(session.sessionId.length).toBeGreaterThan(0); - const responses = await adapter.sendMessage("C1", session.sessionId, "hello", "/tmp"); + const responses = await adapter.sendMessage( + "C1", + session.sessionId, + createAgentInput("hello"), + "/tmp" + ); expect(Array.isArray(responses)).toBe(true); if (responses[0]) { expect(typeof responses[0].text).toBe("string"); diff --git a/packages/core/test/live-provider-smoke.e2e.test.ts b/packages/core/test/live-provider-smoke.e2e.test.ts index 7ba1f801..622786f8 100644 --- a/packages/core/test/live-provider-smoke.e2e.test.ts +++ b/packages/core/test/live-provider-smoke.e2e.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import { createAgentAdapter } from "@/agents/adapter"; +import { createAgentInput } from "@/shared/agent-protocol"; const runLive = process.env.RUN_LIVE_E2E === "1"; @@ -31,7 +32,7 @@ describe("live provider smoke e2e", () => { const responses = await adapter.sendMessage( channelId, session.sessionId, - "Reply with exactly LIVE_E2E_OK and nothing else.", + createAgentInput("Reply with exactly LIVE_E2E_OK and nothing else."), cwd, options ); diff --git a/packages/core/test/pending-question.test.ts b/packages/core/test/pending-question.test.ts index ff55040a..0d97b68a 100644 --- a/packages/core/test/pending-question.test.ts +++ b/packages/core/test/pending-question.test.ts @@ -146,80 +146,6 @@ describe("handlePendingQuestionReply", () => { deleteSession(channelId, threadId); }); - it("uses sendQuestion for follow-up questions when the IM supports it", async () => { - const channelId = "CQ-PENDING-SENDQ"; - const threadId = "TQ-PENDING-SENDQ"; - const userId = "U-OWNER-SENDQ"; - const pending: PendingQuestion = { - requestId: "req-sendq", - sessionId: "ses-sendq", - askedAt: Date.now(), - questions: [ - { question: "Q1" }, - { question: "Q2", options: ["yes", "no"] }, - ], - collectedAnswers: [], - }; - - saveSession({ - sessionId: "ses-sendq", - channelId, - threadId, - workingDirectory: "/tmp", - threadOwnerUserId: userId, - createdAt: Date.now(), - lastActivityAt: Date.now(), - pendingQuestion: pending, - }); - setPendingQuestion(channelId, threadId, pending); - - const sendQuestionCalls: Array<{ - question: string; - options?: string[]; - prefix?: string; - }> = []; - const sendMessageCalls: string[] = []; - const deps = { - agent: { replyToQuestion: async () => {} } as any, - im: { - sendMessage: async (_c: string, _t: string, text: string) => { - sendMessageCalls.push(text); - return undefined; - }, - sendQuestion: async ( - _c: string, - _t: string, - question: string, - options: string[] | undefined, - prefix?: string - ) => { - sendQuestionCalls.push({ question, options, prefix }); - return undefined; - }, - } as any, - }; - - await handlePendingQuestionReply({ - deps, - pendingQuestion: pending, - context: { - channelId, - replyThreadId: threadId, - threadId, - userId, - messageId: "m-sendq-1", - }, - text: "answer 1", - }); - - expect(sendQuestionCalls).toEqual([ - { question: "Q2", options: ["yes", "no"], prefix: "(2/2) " }, - ]); - expect(sendMessageCalls).toEqual([]); - - deleteSession(channelId, threadId); - }); - it("ignores non-owner replies", async () => { const channelId = "CQ-PENDING-2"; const threadId = "TQ-PENDING-2"; diff --git a/packages/core/test/recovery.test.ts b/packages/core/test/recovery.test.ts index 02503f86..7f47edd0 100644 --- a/packages/core/test/recovery.test.ts +++ b/packages/core/test/recovery.test.ts @@ -34,48 +34,6 @@ describe("recoverPendingRequests", () => { deleteSession(channelId, threadId); }); - it("stops persisted status stream before updating recovered request", async () => { - const channelId = "CR-STREAM"; - const threadId = "TR-STREAM"; - const streamTs = "stream-123.45"; - - const active = createActiveRequest("ses-stream", channelId, threadId, threadId, streamTs, "hello"); - active.startedAt = Date.now() - 60_000; - active.statusStreamActive = true; - active.statusStreamTs = streamTs; - - saveSession({ - sessionId: "ses-stream", - channelId, - threadId, - workingDirectory: "/tmp", - createdAt: Date.now(), - lastActivityAt: Date.now(), - activeRequest: active, - }); - - const events: string[] = []; - const stopped = new Set(); - await recoverPendingRequests({ - stopStatusStream: async (_channelId: string, ts: string) => { - events.push(`stop:${ts}`); - stopped.add(ts); - }, - updateMessage: async (_channelId: string, ts: string, text: string) => { - events.push(`update:${ts}:${text}`); - if (!stopped.has(ts)) { - throw new Error("streaming_state_conflict"); - } - }, - } as any); - - expect(events[0]).toBe(`stop:${streamTs}`); - expect(events[1]).toBe(`update:${streamTs}:_Bot restarted - please resend your message_`); - expect(loadSession(channelId, threadId)?.activeRequest).toBeUndefined(); - - deleteSession(channelId, threadId); - }); - it("clears stale request without update", async () => { const channelId = "CR-2"; const threadId = "TR-2"; diff --git a/packages/core/test/runtime-e2e.test.ts b/packages/core/test/runtime-e2e.test.ts index 0401fc57..96e3205b 100644 --- a/packages/core/test/runtime-e2e.test.ts +++ b/packages/core/test/runtime-e2e.test.ts @@ -14,6 +14,8 @@ import { } from "@/config/local/sessions"; import type { AgentAdapter, IMAdapter } from "@/core/types"; import type { RawInboundEvent } from "@/core/model/raw-inbound-event"; +import { renderAgentInputAsText } from "@/shared/agent-protocol"; +import { LEGACY_AGENT_CAPABILITIES } from "@/shared/agent-protocol"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; @@ -107,8 +109,11 @@ function createFakeAgent(params?: { supportsEventStream: false, getProviderForSession: () => "opencode", getDisplayNameForSession: () => "FakeAgent", + getTransportForSession: () => "cli-json", + getCapabilitiesForSession: () => LEGACY_AGENT_CAPABILITIES, getOrCreateSession: async () => ({ sessionId: "session-e2e", created: true }), - sendMessage: async (_channelId, _sessionId, message) => { + sendMessage: async (_channelId, _sessionId, input) => { + const message = renderAgentInputAsText(input); sentPrompts.push(message); await params?.onSend?.(message); return params?.responses ?? [{ text: "Hello from fake agent", messageType: "assistant" }]; diff --git a/packages/core/test/runtime-helpers.test.ts b/packages/core/test/runtime-helpers.test.ts index 8b863e3b..49be928d 100644 --- a/packages/core/test/runtime-helpers.test.ts +++ b/packages/core/test/runtime-helpers.test.ts @@ -99,6 +99,15 @@ describe("runtime helpers", () => { expect(result.suggestion).toContain("Wait a moment"); }); + it("categorizes stale OpenCode prompts as recoverable idle runs", () => { + const error = new Error("OpenCode stopped reporting progress"); + error.name = "OpenCodeIdlePromptError"; + expect(categorizeRuntimeError(error)).toEqual({ + message: "OpenCode run became idle before completing", + suggestion: "Ode stopped the stale run. Retry in the same thread; a new turn can reuse the session history.", + }); + }); + describe("hasSimpleOptions", () => { it("accepts 2-5 short options", () => { expect(hasSimpleOptions(["yes", "no"])).toBe(true); diff --git a/packages/core/test/runtime-resilience-e2e.test.ts b/packages/core/test/runtime-resilience-e2e.test.ts index 28f720ef..2fdf6d6c 100644 --- a/packages/core/test/runtime-resilience-e2e.test.ts +++ b/packages/core/test/runtime-resilience-e2e.test.ts @@ -13,6 +13,8 @@ import { } from "@/config/local/sessions"; import type { AgentAdapter, IMAdapter } from "@/core/types"; import type { RawInboundEvent } from "@/core/model/raw-inbound-event"; +import { renderAgentInputAsText } from "@/shared/agent-protocol"; +import { LEGACY_AGENT_CAPABILITIES } from "@/shared/agent-protocol"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; @@ -135,8 +137,11 @@ function createFakeAgent(params?: { supportsEventStream, getProviderForSession: () => "opencode", getDisplayNameForSession: () => "FakeAgent", + getTransportForSession: () => "cli-json", + getCapabilitiesForSession: () => LEGACY_AGENT_CAPABILITIES, getOrCreateSession: async () => ({ sessionId: "session-resilience", created: true }), - sendMessage: async (_channelId, _sessionId, message) => { + sendMessage: async (_channelId, _sessionId, input) => { + const message = renderAgentInputAsText(input); sentPrompts.push(message); if (params?.errorMessage) { throw new Error(params.errorMessage); @@ -222,7 +227,6 @@ function toInboundEvent(params: { describe("core runtime resilience e2e", () => { const previousCi = process.env.CI; const previousInboxDbFile = process.env.ODE_INBOX_DB_FILE; - const previousSlackStatusStreaming = process.env.ODE_SLACK_STATUS_STREAMING; beforeAll(() => { process.env.CI = "1"; @@ -246,11 +250,6 @@ describe("core runtime resilience e2e", () => { } else { process.env.CI = previousCi; } - if (previousSlackStatusStreaming === undefined) { - delete process.env.ODE_SLACK_STATUS_STREAMING; - } else { - process.env.ODE_SLACK_STATUS_STREAMING = previousSlackStatusStreaming; - } }); it("falls back to sending final message when status updates are rate-limited", async () => { @@ -378,318 +377,6 @@ describe("core runtime resilience e2e", () => { deleteSession(context.channelId, context.threadId); }); - it("preserves full error status after stopping an active status stream", async () => { - process.env.ODE_SLACK_STATUS_STREAMING = "1"; - const logs = { - sends: [] as Array<{ channelId: string; threadId: string; text: string; messageTs: string }>, - updates: [] as Array<{ channelId: string; messageTs: string; text: string }>, - appends: [] as Array<{ channelId: string; messageTs: string; chunks: unknown[] }>, - stops: [] as Array<{ channelId: string; messageTs: string }>, - events: [] as string[], - }; - let nextTs = 0; - const streamTs = "stream-1"; - const im: IMAdapter = { - sendMessage: async (channelId, threadId, text) => { - nextTs += 1; - const messageTs = `ts-${nextTs}`; - logs.events.push(`send:${text}`); - logs.sends.push({ channelId, threadId, text, messageTs }); - return messageTs; - }, - updateMessage: async (channelId, messageTs, text) => { - logs.updates.push({ channelId, messageTs, text }); - if (messageTs === streamTs) { - throw new Error("streaming_state_conflict"); - } - }, - deleteMessage: async () => {}, - fetchThreadHistory: async () => null, - buildAgentContext: async () => ({ slack: { channelId: "C", threadId: "T", userId: "U" } }), - startStatusStream: async () => streamTs, - appendStatusStream: async (channelId, messageTs, chunks) => { - logs.appends.push({ channelId, messageTs, chunks }); - }, - stopStatusStream: async (channelId, messageTs) => { - logs.events.push(`stop:${messageTs}`); - logs.stops.push({ channelId, messageTs }); - }, - }; - const { agent } = createFakeAgent({ - supportsEventStream: true, - errorMessage: "tool exploded with full details", - }); - const runtime = createCoreRuntime({ platform: "slack", im, agent }); - const channelId = uniqueId("CE2E-STREAM-ERR"); - const threadId = uniqueId("TE2E-STREAM-ERR"); - - await runtime.handleInboundEvent(toInboundEvent({ - channelId, - threadId, - userId: "UE2E-stream-err", - messageId: uniqueId("ME2E-stream-err"), - text: "trigger streaming error", - })); - - await waitFor( - () => logs.sends.some((entry) => entry.text.includes("Error: tool exploded with full details")), - 5000 - ); - - const errorMessage = logs.sends.find((entry) => entry.text.includes("Error: tool exploded with full details")); - expect(errorMessage?.text).toContain("_If this persists, try starting a new thread or contact support._"); - expect(logs.stops).toEqual([{ channelId, messageTs: streamTs }]); - expect(logs.updates.some((entry) => entry.messageTs === streamTs)).toBe(false); - expect(logs.events.indexOf(`stop:${streamTs}`)).toBeLessThan( - logs.events.findIndex((entry) => entry.startsWith("send:Error: tool exploded")) - ); - - deleteSession(channelId, threadId); - }); - - it("keeps persisted stream state active when stopStream fails during finalization", async () => { - process.env.ODE_SLACK_STATUS_STREAMING = "1"; - const streamTs = "stream-stop-fail"; - const logs = { - sends: [] as Array<{ channelId: string; threadId: string; text: string; messageTs: string }>, - appends: [] as Array<{ channelId: string; messageTs: string; chunks: unknown[] }>, - stops: [] as Array<{ channelId: string; messageTs: string }>, - deletes: [] as Array<{ channelId: string; messageTs: string }>, - }; - let nextTs = 0; - const im: IMAdapter = { - sendMessage: async (channelId, threadId, text) => { - nextTs += 1; - const messageTs = `ts-${nextTs}`; - logs.sends.push({ channelId, threadId, text, messageTs }); - return messageTs; - }, - updateMessage: async () => {}, - deleteMessage: async (channelId, messageTs) => { - logs.deletes.push({ channelId, messageTs }); - if (messageTs === streamTs) { - throw new Error("streaming_state_conflict"); - } - }, - fetchThreadHistory: async () => null, - buildAgentContext: async () => ({ slack: { channelId: "C", threadId: "T", userId: "U" } }), - startStatusStream: async () => streamTs, - appendStatusStream: async (channelId, messageTs, chunks) => { - logs.appends.push({ channelId, messageTs, chunks }); - }, - stopStatusStream: async (channelId, messageTs) => { - logs.stops.push({ channelId, messageTs }); - throw new Error("temporarily_unavailable"); - }, - }; - const { agent } = createFakeAgent({ - supportsEventStream: true, - responseText: "finished despite stop failure", - }); - const runtime = createCoreRuntime({ platform: "slack", im, agent }); - const channelId = uniqueId("CE2E-STREAM-STOP-FAIL"); - const threadId = uniqueId("TE2E-STREAM-STOP-FAIL"); - - await runtime.handleInboundEvent(toInboundEvent({ - channelId, - threadId, - userId: "UE2E-stream-stop-fail", - messageId: uniqueId("ME2E-stream-stop-fail"), - text: "trigger streaming stop failure", - })); - - await waitFor( - () => logs.sends.some((entry) => entry.text === "finished despite stop failure"), - 5000 - ); - - const savedRequest = loadSession(channelId, threadId)?.activeRequest; - expect(logs.stops).toEqual([{ channelId, messageTs: streamTs }]); - expect(logs.deletes).toContainEqual({ channelId, messageTs: streamTs }); - expect(savedRequest?.statusStreamActive).toBe(true); - expect(savedRequest?.statusStreamTs).toBe(streamTs); - - deleteSession(channelId, threadId); - }); - - it("recreates the Slack AI card when append finds a stale stream", async () => { - process.env.ODE_SLACK_STATUS_STREAMING = "1"; - const logs = { - sends: [] as Array<{ channelId: string; threadId: string; text: string; messageTs: string }>, - starts: [] as Array<{ channelId: string; threadId: string; messageTs: string }>, - appends: [] as Array<{ channelId: string; messageTs: string; chunks: unknown[] }>, - stops: [] as Array<{ channelId: string; messageTs: string }>, - deletes: [] as Array<{ channelId: string; messageTs: string }>, - }; - let nextSendTs = 0; - let nextStreamTs = 0; - let failedFirstAppend = false; - const im: IMAdapter = { - sendMessage: async (channelId, threadId, text) => { - nextSendTs += 1; - const messageTs = `ts-${nextSendTs}`; - logs.sends.push({ channelId, threadId, text, messageTs }); - return messageTs; - }, - updateMessage: async () => {}, - deleteMessage: async (channelId, messageTs) => { - logs.deletes.push({ channelId, messageTs }); - }, - fetchThreadHistory: async () => null, - buildAgentContext: async () => ({ slack: { channelId: "C", threadId: "T", userId: "U" } }), - startStatusStream: async (channelId, threadId) => { - nextStreamTs += 1; - const messageTs = `stream-${nextStreamTs}`; - logs.starts.push({ channelId, threadId, messageTs }); - return messageTs; - }, - appendStatusStream: async (channelId, messageTs, chunks) => { - if (!failedFirstAppend && messageTs === "stream-1") { - failedFirstAppend = true; - throw new Error("message_not_in_streaming_state"); - } - logs.appends.push({ channelId, messageTs, chunks }); - }, - stopStatusStream: async (channelId, messageTs) => { - logs.stops.push({ channelId, messageTs }); - }, - }; - const { agent } = createFakeAgent({ - supportsEventStream: true, - emitToolEvent: true, - streamStopAfterMs: 2500, - delayMs: 2600, - responseText: "finished after stream recreation", - }); - const runtime = createCoreRuntime({ platform: "slack", im, agent }); - const channelId = uniqueId("CE2E-STREAM-STALE"); - const threadId = uniqueId("TE2E-STREAM-STALE"); - - await runtime.handleInboundEvent(toInboundEvent({ - channelId, - threadId, - userId: "UE2E-stream-stale", - messageId: uniqueId("ME2E-stream-stale"), - text: "trigger stale stream recovery", - })); - - await waitFor( - () => logs.stops.some((entry) => entry.messageTs === "stream-2"), - 5000 - ); - - expect(logs.starts.map((entry) => entry.messageTs)).toEqual(["stream-1", "stream-2"]); - expect(logs.deletes).toContainEqual({ channelId, messageTs: "stream-1" }); - expect(logs.appends.some((entry) => entry.messageTs === "stream-2")).toBe(true); - const stream2Chunks = logs.appends - .filter((entry) => entry.messageTs === "stream-2") - .flatMap((entry) => entry.chunks); - expect(stream2Chunks).toContainEqual(expect.objectContaining({ - id: "result", - status: "complete", - title: "Result", - type: "task_update", - })); - expect(logs.stops).toContainEqual({ channelId, messageTs: "stream-2" }); - - deleteSession(channelId, threadId); - }, 10_000); - - it("cleans up a replacement Slack AI card when seeding it fails", async () => { - await withMessageUpdateInterval(5_000, async () => { - process.env.ODE_SLACK_STATUS_STREAMING = "1"; - const logs = { - sends: [] as Array<{ channelId: string; threadId: string; text: string; messageTs: string }>, - starts: [] as Array<{ channelId: string; threadId: string; messageTs: string }>, - appends: [] as Array<{ channelId: string; messageTs: string; chunks: unknown[] }>, - stops: [] as Array<{ channelId: string; messageTs: string }>, - deletes: [] as Array<{ channelId: string; messageTs: string }>, - updates: [] as Array<{ channelId: string; messageTs: string; text: string }>, - }; - let nextSendTs = 0; - let nextStreamTs = 0; - let failedFirstAppend = false; - const im: IMAdapter = { - sendMessage: async (channelId, threadId, text) => { - nextSendTs += 1; - const messageTs = `ts-${nextSendTs}`; - logs.sends.push({ channelId, threadId, text, messageTs }); - return messageTs; - }, - updateMessage: async (channelId, messageTs, text) => { - logs.updates.push({ channelId, messageTs, text }); - }, - deleteMessage: async (channelId, messageTs) => { - logs.deletes.push({ channelId, messageTs }); - }, - fetchThreadHistory: async () => null, - buildAgentContext: async () => ({ slack: { channelId: "C", threadId: "T", userId: "U" } }), - startStatusStream: async (channelId, threadId) => { - nextStreamTs += 1; - const messageTs = `stream-${nextStreamTs}`; - logs.starts.push({ channelId, threadId, messageTs }); - return messageTs; - }, - appendStatusStream: async (channelId, messageTs, chunks) => { - if (!failedFirstAppend && messageTs === "stream-1") { - failedFirstAppend = true; - throw new Error("message_not_in_streaming_state"); - } - if (messageTs === "stream-2") { - throw new Error("rate_limited while seeding replacement stream"); - } - logs.appends.push({ channelId, messageTs, chunks }); - }, - stopStatusStream: async (channelId, messageTs) => { - logs.stops.push({ channelId, messageTs }); - }, - }; - const { agent } = createFakeAgent({ - supportsEventStream: true, - emitToolEvent: true, - streamStopAfterMs: 2500, - delayMs: 3800, - responseText: "finished after replacement fallback", - }); - const runtime = createCoreRuntime({ platform: "slack", im, agent }); - const channelId = uniqueId("CE2E-STREAM-SEED-FAIL"); - const threadId = uniqueId("TE2E-STREAM-SEED-FAIL"); - - await runtime.handleInboundEvent(toInboundEvent({ - channelId, - threadId, - userId: "UE2E-stream-seed-fail", - messageId: uniqueId("ME2E-stream-seed-fail"), - text: "trigger replacement stream seed failure", - })); - - await waitFor(() => { - const savedRequest = loadSession(channelId, threadId)?.activeRequest; - return Boolean( - savedRequest?.statusStreamActive === false && - savedRequest.statusMessageTs && - logs.sends.some((entry) => entry.messageTs === savedRequest.statusMessageTs) - ); - }, 12_000); - - const savedRequest = loadSession(channelId, threadId)?.activeRequest; - const fallbackMessage = logs.sends.find( - (entry) => entry.messageTs === savedRequest?.statusMessageTs - ); - await sleep(1_200); - expect(logs.starts.map((entry) => entry.messageTs)).toEqual(["stream-1", "stream-2"]); - expect(logs.deletes).toContainEqual({ channelId, messageTs: "stream-1" }); - expect(logs.stops).toContainEqual({ channelId, messageTs: "stream-2" }); - expect(logs.deletes).toContainEqual({ channelId, messageTs: "stream-2" }); - expect(savedRequest?.statusStreamActive).toBe(false); - expect(savedRequest?.statusStreamTs).toBeUndefined(); - expect(fallbackMessage).toBeDefined(); - expect(logs.updates.some((entry) => entry.messageTs === fallbackMessage?.messageTs)).toBe(false); - - deleteSession(channelId, threadId); - }); - }, 20_000); - it("does not crash when the initial status send throws", async () => { const logs = { sends: [], updates: [] } as { sends: Array<{ channelId: string; threadId: string; text: string; messageTs: string }>; diff --git a/packages/core/test/session-event-buffer.test.ts b/packages/core/test/session-event-buffer.test.ts new file mode 100644 index 00000000..6979a664 --- /dev/null +++ b/packages/core/test/session-event-buffer.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "bun:test"; +import { + appendCoalescedSessionEvent, + getRawProviderEventSamplingKey, + orderSessionEventsChronologically, + SampledRawEventBuffer, +} from "@/core/runtime/session-event-buffer"; +import type { SessionEvent } from "@/utils/session-inspector"; + +function textEvent(text: string, timestamp: number): SessionEvent { + return { + timestamp, + type: "message.part.updated", + data: { + type: "message.part.updated", + properties: { + sessionID: "child-1", + part: { id: "part-1", sessionID: "child-1", type: "text", text }, + }, + }, + }; +} + +describe("session event buffering", () => { + it("replaces repeated snapshots for the same part", () => { + const history: SessionEvent[] = []; + const indexes = new Map(); + appendCoalescedSessionEvent(history, indexes, textEvent("a", 1)); + appendCoalescedSessionEvent(history, indexes, textEvent("ab", 2)); + expect(history).toHaveLength(1); + expect((history[0]?.data.properties as any).part.text).toBe("ab"); + }); + + it("samples noisy and snapshot raw events by stable keys", () => { + expect(getRawProviderEventSamplingKey(textEvent("a", 1))).toContain("part-1"); + expect(getRawProviderEventSamplingKey({ + timestamp: 1, + type: "server.heartbeat", + data: {}, + })).toBe("noise:server.heartbeat"); + }); + + it("replays replaced snapshots according to their latest timestamp", () => { + const history: SessionEvent[] = []; + const indexes = new Map(); + appendCoalescedSessionEvent(history, indexes, textEvent("first", 1)); + appendCoalescedSessionEvent(history, indexes, { + timestamp: 2, + type: "question.asked", + data: { id: "question-1" }, + }); + appendCoalescedSessionEvent(history, indexes, textEvent("latest", 3)); + + expect(orderSessionEventsChronologically(history).map((event) => event.timestamp)).toEqual([2, 3]); + }); + + it("caps retained raw events and reports dropped events once", () => { + const buffer = new SampledRawEventBuffer(2); + buffer.enqueue({ timestamp: 1, type: "question.asked", data: { id: "1" } }); + buffer.enqueue({ timestamp: 2, type: "question.replied", data: { id: "2" } }); + buffer.enqueue({ timestamp: 3, type: "run.extra", data: { id: "3" } }); + expect(buffer.drain(true)).toEqual({ + events: [ + { timestamp: 1, type: "question.asked", data: { id: "1" } }, + { timestamp: 2, type: "question.replied", data: { id: "2" } }, + ], + summary: { dropped: 1, retained: 2 }, + }); + expect(buffer.drain(true)).toEqual({ events: [], summary: undefined }); + }); +}); diff --git a/packages/core/test/status-message.test.ts b/packages/core/test/status-message.test.ts index 4a989178..78417115 100644 --- a/packages/core/test/status-message.test.ts +++ b/packages/core/test/status-message.test.ts @@ -82,7 +82,7 @@ describe("buildStatusMessageForAgent", () => { }); expect(text).toContain("*OpenCode is running...*"); - expect(text).toContain("_Thinking_"); + expect(text).toContain("*Thinking*"); }); it("keeps title visible when model and agent are present", () => { diff --git a/packages/core/test/stop-command.test.ts b/packages/core/test/stop-command.test.ts deleted file mode 100644 index 7e7539d1..00000000 --- a/packages/core/test/stop-command.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { createActiveRequest, deleteSession, loadSession, saveSession } from "@/config/local/sessions"; -import { handleStopCommand } from "../kernel/stop-command"; -import type { AgentAdapter, IMAdapter } from "@/core/types"; - -function createAgent(): AgentAdapter { - return { - supportsEventStream: false, - getProviderForSession: () => "opencode", - getDisplayNameForSession: () => "FakeAgent", - getOrCreateSession: async () => ({ sessionId: "session-stop", created: false }), - sendMessage: async () => [], - abortSession: async () => {}, - ensureSession: async () => {}, - subscribeToSession: () => () => {}, - replyToQuestion: async () => {}, - normalizeQuestions: () => [], - }; -} - -describe("handleStopCommand", () => { - it("stops an active status stream before deleting the status message", async () => { - const channelId = `C-stop-${process.pid}-${Date.now()}`; - const threadId = `T-stop-${process.pid}-${Date.now()}`; - const statusTs = "123.456"; - const activeRequest = createActiveRequest("session-stop", channelId, threadId, threadId, statusTs, "hello"); - activeRequest.statusStreamActive = true; - activeRequest.statusStreamTs = statusTs; - - saveSession({ - sessionId: "session-stop", - channelId, - threadId, - workingDirectory: "/tmp", - createdAt: Date.now(), - lastActivityAt: Date.now(), - activeRequest, - }, { immediate: true }); - - const calls: string[] = []; - const im: IMAdapter = { - sendMessage: async () => "unused", - updateMessage: async () => undefined, - stopStatusStream: async (_channelId, messageTs) => { - calls.push(`stop:${messageTs}`); - }, - deleteMessage: async (_channelId, messageTs) => { - calls.push(`delete:${messageTs}`); - }, - fetchThreadHistory: async () => null, - buildAgentContext: async () => ({}), - }; - - await handleStopCommand({ - deps: { agent: createAgent(), im }, - channelId, - threadId, - }); - - expect(calls).toEqual([`stop:${statusTs}`, `delete:${statusTs}`]); - const saved = loadSession(channelId, threadId)?.activeRequest; - expect(saved?.state).toBe("failed"); - expect(saved?.statusStreamActive).toBe(false); - - deleteSession(channelId, threadId); - }); -}); diff --git a/packages/core/test/web-routes.test.ts b/packages/core/test/web-routes.test.ts index c9a1e724..09dcdc8d 100644 --- a/packages/core/test/web-routes.test.ts +++ b/packages/core/test/web-routes.test.ts @@ -9,10 +9,12 @@ import { clearMessageStoreForTests, closeMessageDatabaseForTests, ensureMessageThread, + recordOdeRunEvents, recordUserPrompt, startAgentResult, completeAgentResult, } from "@/config/local/inbox"; +import { createOdeRunEvent } from "@/core/runtime/ode-run-events"; import { createWebApp } from "@/core/web/app"; import { collapseTextDeltas } from "@/core/web/session-events"; import * as fs from "fs"; @@ -90,6 +92,19 @@ describe("web app routing", () => { detailId: agentDetail.id, resultText: "All builds green.", }); + recordOdeRunEvents(threadKey, [ + createOdeRunEvent( + { providerId: "opencode", sessionId: "session-web", runId: "run-web", timestamp: 10 }, + "run.progress", + { phase: "Reading tests" }, + ), + createOdeRunEvent( + { providerId: "opencode", sessionId: "session-web", runId: "run-web", timestamp: 11 }, + "provider.raw", + { providerType: "message.part.updated" }, + { rawEvent: { type: "message.part.updated" } }, + ), + ]); const app = createWebApp(); const listResponse = await app.handle(new Request("http://localhost/api/message-threads?page=1&pageSize=5")); @@ -122,6 +137,20 @@ describe("web app routing", () => { const agentResult = detailPayload.result?.details.find((d) => d.kind === "agent_result"); expect(userPrompt?.promptText).toBe("show me the latest build failures"); expect(agentResult?.resultText).toBe("All builds green."); + + const eventsResponse = await app.handle(new Request( + `http://localhost/api/message-threads/${encodeURIComponent(threadKey)}/events?limit=20` + )); + expect(eventsResponse.status).toBe(200); + const eventsPayload = await eventsResponse.json() as { + ok: boolean; + result?: { total: number; items: Array<{ type: string; data: Record }> }; + }; + expect(eventsPayload.ok).toBe(true); + expect(eventsPayload.result?.total).toBe(1); + expect(eventsPayload.result?.items).toEqual([ + expect.objectContaining({ type: "run.progress", data: { phase: "Reading tests" } }), + ]); }); it("returns cron job list payload", async () => { diff --git a/packages/core/types.ts b/packages/core/types.ts index df79fb19..24eeb276 100644 --- a/packages/core/types.ts +++ b/packages/core/types.ts @@ -3,6 +3,9 @@ import type { OpenCodeMessageContext, OpenCodeOptions, OpenCodeSessionInfo, + AgentInput, + AgentCapabilities, + AgentTransport, } from "@/agents"; import type { StatusMessageFormat } from "@/config"; import type { AgentProviderId } from "@/shared/agent-provider"; @@ -41,74 +44,9 @@ export type AgentStatusMessageParams = { statusMessageFormat: StatusMessageFormat; }; -/** - * Chunk emitted by the status-stream renderer. Mirrors the shape of Slack's - * `chat.appendStream` `chunks` payload, but kept platform-agnostic so other - * IM adapters can choose their own rendering (or ignore it). - * - * - `task_update`: one tool / step card. `id` is a stable per-tool key, status - * transitions from `pending` → `in_progress` → `complete`/`error`. - * - `plan_update`: rename the surrounding plan container (e.g. session title - * or phase label). - * - `markdown_text`: free-form markdown chunk appended to the stream body. - */ -export type StatusStreamChunk = - | { - type: "task_update"; - id: string; - title: string; - status: "pending" | "in_progress" | "complete" | "error"; - details?: string; - output?: string; - sources?: Array<{ type: "url"; text: string; url: string }>; - } - | { type: "plan_update"; title: string } - | { type: "markdown_text"; text: string }; - export interface IMAdapter { maxEditableMessageChars?: number; sendMessage(channelId: string, threadId: string, text: string): Promise; - /** - * Optional. When implemented, the runtime uses Slack's (or equivalent) - * Streaming API to render live status updates — `task_update` / - * `plan_update` chunks render as animated task cards instead of repeated - * `chat.update` calls against a plain-text message. - * - * Channel (non-DM) streams on Slack require the requesting user's id and - * team id; pass them on `startStatusStream` and the adapter forwards. - * - * Lifecycle: `startStatusStream` once → many `appendStatusStream` calls → - * one `stopStatusStream` (or fall back to `updateMessage` if the stream - * was never started for this message TS). - * - * Slack-specific quirk: the stream is mode-locked to "chunks" at start; - * `appendStatusStream` / `stopStatusStream` cannot mix in plain markdown. - */ - startStatusStream?( - channelId: string, - threadId: string, - opts: { recipientUserId: string; seedPlanTitle?: string } - ): Promise; - appendStatusStream?( - channelId: string, - messageTs: string, - chunks: StatusStreamChunk[] - ): Promise; - stopStatusStream?(channelId: string, messageTs: string): Promise; - /** - * Optional. When present, the runtime calls this for ask_user-style prompts - * so the IM can render interactive UI (e.g. Slack buttons) when the options - * are simple enough. Implementations are free to fall back to plain text. - * `prefix` is an optional leading marker like "(1/2) " for multi-question - * flows. - */ - sendQuestion?( - channelId: string, - threadId: string, - question: string, - options: string[] | undefined, - prefix?: string - ): Promise; updateMessage( channelId: string, messageTs: string, @@ -129,6 +67,8 @@ export interface AgentAdapter { supportsEventStream: boolean; getProviderForSession(sessionId: string): AgentProviderId; getDisplayNameForSession(sessionId: string): string; + getTransportForSession(sessionId: string): AgentTransport; + getCapabilitiesForSession(sessionId: string): AgentCapabilities; getOrCreateSession( channelId: string, threadId: string, @@ -138,7 +78,7 @@ export interface AgentAdapter { sendMessage( channelId: string, sessionId: string, - message: string, + input: AgentInput, cwd: string, options?: OpenCodeOptions, context?: OpenCodeMessageContext diff --git a/packages/core/web/agent-check.ts b/packages/core/web/agent-check.ts index 4c992c65..3beb8550 100644 --- a/packages/core/web/agent-check.ts +++ b/packages/core/web/agent-check.ts @@ -6,11 +6,9 @@ export type AgentInstallStatus = { claudecode: boolean; codex: boolean; kimi: boolean; - kiro: boolean; kilo: boolean; qwen: boolean; goose: boolean; - gemini: boolean; pi: boolean; openhands: boolean; codebuddy: boolean; @@ -207,11 +205,9 @@ export function getInstalledAgentStatus(): AgentInstallStatus { claudecode: Boolean(Bun.which("claude")), codex: Boolean(Bun.which("codex")), kimi: Boolean(Bun.which("kimi")), - kiro: Boolean(Bun.which("kiro-cli") || Bun.which("kiro")), kilo: Boolean(Bun.which("kilo")), qwen: Boolean(Bun.which("qwen") || Bun.which("qwen-code")), goose: Boolean(Bun.which("goose")), - gemini: Boolean(Bun.which("gemini")), pi: Boolean(Bun.which("pi")), openhands: Boolean(Bun.which("openhands")), codebuddy: Boolean(Bun.which("codebuddy") || Bun.which("cbc")), @@ -239,10 +235,8 @@ export async function runSingleAgentCheck(provider: AgentProviderId): Promise; + }) => { + return runRoute( + async () => { + const id = params.id?.trim(); + if (!id) throw new Error("Missing thread id"); + const limit = parsePositiveInt( + typeof query.limit === "string" ? query.limit : null, + 100, + 500, + ); + const result = getOdeRunEventPage(id, { + limit, + includeRaw: query.includeRaw === "true", + }); + if (!result) throw new Error("Thread not found"); + return result; + }, + (result) => jsonResponse(200, { ok: true, result }), + { + fallbackMessage: "Internal server error", + resolveStatus: (message) => { + if (message === "Missing thread id") return 400; + if (message === "Thread not found") return 404; + return 500; + }, + } + ); + } + ); + // Paginated details for a given thread (default 10 per page). app.get( "/api/message-threads/:id/details", diff --git a/packages/ims/discord/client.ts b/packages/ims/discord/client.ts index 62e97934..738a9f58 100644 --- a/packages/ims/discord/client.ts +++ b/packages/ims/discord/client.ts @@ -49,6 +49,8 @@ import { } from "@/ims/discord/utils/rate-limit"; import { DiscordStatusMessageIndex } from "@/ims/discord/state/status-message-index"; import type { RawInboundEvent } from "@/core/model/raw-inbound-event"; +import { downloadAttachments } from "@/ims/shared/attachment-store"; +import type { InboundAttachment } from "@/shared/agent-protocol"; const DISCORD_MESSAGE_LIMIT = 2000; const DISCORD_THREAD_NAME_LIMIT = 25; @@ -60,6 +62,29 @@ const discordClients = new Map(); const discordClientByProcessorId = new Map(); const statusMessageIndex = new DiscordStatusMessageIndex(); const discordThreadProcessorByKey = new Map(); + +async function downloadDiscordMessageAttachments(message: any): Promise { + const raw = message?.attachments; + const values = raw && typeof raw.values === "function" + ? Array.from(raw.values()) as Array> + : []; + if (values.length === 0) return []; + return downloadAttachments({ + platform: "discord", + messageId: String(message.id), + sources: values.flatMap((attachment) => { + const url = typeof attachment.url === "string" ? attachment.url : ""; + if (!url) return []; + return [{ + id: typeof attachment.id === "string" ? attachment.id : undefined, + filename: typeof attachment.name === "string" ? attachment.name : undefined, + mimeType: typeof attachment.contentType === "string" ? attachment.contentType : undefined, + size: typeof attachment.size === "number" ? attachment.size : undefined, + url, + }]; + }), + }); +} const discordProcessorManager = createProcessorManager({ createRuntime: (processorId) => createCoreRuntime({ platform: "discord", @@ -491,6 +516,7 @@ async function startDiscordRuntimeInternal(reason: string): Promise { const parentId = message.channel.parentId; if (!parentId) return; if (configuredChannels && !configuredChannels.includes(parentId)) return; + const attachments = await downloadDiscordMessageAttachments(message); const threadId = message.channel.id; const text = message.content.trim(); @@ -525,6 +551,7 @@ async function startDiscordRuntimeInternal(reason: string): Promise { activeThread: active, rawText: text, normalizedText, + attachments, receivedAtMs: Date.now(), }; rememberThreadProcessor(parentId, threadId, processorId); @@ -534,6 +561,7 @@ async function startDiscordRuntimeInternal(reason: string): Promise { const parentId = message.channel.id; if (configuredChannels && !configuredChannels.includes(parentId)) return; + const attachments = await downloadDiscordMessageAttachments(message); if (await maybeHandleLauncherCommand({ text: message.content, @@ -558,7 +586,7 @@ async function startDiscordRuntimeInternal(reason: string): Promise { }); return; } - if (!topLevelText.trim()) { + if (!topLevelText.trim() && attachments.length === 0) { await message.reply("Please include a request after mentioning me."); return; } @@ -573,7 +601,10 @@ async function startDiscordRuntimeInternal(reason: string): Promise { } const thread = await message.startThread({ - name: buildMeaningfulThreadName(topLevelText, DISCORD_THREAD_NAME_LIMIT), + name: buildMeaningfulThreadName( + topLevelText || attachments.map((attachment) => attachment.filename).join(" "), + DISCORD_THREAD_NAME_LIMIT + ), autoArchiveDuration: 60, }); @@ -596,6 +627,7 @@ async function startDiscordRuntimeInternal(reason: string): Promise { activeThread: false, rawText: message.content, normalizedText: topLevelText, + attachments, receivedAtMs: Date.now(), }); } catch (error) { diff --git a/packages/ims/index.ts b/packages/ims/index.ts index 80dfbdb6..03d8c5d2 100644 --- a/packages/ims/index.ts +++ b/packages/ims/index.ts @@ -1,4 +1,4 @@ -export { uploadSlackFile, getSlackThreadMessages, addSlackReaction, postSlackQuestion } from "./slack/api"; +export { uploadSlackFile, getSlackThreadMessages, addSlackReaction } from "./slack/api"; export * from "./discord"; export * from "./lark"; export { diff --git a/packages/ims/lark/client.ts b/packages/ims/lark/client.ts index 91e32f4d..9a19cb32 100644 --- a/packages/ims/lark/client.ts +++ b/packages/ims/lark/client.ts @@ -52,6 +52,7 @@ import { } from "@/ims/lark/utils/card-action-utils"; import { LarkRuntimeState } from "@/ims/lark/state/runtime-state"; import type { RawInboundEvent } from "@/core/model/raw-inbound-event"; +import { downloadAttachments, type AttachmentSource } from "@/ims/shared/attachment-store"; let larkRuntimeStarted = false; @@ -338,6 +339,57 @@ function parseLarkText(content: string | undefined): string { } } +function parseLarkAttachmentSources(params: { + messageType: string; + content: string | undefined; + messageId: string; + token: string; +}): AttachmentSource[] { + if (!params.content) return []; + let parsed: unknown; + try { + parsed = JSON.parse(params.content); + } catch { + return []; + } + + const candidates: Array<{ key: string; type: "image" | "file"; filename?: string }> = []; + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + if (!value || typeof value !== "object") return; + const record = value as Record; + const fileName = typeof record.file_name === "string" ? record.file_name : undefined; + if (typeof record.file_key === "string" && record.file_key) { + candidates.push({ key: record.file_key, type: "file", filename: fileName }); + } + if (typeof record.image_key === "string" && record.image_key) { + candidates.push({ key: record.image_key, type: "image", filename: fileName }); + } + for (const child of Object.values(record)) visit(child); + }; + visit(parsed); + + const directType = params.messageType === "image" ? "image" : "file"; + const seen = new Set(); + return candidates.flatMap((candidate): AttachmentSource[] => { + if (params.messageType !== "post" && candidate.type !== directType) return []; + const resourceType = params.messageType === "post" ? candidate.type : directType; + const dedupeKey = `${resourceType}:${candidate.key}`; + if (seen.has(dedupeKey)) return []; + seen.add(dedupeKey); + const extension = resourceType === "image" ? ".png" : ""; + return [{ + id: candidate.key, + filename: candidate.filename ?? `${candidate.key}${extension}`, + url: `https://open.feishu.cn/open-apis/im/v1/messages/${encodeURIComponent(params.messageId)}/resources/${encodeURIComponent(candidate.key)}?type=${resourceType}`, + headers: { Authorization: `Bearer ${params.token}` }, + }]; + }); +} + async function buildLarkContext( channelId: string, threadId: string, @@ -875,11 +927,9 @@ async function processLarkCardAction(payload: unknown): Promise { || provider === "claudecode" || provider === "codex" || provider === "kimi" - || provider === "kiro" || provider === "kilo" || provider === "qwen" || provider === "goose" - || provider === "gemini" ) { setChannelAgentProvider(channelId, provider); } @@ -1096,16 +1146,30 @@ async function processLarkIncomingEvent(event: LarkIncomingEvent, processorAppId const botOpenId = await getBotOpenIdForChannel(channelId); const isSelfMessage = Boolean(botOpenId && senderOpenId === botOpenId); - if (message?.message_type !== "text") { - logLarkEvent("Lark inbound ignored: non-text message", { + const messageType = message?.message_type ?? ""; + const rawText = messageType === "text" || messageType === "post" + ? parseLarkText(message?.content) + : ""; + const tenantToken = mappedCreds ? await getLarkTenantAccessToken(mappedCreds) : ""; + const attachmentSources = tenantToken + ? parseLarkAttachmentSources({ + messageType, + content: message?.content, + messageId, + token: tenantToken, + }) + : []; + const attachments = attachmentSources.length > 0 + ? await downloadAttachments({ platform: "lark", messageId, sources: attachmentSources }) + : []; + if (!rawText.trim() && attachments.length === 0) { + logLarkEvent("Lark inbound ignored: unsupported empty message", { channelId, messageId, - messageType: message?.message_type ?? "", + messageType, }); return; } - - const rawText = parseLarkText(message?.content); const mentions = parseMentionedOpenIds(message?.mentions); const isMentioned = botOpenId ? (mentions.includes(botOpenId) || isBotMentionedInText(rawText, botOpenId)) @@ -1132,6 +1196,7 @@ async function processLarkIncomingEvent(event: LarkIncomingEvent, processorAppId activeThread: active, rawText, normalizedText: text, + attachments, receivedAtMs: Date.now(), }; diff --git a/packages/ims/shared/attachment-store.test.ts b/packages/ims/shared/attachment-store.test.ts new file mode 100644 index 00000000..d791a083 --- /dev/null +++ b/packages/ims/shared/attachment-store.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtemp, readFile, rm, stat, utimes } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + cleanupExpiredAttachments, + downloadAttachments, + storeAttachmentBytes, +} from "./attachment-store"; + +describe("attachment store", () => { + let root = ""; + + afterEach(async () => { + delete process.env.ODE_ATTACHMENT_DIR; + if (root) await rm(root, { recursive: true, force: true }); + }); + + it("stores sanitized private files and hashes their bytes", async () => { + root = await mkdtemp(path.join(tmpdir(), "ode-attachments-")); + process.env.ODE_ATTACHMENT_DIR = root; + const attachment = await storeAttachmentBytes({ + platform: "discord", + messageId: "message/1", + sourceId: "file/1", + filename: "../../notes.txt", + mimeType: "text/plain", + bytes: new TextEncoder().encode("hello"), + }); + + expect(attachment.filename).toBe("notes.txt"); + expect(attachment.kind).toBe("text"); + expect(attachment.sha256).toHaveLength(64); + expect(await readFile(attachment.localPath, "utf8")).toBe("hello"); + expect((await stat(attachment.localPath)).mode & 0o777).toBe(0o600); + expect(attachment.localPath.startsWith(root)).toBe(true); + }); + + it("rejects a file above the configured limit", async () => { + root = await mkdtemp(path.join(tmpdir(), "ode-attachments-")); + process.env.ODE_ATTACHMENT_DIR = root; + await expect(storeAttachmentBytes({ + platform: "slack", + messageId: "m1", + bytes: new Uint8Array(4), + limits: { maxFileBytes: 3, maxMessageBytes: 10, maxFiles: 1 }, + })).rejects.toThrow("per-file limit"); + }); + + it("enforces streamed response limits even without a content-length header", async () => { + root = await mkdtemp(path.join(tmpdir(), "ode-attachments-")); + process.env.ODE_ATTACHMENT_DIR = root; + await expect(downloadAttachments({ + platform: "lark", + messageId: "m2", + sources: [{ url: "data:application/octet-stream;base64,AQIDBA==" }], + limits: { maxFileBytes: 3, maxMessageBytes: 10, maxFiles: 1 }, + })).rejects.toThrow("per-file limit"); + }); + + it("cleans message directories older than retention", async () => { + root = await mkdtemp(path.join(tmpdir(), "ode-attachments-")); + process.env.ODE_ATTACHMENT_DIR = root; + const attachment = await storeAttachmentBytes({ + platform: "discord", + messageId: "old-message", + filename: "old.txt", + mimeType: "text/plain", + bytes: new TextEncoder().encode("old"), + }); + const messageDirectory = path.dirname(attachment.localPath); + const oldTime = new Date(Date.now() - 10_000); + await utimes(messageDirectory, oldTime, oldTime); + + expect(await cleanupExpiredAttachments({ retentionMs: 1_000 })).toBe(1); + await expect(stat(messageDirectory)).rejects.toThrow(); + }); +}); diff --git a/packages/ims/shared/attachment-store.ts b/packages/ims/shared/attachment-store.ts new file mode 100644 index 00000000..9473a84e --- /dev/null +++ b/packages/ims/shared/attachment-store.ts @@ -0,0 +1,249 @@ +import { createHash, randomUUID } from "node:crypto"; +import { chmod, mkdir, readdir, rm, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import { fileTypeFromBuffer } from "file-type"; +import type { + InboundAttachment, + InboundAttachmentKind, +} from "@/shared/agent-protocol"; + +const DEFAULT_MAX_FILE_BYTES = 20 * 1024 * 1024; +const DEFAULT_MAX_MESSAGE_BYTES = 50 * 1024 * 1024; +const DEFAULT_MAX_FILES = 10; +const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; +const CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; +let lastCleanupStartedAt = 0; + +export type AttachmentLimits = Readonly<{ + maxFileBytes: number; + maxMessageBytes: number; + maxFiles: number; +}>; + +export type AttachmentSource = Readonly<{ + id?: string; + filename?: string; + mimeType?: string; + size?: number; + url: string; + headers?: Readonly>; +}>; + +export function getAttachmentLimits(): AttachmentLimits { + return { + maxFileBytes: positiveInteger(process.env.ODE_ATTACHMENT_MAX_FILE_BYTES, DEFAULT_MAX_FILE_BYTES), + maxMessageBytes: positiveInteger(process.env.ODE_ATTACHMENT_MAX_MESSAGE_BYTES, DEFAULT_MAX_MESSAGE_BYTES), + maxFiles: positiveInteger(process.env.ODE_ATTACHMENT_MAX_FILES, DEFAULT_MAX_FILES), + }; +} + +export function getAttachmentStoreRoot(): string { + return process.env.ODE_ATTACHMENT_DIR?.trim() + || path.join(homedir(), ".config", "ode", "attachments"); +} + +function positiveInteger(raw: string | undefined, fallback: number): number { + const parsed = Number(raw); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function sanitizeSegment(value: string, fallback: string): string { + const normalized = value + .normalize("NFKC") + .replace(/[\\/\0]/g, "_") + .replace(/[\u0001-\u001f\u007f]/g, "") + .replace(/^\.+/, "") + .trim() + .slice(0, 180); + return normalized || fallback; +} + +function classifyAttachment(mimeType: string, filename: string): InboundAttachmentKind { + const mime = mimeType.toLowerCase(); + if (mime.startsWith("image/")) return "image"; + if (mime.startsWith("text/") || /\.(md|txt|json|jsonl|csv|tsv|ya?ml|xml|html?|css|[cm]?[jt]sx?|py|rb|go|rs|java|kt|swift|sh)$/i.test(filename)) { + return "text"; + } + if (/^(application\/(pdf|json|xml|rtf|msword|vnd\.)|text\/)/.test(mime)) return "document"; + return "binary"; +} + +async function detectMime(buffer: Uint8Array, declared: string | undefined): Promise { + const detected = await fileTypeFromBuffer(buffer); + if (detected?.mime) return detected.mime; + const clean = declared?.split(";", 1)[0]?.trim().toLowerCase(); + return clean || "application/octet-stream"; +} + +async function ensurePrivateDirectory(directory: string): Promise { + await mkdir(directory, { recursive: true, mode: 0o700 }); + await chmod(directory, 0o700); +} + +async function readResponseBytes(response: Response, maxBytes: number): Promise { + if (!response.body) return new Uint8Array(); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel("attachment size limit exceeded").catch(() => {}); + throw new Error(`Attachment exceeds ${maxBytes} byte per-file limit`); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +function scheduleAttachmentCleanup(): void { + const now = Date.now(); + if (now - lastCleanupStartedAt < CLEANUP_INTERVAL_MS) return; + lastCleanupStartedAt = now; + void cleanupExpiredAttachments().catch(() => {}); +} + +export async function storeAttachmentBytes(params: { + platform: InboundAttachment["sourcePlatform"]; + messageId: string; + sourceId?: string; + filename?: string; + mimeType?: string; + bytes: Uint8Array; + limits?: AttachmentLimits; +}): Promise { + const limits = params.limits ?? getAttachmentLimits(); + if (params.bytes.byteLength > limits.maxFileBytes) { + throw new Error(`Attachment exceeds ${limits.maxFileBytes} byte per-file limit`); + } + + const messageId = sanitizeSegment(params.messageId, "message"); + const directory = path.join(getAttachmentStoreRoot(), params.platform, messageId); + await ensurePrivateDirectory(directory); + + const originalName = sanitizeSegment( + path.basename((params.filename ?? "attachment").replace(/\\/g, "/")), + "attachment" + ); + const id = sanitizeSegment(params.sourceId ?? randomUUID(), randomUUID()); + const filename = `${id}-${originalName}`; + const localPath = path.join(directory, filename); + await Bun.write(localPath, params.bytes, { mode: 0o600 }); + await chmod(localPath, 0o600); + + const mimeType = await detectMime(params.bytes, params.mimeType); + return { + id, + sourcePlatform: params.platform, + sourceMessageId: params.messageId, + filename: originalName, + mimeType, + size: params.bytes.byteLength, + localPath, + sha256: createHash("sha256").update(params.bytes).digest("hex"), + kind: classifyAttachment(mimeType, originalName), + }; +} + +export async function downloadAttachments(params: { + platform: InboundAttachment["sourcePlatform"]; + messageId: string; + sources: readonly AttachmentSource[]; + limits?: AttachmentLimits; +}): Promise { + const limits = params.limits ?? getAttachmentLimits(); + if (params.sources.length > limits.maxFiles) { + throw new Error(`Message has ${params.sources.length} attachments; limit is ${limits.maxFiles}`); + } + + const declaredTotal = params.sources.reduce( + (total, source) => total + (typeof source.size === "number" ? source.size : 0), + 0 + ); + if (declaredTotal > limits.maxMessageBytes) { + throw new Error(`Attachments exceed ${limits.maxMessageBytes} byte per-message limit`); + } + + let totalBytes = 0; + const stored: InboundAttachment[] = []; + try { + for (const source of params.sources) { + if (typeof source.size === "number" && source.size > limits.maxFileBytes) { + throw new Error(`${source.filename ?? "Attachment"} exceeds the per-file limit`); + } + const response = await fetch(source.url, { headers: source.headers }); + if (!response.ok) { + throw new Error(`Attachment download failed: ${response.status} ${response.statusText}`); + } + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > limits.maxFileBytes) { + throw new Error(`${source.filename ?? "Attachment"} exceeds the per-file limit`); + } + const bytes = await readResponseBytes(response, limits.maxFileBytes); + totalBytes += bytes.byteLength; + if (totalBytes > limits.maxMessageBytes) { + throw new Error(`Attachments exceed ${limits.maxMessageBytes} byte per-message limit`); + } + stored.push(await storeAttachmentBytes({ + platform: params.platform, + messageId: params.messageId, + sourceId: source.id, + filename: source.filename, + mimeType: source.mimeType ?? response.headers.get("content-type") ?? undefined, + bytes, + limits, + })); + } + } catch (error) { + const messageDirectory = path.join( + getAttachmentStoreRoot(), + params.platform, + sanitizeSegment(params.messageId, "message") + ); + await rm(messageDirectory, { recursive: true, force: true }).catch(() => {}); + throw error; + } + scheduleAttachmentCleanup(); + return stored; +} + +export async function cleanupExpiredAttachments(params: { + now?: number; + retentionMs?: number; +} = {}): Promise { + const root = getAttachmentStoreRoot(); + const cutoff = (params.now ?? Date.now()) + - (params.retentionMs ?? positiveInteger(process.env.ODE_ATTACHMENT_RETENTION_MS, DEFAULT_RETENTION_MS)); + let removed = 0; + let platforms: string[]; + try { + platforms = await readdir(root); + } catch { + return 0; + } + for (const platform of platforms) { + const platformDir = path.join(root, platform); + for (const messageId of await readdir(platformDir).catch(() => [])) { + const messageDir = path.join(platformDir, messageId); + const info = await stat(messageDir).catch(() => null); + if (info?.isDirectory() && info.mtimeMs < cutoff) { + await rm(messageDir, { recursive: true, force: true }); + removed += 1; + } + } + } + return removed; +} diff --git a/packages/ims/shared/inbound-policy.test.ts b/packages/ims/shared/inbound-policy.test.ts index 33d7a94b..08a8044c 100644 --- a/packages/ims/shared/inbound-policy.test.ts +++ b/packages/ims/shared/inbound-policy.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import { defaultInboundPolicy } from "./inbound-policy"; +import type { InboundAttachment } from "@/shared/agent-protocol"; describe("defaultInboundPolicy", () => { it("drops thread messages that mention another target", () => { @@ -27,7 +28,11 @@ describe("defaultInboundPolicy", () => { normalizedText: "continue", }); - expect(decision).toEqual({ kind: "message", text: "continue" }); + expect(decision).toEqual({ + kind: "message", + text: "continue", + input: { parts: [{ type: "text", text: "continue" }] }, + }); }); it("adopts a synthetic-owner thread on the first human reply even when inactive", () => { @@ -47,7 +52,11 @@ describe("defaultInboundPolicy", () => { normalizedText: "thanks, now do X", }); - expect(decision).toEqual({ kind: "message", text: "thanks, now do X" }); + expect(decision).toEqual({ + kind: "message", + text: "thanks, now do X", + input: { parts: [{ type: "text", text: "thanks, now do X" }] }, + }); }); it("still ignores stranger replies in inactive threads without a mention", () => { @@ -63,4 +72,42 @@ describe("defaultInboundPolicy", () => { expect(decision).toEqual({ kind: "ignore", reason: "not_mentioned_and_inactive" }); }); + + it("accepts an attachment-only message in an active thread", () => { + const attachment: InboundAttachment = { + id: "image-1", + sourcePlatform: "slack", + sourceMessageId: "message-1", + filename: "screen.png", + mimeType: "image/png", + size: 42, + localPath: "/tmp/screen.png", + sha256: "a".repeat(64), + kind: "image", + }; + const decision = defaultInboundPolicy({ + selfMessage: false, + threadOwnerMessage: true, + isTopLevel: false, + hasAnyMention: false, + mentionedBot: false, + activeThread: true, + normalizedText: "", + attachments: [attachment], + }); + + expect(decision).toEqual({ + kind: "message", + text: "", + input: { + parts: [{ + type: "image", + path: "/tmp/screen.png", + filename: "screen.png", + mimeType: "image/png", + size: 42, + }], + }, + }); + }); }); diff --git a/packages/ims/shared/inbound-policy.ts b/packages/ims/shared/inbound-policy.ts index 5be0e56c..95a491cd 100644 --- a/packages/ims/shared/inbound-policy.ts +++ b/packages/ims/shared/inbound-policy.ts @@ -1,4 +1,5 @@ import type { InboundDecision } from "@/core/model/inbound-decision"; +import { createAgentInput, type InboundAttachment } from "@/shared/agent-protocol"; export function defaultInboundPolicy(params: { selfMessage: boolean; @@ -8,6 +9,7 @@ export function defaultInboundPolicy(params: { mentionedBot: boolean; activeThread: boolean; normalizedText: string; + attachments?: readonly InboundAttachment[]; detectStop?: boolean; }): InboundDecision { if (params.selfMessage) { @@ -43,7 +45,8 @@ export function defaultInboundPolicy(params: { } const text = params.normalizedText.trim(); - if (!text) { + const attachments = params.attachments ?? []; + if (!text && attachments.length === 0) { return { kind: "ignore", reason: "empty_text" }; } @@ -51,5 +54,5 @@ export function defaultInboundPolicy(params: { return { kind: "stop" }; } - return { kind: "message", text }; + return { kind: "message", text, input: createAgentInput(text, attachments) }; } diff --git a/packages/ims/slack/api.test.ts b/packages/ims/slack/api.test.ts deleted file mode 100644 index 6882d749..00000000 --- a/packages/ims/slack/api.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test"; - -const apiCalls: Array<{ method: string; args: Record }> = []; - -mock.module("./client", () => ({ - getApp: () => ({ - client: { - apiCall: async (method: string, args: Record) => { - apiCalls.push({ method, args }); - return method === "chat.startStream" ? { ts: "111.222" } : {}; - }, - }, - }), - getSlackBotToken: () => "xoxb-test", -})); - -describe("Slack streaming API helpers", () => { - beforeEach(() => { - apiCalls.length = 0; - }); - - it("uses raw apiCall methods for stream lifecycle operations", async () => { - const { appendSlackStream, startSlackStream, stopSlackStream } = await import("./api"); - - const ts = await startSlackStream({ - channelId: "C1", - threadId: "1700000000.000001", - recipientUserId: "U1", - recipientTeamId: "T1", - seedPlanTitle: "Working", - token: "xoxb-test", - }); - await appendSlackStream({ - channelId: "C1", - messageTs: ts!, - chunks: [{ type: "plan_update", title: "Still working" }], - token: "xoxb-test", - }); - await stopSlackStream({ - channelId: "C1", - messageTs: ts!, - token: "xoxb-test", - }); - - expect(apiCalls.map((call) => call.method)).toEqual([ - "chat.startStream", - "chat.appendStream", - "chat.stopStream", - ]); - expect(apiCalls[0]?.args).toMatchObject({ - channel: "C1", - thread_ts: "1700000000.000001", - task_display_mode: "plan", - recipient_user_id: "U1", - recipient_team_id: "T1", - token: "xoxb-test", - }); - expect(apiCalls[1]?.args).toMatchObject({ - channel: "C1", - ts: "111.222", - token: "xoxb-test", - }); - expect(apiCalls[2]?.args).toMatchObject({ - channel: "C1", - ts: "111.222", - token: "xoxb-test", - }); - }); -}); diff --git a/packages/ims/slack/api.ts b/packages/ims/slack/api.ts index 7a277447..3fea8d33 100644 --- a/packages/ims/slack/api.ts +++ b/packages/ims/slack/api.ts @@ -1,7 +1,5 @@ import { basename } from "path"; import { getApp, getSlackBotToken } from "./client"; -import { hasSimpleOptions } from "@/core/runtime/helpers"; -import type { StatusStreamChunk } from "@/core/types"; // --------------------------------------------------------------------------- // Slack IM helper module. @@ -12,8 +10,6 @@ import type { StatusStreamChunk } from "@/core/types"; // (`ode send file`, `ode messages get`, `ode reaction add`, etc.), so this // module now only exposes: // -// - `postSlackQuestion` – used by the core runtime to render SDK-emitted -// question events in Slack. // - `uploadSlackFile` – powering `ode send file` on Slack channels. // - `getSlackThreadMessages` – powering `ode messages get`. // - `addSlackReaction` – powering `ode reaction add`. @@ -49,100 +45,6 @@ function normalizeSlackEmojiName(emoji: string): string { return alias; } -function normalizeOptionLabel(option: unknown): string { - if (typeof option === "string") return option; - if (option && typeof option === "object") { - const record = option as Record; - if (typeof record.label === "string") return record.label; - if (typeof record.text === "string") return record.text; - if (typeof record.value === "string") return record.value; - } - return String(option ?? ""); -} - -/** - * Post a question to Slack. When the options are "simple" (2-5 short labels - * with no newlines) we render interactive buttons via an `actions` block so - * the user can tap a choice. Otherwise — including when there are no options - * at all — we fall back to a plain text message listing the choices inline. - * - * If Slack rejects the Block Kit payload (e.g. `invalid_blocks` because a - * button label still exceeds Slack's 75-char hard limit, or some other - * schema error), we catch the error, log it, and fall back to the same - * plain-text format so the user still sees the question. - * - * Used by the runtime's `sendQuestion` path (SDK-emitted `question` events). - */ -export async function postSlackQuestion(args: { - channelId: string; - threadId: string; - question: string; - options?: string[]; - prefix?: string; - token: string; -}): Promise { - const { channelId, threadId, question, prefix, token } = args; - const client = getApp().client; - const options = (args.options ?? []) - .map((opt) => (typeof opt === "string" ? opt : normalizeOptionLabel(opt))) - .filter((opt) => opt.trim().length > 0); - - const displayPrefix = prefix ?? ""; - const questionText = `${displayPrefix}${question}`; - - const postPlainText = async (): Promise => { - const optionText = options.length > 0 ? `\nOptions: ${options.join(" / ")}` : ""; - const result = await client.chat.postMessage({ - channel: channelId, - thread_ts: threadId, - text: `${questionText}${optionText}`, - token, - }); - return result.ts ?? undefined; - }; - - if (hasSimpleOptions(options)) { - const buttons = options.map((opt, i) => ({ - type: "button" as const, - text: { type: "plain_text" as const, text: opt }, - action_id: `user_choice_${i}`, - value: opt, - })); - - try { - const result = await client.chat.postMessage({ - channel: channelId, - thread_ts: threadId, - text: questionText, - blocks: [ - { - type: "section", - text: { type: "mrkdwn", text: questionText }, - }, - { - type: "actions", - block_id: "user_choice", - elements: buttons, - }, - ], - token, - }); - return result.ts ?? undefined; - } catch (err) { - // Slack rejected the Block Kit payload (invalid_blocks, etc.). Log the - // underlying reason and fall back to the plain-text format so the user - // still sees the question rather than silently losing it. - const data = (err as { data?: { error?: string; errors?: string[] } } | undefined)?.data; - const slackError = data?.error ?? (err as Error | undefined)?.message ?? "unknown"; - const details = Array.isArray(data?.errors) ? ` (${data?.errors?.join("; ")})` : ""; - console.warn(`[slack] postSlackQuestion buttons rejected (${slackError})${details}; falling back to plain text`); - return postPlainText(); - } - } - - return postPlainText(); -} - async function slackApiCall(method: string, body: Record, token: string): Promise { const formBody = new URLSearchParams(); for (const [key, value] of Object.entries(body)) { @@ -299,147 +201,3 @@ export async function addSlackReaction(args: { }, token); return { status: "reaction_added" }; } - -// --------------------------------------------------------------------------- -// Streaming status (chat.startStream / chat.appendStream / chat.stopStream) -// -// Slack's text-streaming API (Feb 2026, expanded Apr 2026) is purpose-built -// for AI agents rendering live progress as `task_card` / `plan` blocks. -// We expose three thin helpers used by the Slack IMAdapter when the -// Slack workspace status mode is configured for AI cards. -// -// Empirical quirks (the docs lie a bit): -// - Channel (non-DM) streams REQUIRE `recipient_user_id` + -// `recipient_team_id`. Omitting them yields `missing_recipient_team_id`. -// - A stream is locked to either "text" mode or "chunks" mode at -// `startStream` time. After that, you cannot mix `markdown_text` and -// `chunks` on `appendStream` (returns -// `cannot_provide_both_markdown_text_and_chunks`) nor switch modes -// (returns `streaming_mode_mismatch`). We always use chunks mode. -// - To start in chunks mode you MUST pass `chunks` on `startStream` (a -// `plan_update` is a natural opener); a bare/empty `markdown_text` locks -// you into text mode forever. -// - Per-chunk title/details/output cap = 256 chars (Slack-side). -// - `task_display_mode: "plan"` groups task_updates inside a single plan -// block; `"individual"` (default) renders each as a standalone card. -// --------------------------------------------------------------------------- - -const STREAM_CHUNK_MAX_CHARS = 256; - -function truncateStreamField(value: string | undefined): string | undefined { - if (typeof value !== "string") return undefined; - if (value.length <= STREAM_CHUNK_MAX_CHARS) return value; - return value.slice(0, STREAM_CHUNK_MAX_CHARS - 1) + "…"; -} - -function serializeStreamChunk(chunk: StatusStreamChunk): Record { - if (chunk.type === "task_update") { - return { - type: "task_update", - id: chunk.id, - title: truncateStreamField(chunk.title) ?? "", - status: chunk.status, - ...(chunk.details ? { details: truncateStreamField(chunk.details) } : {}), - ...(chunk.output ? { output: truncateStreamField(chunk.output) } : {}), - ...(chunk.sources && chunk.sources.length > 0 ? { sources: chunk.sources } : {}), - }; - } - if (chunk.type === "plan_update") { - return { type: "plan_update", title: truncateStreamField(chunk.title) ?? "" }; - } - // markdown_text — only valid in text-mode streams; the kernel currently - // never emits these, but we serialize defensively. - return { type: "markdown_text", text: chunk.text }; -} - -/** - * Start a Slack streaming status message in chunks mode. - * - * Channel (non-DM) streams require recipient identification; we resolve the - * team id via `auth.test` on the bot token and accept the requesting user id - * from the caller. `task_display_mode: "plan"` is what gives us the unified - * "thinking steps" card with a checklist of task_card rows. - * - * Seeds the stream with a single `plan_update` chunk (using `seedPlanTitle`) - * so subsequent `appendSlackStream` calls don't trip `streaming_mode_mismatch`. - * - * `token` is required: the adapter resolves the right bot token (processor- - * scoped or channel-scoped) at the call site so append/stop can use the - * same identity by passing it explicitly. Mixing tokens within a single - * stream lifecycle causes Slack to reject later calls. - */ -export async function startSlackStream(args: { - channelId: string; - threadId: string; - recipientUserId: string; - recipientTeamId: string; - seedPlanTitle?: string; - token: string; -}): Promise { - const channelId = requireString(args.channelId, "channelId"); - const threadId = requireString(args.threadId, "threadId"); - const recipientUserId = requireString(args.recipientUserId, "recipientUserId"); - const recipientTeamId = requireString(args.recipientTeamId, "recipientTeamId"); - const token = requireString(args.token, "token"); - const client = getApp().client; - const result = await client.apiCall("chat.startStream", { - channel: channelId, - thread_ts: threadId, - task_display_mode: "plan", - recipient_user_id: recipientUserId, - recipient_team_id: recipientTeamId, - chunks: [{ type: "plan_update", title: truncateStreamField(args.seedPlanTitle ?? "Working") }], - token, - }) as { ts?: string }; - return result.ts ?? undefined; -} - -/** - * Append chunks to a running chunks-mode Slack stream message. Does NOT - * accept a markdown_text body — the API rejects messages that mix the two - * with `cannot_provide_both_markdown_text_and_chunks`. - * - * `token` must be the same bot token that started the stream — using a - * different workspace's token (e.g. when multiple Slack workspaces are - * installed) causes Slack to silently reject the append. - */ -export async function appendSlackStream(args: { - channelId: string; - messageTs: string; - chunks: StatusStreamChunk[]; - token: string; -}): Promise { - const channelId = requireString(args.channelId, "channelId"); - const messageTs = requireString(args.messageTs, "messageTs"); - const token = requireString(args.token, "token"); - if (!Array.isArray(args.chunks) || args.chunks.length === 0) return; - const client = getApp().client; - await client.apiCall("chat.appendStream", { - channel: channelId, - ts: messageTs, - chunks: args.chunks.map(serializeStreamChunk), - token, - }); -} - -/** - * Stop a chunks-mode stream. Cannot pass markdown_text here either — the - * stream is mode-locked. If you need a final summary line, append a final - * `plan_update` chunk before calling stop. `token` must match the start - * call's token (see `appendSlackStream`). - */ -export async function stopSlackStream(args: { - channelId: string; - messageTs: string; - token: string; -}): Promise { - const channelId = requireString(args.channelId, "channelId"); - const messageTs = requireString(args.messageTs, "messageTs"); - const token = requireString(args.token, "token"); - const client = getApp().client; - await client.apiCall("chat.stopStream", { - channel: channelId, - ts: messageTs, - token, - }); -} diff --git a/packages/ims/slack/client.ts b/packages/ims/slack/client.ts index 1efbdac0..ffc85bdd 100644 --- a/packages/ims/slack/client.ts +++ b/packages/ims/slack/client.ts @@ -620,89 +620,8 @@ function createSlackAdapter(processorId?: string): IMAdapter { maxEditableMessageChars: 35_000, sendMessage: (channelId: string, threadId: string, text: string) => sendMessage(channelId, threadId, text, processorId), - sendQuestion: async ( - channelId: string, - threadId: string, - question: string, - options: string[] | undefined, - prefix?: string - ) => { - const token = getSlackBotTokenForProcessor(processorId) ?? getSlackBotToken(channelId, threadId); - if (!token) { - // No token -> fall through to plain-text sendMessage so the question - // still gets delivered through whatever channel/path the caller has. - const optionText = options && options.length > 0 ? `\nOptions: ${options.join(" / ")}` : ""; - return sendMessage(channelId, threadId, `${prefix ?? ""}${question}${optionText}`, processorId); - } - const { postSlackQuestion } = await import("./api"); - return postSlackQuestion({ - channelId, - threadId, - question, - options, - prefix, - token, - }); - }, updateMessage: (channelId: string, messageTs: string, text: string) => updateMessage(channelId, messageTs, text, processorId), - startStatusStream: async (channelId, threadId, opts) => { - // Resolve recipient_team_id + bot token together so append/stop can - // use the same identity by reading the token back from the - // message-bot-token registry (see setMessageBotToken below). - const botToken = getSlackBotTokenForProcessor(processorId) - ?? getSlackBotToken(channelId, threadId); - if (!botToken) { - log.warn("No Slack bot token available for channel; cannot start stream", { channelId }); - return undefined; - } - const auth = slackAuthRegistry.resolveWorkspaceAuth(botToken); - const recipientTeamId = auth?.teamId ?? undefined; - if (!recipientTeamId) { - log.warn("No team id resolved for channel; cannot start Slack stream", { channelId }); - return undefined; - } - const { startSlackStream } = await import("./api"); - const ts = await startSlackStream({ - channelId, - threadId, - recipientUserId: opts.recipientUserId, - recipientTeamId, - seedPlanTitle: opts.seedPlanTitle, - token: botToken, - }); - if (ts) { - // Bind the bot token to the streamed TS so future - // appendStatusStream / stopStatusStream calls resolve the SAME - // workspace token via getMessageBotToken — multi-workspace installs - // would otherwise risk mixing identities and getting silent - // rejections from Slack mid-stream. - slackAuthRegistry.setMessageBotToken(channelId, ts, botToken); - } - return ts; - }, - appendStatusStream: async (channelId, messageTs, chunks) => { - const token = slackAuthRegistry.getMessageBotToken(channelId, messageTs) - ?? getSlackBotTokenForProcessor(processorId) - ?? getSlackBotToken(channelId); - if (!token) { - log.warn("No Slack bot token available for stream append", { channelId, messageTs }); - return; - } - const { appendSlackStream } = await import("./api"); - await appendSlackStream({ channelId, messageTs, chunks, token }); - }, - stopStatusStream: async (channelId, messageTs) => { - const token = slackAuthRegistry.getMessageBotToken(channelId, messageTs) - ?? getSlackBotTokenForProcessor(processorId) - ?? getSlackBotToken(channelId); - if (!token) { - log.warn("No Slack bot token available for stream stop", { channelId, messageTs }); - return; - } - const { stopSlackStream } = await import("./api"); - await stopSlackStream({ channelId, messageTs, token }); - }, cancelPendingUpdates: (channelId: string, messageTs: string) => slackMessageUpdateManager.cancelPendingUpdates(channelId, messageTs), markMessageFinalized: (channelId: string, messageTs: string) => diff --git a/packages/ims/slack/formatter.test.ts b/packages/ims/slack/formatter.test.ts new file mode 100644 index 00000000..869d7a10 --- /dev/null +++ b/packages/ims/slack/formatter.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "bun:test"; +import { markdownToSlack } from "./formatter"; + +describe("Slack Markdown formatter", () => { + it("keeps CommonMark bold and italic semantically distinct", () => { + expect(markdownToSlack("**Running** and *thinking*")) + .toBe("*Running* and _thinking_"); + }); + + it("preserves Markdown blockquotes used by reasoning previews", () => { + expect(markdownToSlack("> inspected the repository")) + .toBe("> inspected the repository"); + }); +}); diff --git a/packages/ims/slack/formatter.ts b/packages/ims/slack/formatter.ts index f892c241..2f44972a 100644 --- a/packages/ims/slack/formatter.ts +++ b/packages/ims/slack/formatter.ts @@ -14,19 +14,24 @@ export function markdownToSlack(text: string): string { // Convert inline code // Slack uses single backticks same as markdown - // Convert bold: **text** -> *text* - result = result.replace(/\*\*([^*]+)\*\*/g, "*$1*"); - // Convert italic: *text* or _text_ -> _text_ - // Be careful not to match bold markers + // Do this before converting bold so the freshly-created Slack `*bold*` + // markers are not mistaken for CommonMark italics on the next line. result = result.replace(/(? *text* + result = result.replace(/\*\*([^*]+)\*\*/g, "*$1*"); + // Convert links: [text](url) -> result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "<$2|$1>"); // Convert headers: # text -> *text* result = result.replace(/^#{1,6}\s+(.+)$/gm, "*$1*"); + // A leading `>` is Markdown blockquote syntax, not an arbitrary HTML + // character. Restore it after the general Slack escaping above. + result = result.replace(/^>\s?/gm, "> "); + // Convert strikethrough: ~~text~~ -> ~text~ result = result.replace(/~~([^~]+)~~/g, "~$1~"); diff --git a/packages/ims/slack/index.ts b/packages/ims/slack/index.ts index 0ea7491b..883c2ddd 100644 --- a/packages/ims/slack/index.ts +++ b/packages/ims/slack/index.ts @@ -13,7 +13,7 @@ export { type MessageContext, } from "./client"; -export { uploadSlackFile, getSlackThreadMessages, addSlackReaction, postSlackQuestion } from "./api"; +export { uploadSlackFile, getSlackThreadMessages, addSlackReaction } from "./api"; export { setupInteractiveHandlers } from "./commands"; diff --git a/packages/ims/slack/message-router.ts b/packages/ims/slack/message-router.ts index f303122c..bf66e831 100644 --- a/packages/ims/slack/message-router.ts +++ b/packages/ims/slack/message-router.ts @@ -6,12 +6,14 @@ import { } from "@/ims/shared/incoming-message-processor"; import type { InboundDecision } from "@/core/model/inbound-decision"; import type { RawInboundEvent } from "@/core/model/raw-inbound-event"; +import type { InboundAttachment } from "@/shared/agent-protocol"; import { RuntimeCache } from "@/shared/cache/runtime-cache"; import { SlackInboundAdapter } from "@/ims/slack/slack-inbound-adapter"; import { deliveryStats, renderDeliveryStatsForSlack, } from "@/ims/shared/delivery-stats"; +import { downloadAttachments, type AttachmentSource } from "@/ims/shared/attachment-store"; type RouterDeps = { app: any; @@ -74,6 +76,7 @@ type IncomingMessageData = { text: string; threadId: string; messageId: string; + files: AttachmentSource[]; }; function syncWorkspaceAuth( @@ -109,16 +112,36 @@ function extractMentionedUserIds(text: string): string[] { } function extractIncomingMessageData(message: any): IncomingMessageData | null { - if (message.subtype !== undefined) return null; - if (!("text" in message) || !message.text) return null; + if (message.subtype !== undefined && message.subtype !== "file_share") return null; if (!("user" in message) || !message.user) return null; + const text = typeof message.text === "string" ? message.text : ""; + const files = Array.isArray(message.files) + ? message.files.flatMap((file: Record): AttachmentSource[] => { + const url = typeof file.url_private_download === "string" + ? file.url_private_download + : typeof file.url_private === "string" + ? file.url_private + : ""; + if (!url) return []; + return [{ + id: typeof file.id === "string" ? file.id : undefined, + filename: typeof file.name === "string" ? file.name : undefined, + mimeType: typeof file.mimetype === "string" ? file.mimetype : undefined, + size: typeof file.size === "number" ? file.size : undefined, + url, + }]; + }) + : []; + if (!text.trim() && files.length === 0) return null; + return { channelId: message.channel, userId: message.user, - text: message.text, + text, threadId: message.thread_ts || message.ts, messageId: message.ts, + files, }; } @@ -300,7 +323,7 @@ export function registerSlackMessageRouter(deps: RouterDeps): void { if (!incoming) return; contextData = incoming; - const { channelId, userId, text, threadId, messageId } = incoming; + const { channelId, userId, text, threadId, messageId, files } = incoming; const contextBotToken = context?.botToken as string | undefined; let workspaceAuth = syncWorkspaceAuth( deps, @@ -364,6 +387,21 @@ export function registerSlackMessageRouter(deps: RouterDeps): void { }); const runtimeBotId = contextBotToken ?? workspaceAuth?.botToken ?? "default"; + let attachments: InboundAttachment[] = []; + if (files.length > 0) { + const token = contextBotToken ?? workspaceAuth?.botToken; + if (!token) { + throw new Error("No Slack bot token available to download attachments"); + } + attachments = await downloadAttachments({ + platform: "slack", + messageId, + sources: files.map((file) => ({ + ...file, + headers: { Authorization: `Bearer ${token}` }, + })), + }); + } const isTopLevel = threadId === messageId; const threadOwnerMessage = deps.isThreadOwner(channelId, threadId, userId); const threadActive = deps.isThreadActive(channelId, threadId, runtimeBotId); @@ -384,6 +422,7 @@ export function registerSlackMessageRouter(deps: RouterDeps): void { activeThread: threadActive, rawText: text, normalizedText: cleanText, + attachments, receivedAtMs: Date.now(), }; const flowResult = toIncomingFlowResult(slackInboundAdapter.evaluate(inboundEvent)); diff --git a/packages/ims/slack/slack-inbound-adapter.ts b/packages/ims/slack/slack-inbound-adapter.ts index eb3cebd5..7f4d6848 100644 --- a/packages/ims/slack/slack-inbound-adapter.ts +++ b/packages/ims/slack/slack-inbound-adapter.ts @@ -13,6 +13,7 @@ export class SlackInboundAdapter implements InboundAdapter { mentionedBot: event.mentionedBot, activeThread: event.activeThread, normalizedText: event.normalizedText, + attachments: event.attachments, }); } } diff --git a/packages/live-status-harness/README.md b/packages/live-status-harness/README.md index 0f0fb51d..bc13e004 100644 --- a/packages/live-status-harness/README.md +++ b/packages/live-status-harness/README.md @@ -1,6 +1,6 @@ # Live Status Harness -Standalone harness for collecting real agent stream events and replaying them into live status messages. +Standalone harness for collecting real agent stream events and replaying them into live status messages. OpenCode sync envelopes are normalized during capture so root and child-session events replay through the same provider-neutral path used by Ode. ## Why @@ -22,7 +22,7 @@ In local mode, `opencode` capture should include `--model` unless your channel m Optional flags: -- `--provider opencode|claudecode|codex|kimi|kiro|kilo|qwen|goose|gemini|pi|openhands|codebuddy|crush` +- `--provider opencode|claudecode|codex|kimi|kilo|qwen|goose|pi|openhands|codebuddy|crush` - `--cwd ` - `--channel ` - `--thread ` @@ -47,7 +47,7 @@ If `--run-id` is omitted, the latest run in Redis is used. bun run packages/live-status-harness/scripts/generate-report.ts ``` -This runs capture + render for each provider (`opencode`, `claudecode`, `codex`, `kimi`, `kiro`, `kilo`, `qwen`, `goose`, `gemini`, `pi`, `openhands`, `codebuddy`, `crush`). +This runs capture + render for each provider (`opencode`, `claudecode`, `codex`, `kimi`, `kilo`, `qwen`, `goose`, `pi`, `openhands`, `codebuddy`, `crush`). When possible, report generation reuses the latest Redis run for each provider and skips capture. If no Redis stream data exists for a provider, it captures a new run. @@ -57,11 +57,9 @@ By default, it writes one report per provider: - `packages/live-status-harness/reports/claudecode.md` - `packages/live-status-harness/reports/codex.md` - `packages/live-status-harness/reports/kimi.md` -- `packages/live-status-harness/reports/kiro.md` - `packages/live-status-harness/reports/kilo.md` - `packages/live-status-harness/reports/qwen.md` - `packages/live-status-harness/reports/goose.md` -- `packages/live-status-harness/reports/gemini.md` - `packages/live-status-harness/reports/pi.md` - `packages/live-status-harness/reports/openhands.md` - `packages/live-status-harness/reports/codebuddy.md` @@ -71,13 +69,11 @@ Use `--providers ` to run only specific providers. For `opencode`, the report run forces model `openai/gpt-5.3-codex` so it does not depend on channel-level model config. -For `gemini`, the report run forces `--agent plan` to avoid file edits during harness capture. - For `pi` and `openhands`, the report run forces `anthropic/claude-sonnet-4-5-20250929`; for `codebuddy` and `crush`, it forces `gpt-5.1` through their configured OpenAI-compatible providers. Optional flags: -- `--providers opencode,claudecode,codex,kimi,kiro,kilo,qwen,goose,gemini,pi,openhands,codebuddy,crush` +- `--providers opencode,claudecode,codex,kimi,kilo,qwen,goose,pi,openhands,codebuddy,crush` - `--run-id ` reuse an existing captured run (requires exactly one provider and skips capture) - `--layout split|combined|both` (default: `split`) - `--output-dir ` for provider files (default: `packages/live-status-harness/reports`) @@ -91,5 +87,5 @@ Optional flags: - `:runs:index` sorted set of run ids - `:runs::meta` run metadata JSON -- `:runs::events` ordered raw stream events +- `:runs::events` ordered stream events (OpenCode transport envelopes are normalized) - `:runs::rendered` rendered live statuses JSON diff --git a/packages/live-status-harness/reports/agent-live-status.md b/packages/live-status-harness/reports/agent-live-status.md index c027da1a..86ab881b 100644 --- a/packages/live-status-harness/reports/agent-live-status.md +++ b/packages/live-status-harness/reports/agent-live-status.md @@ -2,7 +2,7 @@ Generated: 2026-02-22T11:06:45.423Z Working directory: /root/ode-new/.worktree/ode_1771752635.512539 -Providers: opencode, claudecode, codex, kimi, kiro, kilo, qwen, goose, gemini +Providers: opencode, claudecode, codex, kimi, kilo, qwen, goose | Provider | Run ID | Events | Statuses | State | | --- | --- | ---: | ---: | --- | @@ -10,11 +10,9 @@ Providers: opencode, claudecode, codex, kimi, kiro, kilo, qwen, goose, gemini | claudecode | (not completed) | 0 | 0 | failed | | codex | codex_1771757062783_22a91ed8 | 83 | 82 | ok | | kimi | (not completed) | 0 | 0 | failed | -| kiro | kiro_1771757149760_9f10c8ac | 30 | 25 | ok | | kilo | kilo_1771757203411_292f1613 | 8 | 8 | ok | | qwen | qwen_1771757570446_8867e8d0 | 311 | 111 | ok | | goose | goose_1771757884861_7e7c6a15 | 752 | 8 | ok | -| gemini | gemini_1771757971028_122d2f73 | 161 | 95 | ok | ## opencode @@ -157,59 +155,6 @@ Plan to fix: at async main (/root/ode-new/.worktree/ode_1771752635.512539/packages/live-status-harness/scripts/capture-stream.ts:328:40) at processTicksAndRejections (native:7:39) -## kiro - -- Run ID: kiro_1771757149760_9f10c8ac -- Events captured: 30 -- Status updates rendered: 25 - -### Final Live Status Message - -```text -*Kiro is running...* (53s) -_Waiting_ - -*Tasks* -`completed` Generate codebase overview - -*Tool execution (Last 6 items in 13)* -- `Bash` -- `Bash` -- `Bash` -- `Grep` AgentProviderId.*=.*opencode.*claudecode.*codex (*.ts) in . -- `Grep` agentProviderSchema.*=.*z\.enum (*.ts) in . -- `Read` packages/agents/registry.ts (offset 0, limit 100) -``` - -### Result Message - -```text -Based on my exploration, here's what I found: -## Most Important Issue: Agent Provider Type Duplication -The codebase has 34,146 lines of tests but only 22,825 lines of actual code - that's a 1.5:1 test-to-code ratio, which is excellent. However, there's a critical maintenance issue: -The agent provider list is hardcoded in 13+ different locations across the codebase: -- packages/agents/registry.ts - AgentProviderId type -- packages/config/local/ode.ts - agentProviderSchema (Zod) -- packages/ims/slack/commands.ts - AgentProvider type + array -- packages/config/local/redis.ts - SessionAgentProvider type -- packages/live-status-harness/scripts/generate-report.ts - DEFAULT_PROVIDERS array -- packages/utils/status.ts - AgentStatusProvider type + PROVIDER_FALLBACK_TITLES -- And more... -Every time a new agent is added (like when you added Kiro), you need to update all these locations. Miss one, and you get runtime errors or missing features. -## Plan to Fix -✅ Create single source of truth -- Define agent list once in packages/agents/registry.ts -- Export both the type and runtime array -✅ Replace all duplicates -- Update Zod schema to derive from registry -- Update all type definitions to import from registry -- Update all arrays/objects to use registry constant -✅ Add compile-time safety -- Use TypeScript's satisfies to ensure objects match the type -- Add exhaustiveness checks where needed -This will reduce maintenance burden and prevent bugs when adding new agents. -``` - ## kilo - Run ID: kilo_1771757203411_292f1613 @@ -452,249 +397,3 @@ This is foundational infrastructure. Everything else (features, UX, optimization Want me to implement this? I can create the files and integrate logging into the existing codebase. ``` - -## gemini - -- Run ID: gemini_1771757971028_122d2f73 -- Events captured: 161 -- Status updates rendered: 95 - -### Final Live Status Message - -```text -*Gemini is running...* (7m 14s) -_Finalizing response_ - -*Tool execution (Last 6 items in 32)* -- `read` packages/agents/index.ts -- `read` packages/core/index.ts -- `read` packages/config/message-update-interval.ts -- `read` packages/core/runtime/thread-queue.ts -- `read` packages/agents/runtime/base.ts -- `read` packages/core/runtime/session-bootstrap.ts -``` - -### Result Message - -```text -I'll start by exploring the codebase to understand its structure and identify the most critical improvement. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I'll read `package.json` and list the `packages` directory to get a better sense of the project's dependencies and modularity. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I'll explore the `core` and `agents` packages to understand the system's architecture and identify potential areas for improvement. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I'll examine `packages/core/runtime.ts` and `packages/agents/registry.ts` to understand the system's core execution and agent registration logic. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I'll examine `packages/core/runtime/open-request.ts` to see how it handles requests for providers that don't support event streams and how updates are communicated to the user. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I'll examine `packages/core/runtime/request-runner.ts` to see how it manages the `liveParsedState` and how it interacts with the `onProgressTick` to update the status message. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I'll investigate `packages/core/runtime/event-stream.ts` to understand how `startEventStreamWatcher` functions, particularly for providers lacking event stream support, as this could reveal issues with user experience or system stability. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I'll investigate the implementation of the `claudecode` and `gemini` agents to see if they provide any updates before `sendMessage` finishes, despite not supporting event streams. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I'll examine `packages/agents/claude/index.ts` to see how it handles messages and if it offers any intermediate updates. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I'll examine `packages/agents/claude/client.ts` to see how it handles messages and if it offers any intermediate updates. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I've discovered that while the Claude agent publishes events during execution, the core system ignores them because `supportsEventStream` is set to `false`. I'll now examine `packages/agents/runtime/base.ts` to understand how `publishSessionEvent` and `subscribeToSession` function, as this oversight prevents users from seeing progress updates for Claude. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I've pinpointed the problem: the core runtime skips progress updates for CLI-based agents like Claude and Gemini because `supportsEventStream` is set to `false`, leaving users with a static message during long runs. I plan to fix this by enabling `supportsEventStream` for these agents and ensuring `startEventStreamWatcher` correctly processes their events. Now, I'll examine `packages/utils/index.ts` to understand how `buildSessionMessageState` handles event parsing. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -I'll examine `packages/utils/session-inspector.ts` to see how it handles session events and builds the message state. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -`buildSessionMessageState` already supports various providers, so I just need to enable `supportsEventStream` for CLI-based agents like Claude and Gemini in `packages/agents/registry.ts`. These agents use `CliAgentRuntime` and provide streaming feedback, which `startEventStreamWatcher` should handle correctly. I'll now verify that all listed agents indeed support streaming and then apply the change. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -I'm enabling interactive progress for all users by updating the UX for CLI agents. Before I proceed, I'll check the `AgentAdapter` interface in `packages/core/types.ts`. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -`AgentAdapter` includes `supportsEventStream: boolean`, but `startEventStreamWatcher` skips `question.asked` events when it's `false`, preventing Claude and Gemini users from answering interactive questions via Slack. To fix this critical limitation, I'll enable event stream support for all agents in `packages/agents/registry.ts`, ensuring real-time updates and interactive Q&A. I'll also verify `Claude`'s `normalizeQuestions` implementation in `packages/agents/claude/client.ts`. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -If `normalizeQuestions` isn't in `client.ts`, it might be in `packages/agents/adapter.ts` for the `AgentAdapter` implementation. I'll check there. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -**Discrepancy Investigation** - -I've discovered that while `AgentAdapter`'s `supportsEventStream` is hardcoded to `true`, the underlying `AgentProvider` (like `claudecode`) might have it set to `false`. Interestingly, `claudecode` *does* produce events. To solve this, I'll examine `packages/core/runtime/status-message.ts` to see how it handles these potentially conflicting states. - - -I'll check `packages/core/runtime/status-message.ts`. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -I'll check `packages/utils/status.ts` as `buildStatusMessageByProvider` is delegated there. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -`AgentAdapter.supportsEventStream` is hardcoded to `true`, so `startEventStreamWatcher` should run. However, `AgentProvider` in `registry.ts` has its own `supportsEventStream` property, which is `false` for Claude. I need to find where `createCoreRuntime` is called, likely in `packages/core/index.ts` or `packages/core/cli.ts`, to see how these properties interact and if they're causing the issue I suspect. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -I'll check `packages/ims/slack/client.ts`. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -`createAgentAdapter()` is used, which has `supportsEventStream: true`, so `startEventStreamWatcher` should be active for all agents. For Claude, `subscribeToSession` should also work. I'm confused why `registry.ts` has `supportsEventStream: false` for most agents and will grep the project to see if it's used elsewhere. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -`AgentAdapter.supportsEventStream` is `true`, while `AgentProvider.supportsEventStream` is `false`, with the latter seemingly used only in `registry.ts` and tests. I'll check `packages/agents/index.ts` to see if it re-exports this value. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -`packages/agents/index.ts` re-exports `supportsEventStream` from the selected provider, but `createAgentAdapter` in `adapter.ts` uses `registry.ts` and hardcodes `supportsEventStream: true`. `createCoreRuntime` uses this adapter, meaning `startEventStreamWatcher` in `packages/core/runtime/event-stream.ts` should see `true`. I'll now check `packages/core/index.ts` to see if there's any other interaction I've missed. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -`createCoreRuntime` uses `createAgentAdapter`, which has `supportsEventStream: true`, so `startEventStreamWatcher` should be active. However, `startEventStreamWatcher`'s `onUpdate` is empty, meaning events update state but don't trigger immediate message updates. Updates only happen via `onProgressTick`'s `setInterval`, which depends on `progressIntervalMs`. I'll now check `packages/config/message-update-interval.ts` to see that interval. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -`AgentAdapter` hardcodes `supportsEventStream: true`, allowing `startEventStreamWatcher` to run for all agents, including Claude. However, `AgentAdapter` throws an error when attempting to reply to questions for any agent other than `opencode`, which is a major limitation since Claude supports interactive questions. I've also noted an inconsistency between `AgentAdapter` and `AgentProvider` regarding `supportsEventStream`. My plan is to improve CLI agent reliability and interactivity by fixing this inconsistency, enabling question replies for more agents, and investigating stdin support for Claude replies. I'm also considering `packages/core/runtime/thread-queue.ts` for potential critical improvements. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -`ThreadMessageQueue` combines messages, which could disrupt question replies. However, the lack of interactivity for CLI agents like Claude is a bigger issue. Currently, `sendMessage` spawns a new, non-interactive CLI process with `stdin` closed, making interactive questions impossible and inefficient. I've identified that `AgentAdapter` also hardcodes `supportsEventStream: true`, but its `replyToQuestion` throws for non-OpenCode agents. - -My plan is to refactor CLI agent execution: -1. Keep `stdin` open in `CliAgentRuntime`. -2. Implement `writeToSessionStdin` in `CliAgentRuntime` and update `AgentAdapter` to use it for question replies. -3. Correct `supportsEventStream` in the registry for all event-producing agents. -This will allow `sendMessage` to wait for question replies while `onProgressTick` updates Slack with buttons, making CLI agents fully interactive. I'll begin with `packages/agents/runtime/base.ts`. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -I'll first update `packages/agents/runtime/base.ts` by adding `writeToSessionStdin` to `CliAgentRuntime` and an `interactive: boolean` flag to `RunCliJsonCommandParams` to keep `stdin` open. Since the user's request for a plan is an inquiry, I'll provide the identification and strategy without modifying files yet, per my instructions. I've identified the lack of CLI agent interactivity as a major UX gap and will also briefly check `packages/core/runtime/session-bootstrap.ts` for any other critical issues. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -I've identified that CLI agents like Claude and Gemini are hampered by non-interactive execution, a restrictive 5-minute timeout, and closed `stdin`. This results in missing progress updates, an inability to answer interactive questions, and frequent timeouts. My plan to fix this involves updating `CliAgentRuntime` to support interactive `stdin` and longer timeouts, enabling `supportsEventStream` for these agents in the registry, and implementing a `replyToQuestion` method in `AgentAdapter` that bridges Slack actions to the running process. I'll also need to update the `AgentProvider` interface to include `replyToQuestion`. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ✅ Developing a fix plan - -After reviewing the repository, I have identified a critical architectural limitation that significantly impacts the user experience and functionality of CLI-based agents (Claude, Gemini, etc.). - -### 🚨 Most Important Change: Enable Interactivity for CLI Agents -Currently, CLI agents are executed as non-interactive, blocking subprocesses with `stdin` closed immediately and a strict 5-minute timeout. This leads to several major issues: -1. **Static UI:** Progress updates (tool use, thinking steps) are ignored for most agents because they are marked as `supportsEventStream: false`, even though they produce parseable JSON streams. -2. **Broken Interactivity:** Users cannot answer interactive questions (like Claude's `AskUserQuestion`) because the process cannot receive input via `stdin`, and the system hard-throws an error when attempting to reply to non-OpenCode agents. -3. **Premature Timeouts:** Complex coding tasks frequently fail due to a hardcoded 5-minute timeout that is insufficient for real-world agentic workflows. - -### 📋 The Fix Plan - -I propose a "Modernize CLI Agent Support" initiative to transform these static integrations into fully interactive ones. - -#### 1. Enhance CLI Runtime Capability -* **File:** `packages/agents/runtime/base.ts` -* Update `runCliJsonCommand` to support an optional `interactive` mode that keeps `stdin` open. -* Add `writeToSessionStdin(sessionId, text)` to `CliAgentRuntime` to allow piping Slack/IM responses back to the running process. -* Increase the default `timeoutMs` from 5 minutes to 30 minutes to accommodate complex tasks. - -#### 2. Unlock Event Streaming -* **File:** `packages/agents/registry.ts` -* Update all CLI-based providers (`claudecode`, `gemini`, `goose`, etc.) to set `supportsEventStream: true`. The underlying infrastructure already exists to parse their events, but it's currently disabled. -* **File:** `packages/agents/adapter.ts` -* Remove hardcoded `supportsEventStream: true` and instead delegate to the active provider's capability. - -#### 3. Implement Interactive Question Bridging -* **File:** `packages/agents/registry.ts` -* Add `replyToQuestion(sessionId, answers)` to the `AgentProvider` interface. -* **File:** `packages/agents/adapter.ts` -* Update the `replyToQuestion` implementation to remove the "opencode-only" restriction. For CLI agents, it will now bridge the user's response to the process's `stdin`. - -#### 4. Update Agent Adapters (e.g., Claude) -* **File:** `packages/agents/claude/client.ts` -* Update `sendMessage` to leverage the interactive mode and longer timeouts, ensuring it can wait for and receive user input during a task. - -This change will make the entire "agent orchestrator" feel significantly more responsive and capable, allowing it to handle complex, multi-step interactive sessions with any supported CLI agent. -``` diff --git a/packages/live-status-harness/reports/claudecode.md b/packages/live-status-harness/reports/claudecode.md index 25f97fbf..89918d43 100644 --- a/packages/live-status-harness/reports/claudecode.md +++ b/packages/live-status-harness/reports/claudecode.md @@ -73,11 +73,9 @@ packages/utils/parsers/ ├── opencode-parser.ts ├── codex-parser.ts ├── kimi-parser.ts -├── kiro-parser.ts ├── kilo-parser.ts ├── qwen-parser.ts -├── goose-parser.ts -└── gemini-parser.ts +└── goose-parser.ts ` ` ` Each parser only handles its own event format, making it easy to test and modify. diff --git a/packages/live-status-harness/reports/gemini.md b/packages/live-status-harness/reports/gemini.md deleted file mode 100644 index 70ab089f..00000000 --- a/packages/live-status-harness/reports/gemini.md +++ /dev/null @@ -1,249 +0,0 @@ -# Live Status Harness Report - gemini - -Generated: 2026-02-22T11:06:45.423Z -Provider: gemini -Working directory: /root/ode-new/.worktree/ode_1771752635.512539 - -- Run ID: gemini_1771757971028_122d2f73 -- Events captured: 161 -- Status updates rendered: 95 - -## Final Live Status Message - -```text -*Gemini is running...* (7m 14s) -_Finalizing response_ - -*Tool execution (Last 6 items in 32)* -- `read` packages/agents/index.ts -- `read` packages/core/index.ts -- `read` packages/config/message-update-interval.ts -- `read` packages/core/runtime/thread-queue.ts -- `read` packages/agents/runtime/base.ts -- `read` packages/core/runtime/session-bootstrap.ts -``` - -## Result Message - -```text -I'll start by exploring the codebase to understand its structure and identify the most critical improvement. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I'll read `package.json` and list the `packages` directory to get a better sense of the project's dependencies and modularity. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I'll explore the `core` and `agents` packages to understand the system's architecture and identify potential areas for improvement. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I'll examine `packages/core/runtime.ts` and `packages/agents/registry.ts` to understand the system's core execution and agent registration logic. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I'll examine `packages/core/runtime/open-request.ts` to see how it handles requests for providers that don't support event streams and how updates are communicated to the user. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I'll examine `packages/core/runtime/request-runner.ts` to see how it manages the `liveParsedState` and how it interacts with the `onProgressTick` to update the status message. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I'll investigate `packages/core/runtime/event-stream.ts` to understand how `startEventStreamWatcher` functions, particularly for providers lacking event stream support, as this could reveal issues with user experience or system stability. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I'll investigate the implementation of the `claudecode` and `gemini` agents to see if they provide any updates before `sendMessage` finishes, despite not supporting event streams. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I'll examine `packages/agents/claude/index.ts` to see how it handles messages and if it offers any intermediate updates. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I'll examine `packages/agents/claude/client.ts` to see how it handles messages and if it offers any intermediate updates. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I've discovered that while the Claude agent publishes events during execution, the core system ignores them because `supportsEventStream` is set to `false`. I'll now examine `packages/agents/runtime/base.ts` to understand how `publishSessionEvent` and `subscribeToSession` function, as this oversight prevents users from seeing progress updates for Claude. - -* ♻️ Researching repository structure and key components -* * Identifying the most important change -* * Developing a fix plan - -I've pinpointed the problem: the core runtime skips progress updates for CLI-based agents like Claude and Gemini because `supportsEventStream` is set to `false`, leaving users with a static message during long runs. I plan to fix this by enabling `supportsEventStream` for these agents and ensuring `startEventStreamWatcher` correctly processes their events. Now, I'll examine `packages/utils/index.ts` to understand how `buildSessionMessageState` handles event parsing. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -I'll examine `packages/utils/session-inspector.ts` to see how it handles session events and builds the message state. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -`buildSessionMessageState` already supports various providers, so I just need to enable `supportsEventStream` for CLI-based agents like Claude and Gemini in `packages/agents/registry.ts`. These agents use `CliAgentRuntime` and provide streaming feedback, which `startEventStreamWatcher` should handle correctly. I'll now verify that all listed agents indeed support streaming and then apply the change. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -I'm enabling interactive progress for all users by updating the UX for CLI agents. Before I proceed, I'll check the `AgentAdapter` interface in `packages/core/types.ts`. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -`AgentAdapter` includes `supportsEventStream: boolean`, but `startEventStreamWatcher` skips `question.asked` events when it's `false`, preventing Claude and Gemini users from answering interactive questions via Slack. To fix this critical limitation, I'll enable event stream support for all agents in `packages/agents/registry.ts`, ensuring real-time updates and interactive Q&A. I'll also verify `Claude`'s `normalizeQuestions` implementation in `packages/agents/claude/client.ts`. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -If `normalizeQuestions` isn't in `client.ts`, it might be in `packages/agents/adapter.ts` for the `AgentAdapter` implementation. I'll check there. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -**Discrepancy Investigation** - -I've discovered that while `AgentAdapter`'s `supportsEventStream` is hardcoded to `true`, the underlying `AgentProvider` (like `claudecode`) might have it set to `false`. Interestingly, `claudecode` *does* produce events. To solve this, I'll examine `packages/core/runtime/status-message.ts` to see how it handles these potentially conflicting states. - - -I'll check `packages/core/runtime/status-message.ts`. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -I'll check `packages/utils/status.ts` as `buildStatusMessageByProvider` is delegated there. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -`AgentAdapter.supportsEventStream` is hardcoded to `true`, so `startEventStreamWatcher` should run. However, `AgentProvider` in `registry.ts` has its own `supportsEventStream` property, which is `false` for Claude. I need to find where `createCoreRuntime` is called, likely in `packages/core/index.ts` or `packages/core/cli.ts`, to see how these properties interact and if they're causing the issue I suspect. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -I'll check `packages/ims/slack/client.ts`. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -`createAgentAdapter()` is used, which has `supportsEventStream: true`, so `startEventStreamWatcher` should be active for all agents. For Claude, `subscribeToSession` should also work. I'm confused why `registry.ts` has `supportsEventStream: false` for most agents and will grep the project to see if it's used elsewhere. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -`AgentAdapter.supportsEventStream` is `true`, while `AgentProvider.supportsEventStream` is `false`, with the latter seemingly used only in `registry.ts` and tests. I'll check `packages/agents/index.ts` to see if it re-exports this value. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -`packages/agents/index.ts` re-exports `supportsEventStream` from the selected provider, but `createAgentAdapter` in `adapter.ts` uses `registry.ts` and hardcodes `supportsEventStream: true`. `createCoreRuntime` uses this adapter, meaning `startEventStreamWatcher` in `packages/core/runtime/event-stream.ts` should see `true`. I'll now check `packages/core/index.ts` to see if there's any other interaction I've missed. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -`createCoreRuntime` uses `createAgentAdapter`, which has `supportsEventStream: true`, so `startEventStreamWatcher` should be active. However, `startEventStreamWatcher`'s `onUpdate` is empty, meaning events update state but don't trigger immediate message updates. Updates only happen via `onProgressTick`'s `setInterval`, which depends on `progressIntervalMs`. I'll now check `packages/config/message-update-interval.ts` to see that interval. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -`AgentAdapter` hardcodes `supportsEventStream: true`, allowing `startEventStreamWatcher` to run for all agents, including Claude. However, `AgentAdapter` throws an error when attempting to reply to questions for any agent other than `opencode`, which is a major limitation since Claude supports interactive questions. I've also noted an inconsistency between `AgentAdapter` and `AgentProvider` regarding `supportsEventStream`. My plan is to improve CLI agent reliability and interactivity by fixing this inconsistency, enabling question replies for more agents, and investigating stdin support for Claude replies. I'm also considering `packages/core/runtime/thread-queue.ts` for potential critical improvements. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -`ThreadMessageQueue` combines messages, which could disrupt question replies. However, the lack of interactivity for CLI agents like Claude is a bigger issue. Currently, `sendMessage` spawns a new, non-interactive CLI process with `stdin` closed, making interactive questions impossible and inefficient. I've identified that `AgentAdapter` also hardcodes `supportsEventStream: true`, but its `replyToQuestion` throws for non-OpenCode agents. - -My plan is to refactor CLI agent execution: -1. Keep `stdin` open in `CliAgentRuntime`. -2. Implement `writeToSessionStdin` in `CliAgentRuntime` and update `AgentAdapter` to use it for question replies. -3. Correct `supportsEventStream` in the registry for all event-producing agents. -This will allow `sendMessage` to wait for question replies while `onProgressTick` updates Slack with buttons, making CLI agents fully interactive. I'll begin with `packages/agents/runtime/base.ts`. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -I'll first update `packages/agents/runtime/base.ts` by adding `writeToSessionStdin` to `CliAgentRuntime` and an `interactive: boolean` flag to `RunCliJsonCommandParams` to keep `stdin` open. Since the user's request for a plan is an inquiry, I'll provide the identification and strategy without modifying files yet, per my instructions. I've identified the lack of CLI agent interactivity as a major UX gap and will also briefly check `packages/core/runtime/session-bootstrap.ts` for any other critical issues. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ♻️ Developing a fix plan - -I've identified that CLI agents like Claude and Gemini are hampered by non-interactive execution, a restrictive 5-minute timeout, and closed `stdin`. This results in missing progress updates, an inability to answer interactive questions, and frequent timeouts. My plan to fix this involves updating `CliAgentRuntime` to support interactive `stdin` and longer timeouts, enabling `supportsEventStream` for these agents in the registry, and implementing a `replyToQuestion` method in `AgentAdapter` that bridges Slack actions to the running process. I'll also need to update the `AgentProvider` interface to include `replyToQuestion`. - -* ♻️ Researching repository structure and key components -* ✅ Identifying the most important change -* ✅ Developing a fix plan - -After reviewing the repository, I have identified a critical architectural limitation that significantly impacts the user experience and functionality of CLI-based agents (Claude, Gemini, etc.). - -### 🚨 Most Important Change: Enable Interactivity for CLI Agents -Currently, CLI agents are executed as non-interactive, blocking subprocesses with `stdin` closed immediately and a strict 5-minute timeout. This leads to several major issues: -1. **Static UI:** Progress updates (tool use, thinking steps) are ignored for most agents because they are marked as `supportsEventStream: false`, even though they produce parseable JSON streams. -2. **Broken Interactivity:** Users cannot answer interactive questions (like Claude's `AskUserQuestion`) because the process cannot receive input via `stdin`, and the system hard-throws an error when attempting to reply to non-OpenCode agents. -3. **Premature Timeouts:** Complex coding tasks frequently fail due to a hardcoded 5-minute timeout that is insufficient for real-world agentic workflows. - -### 📋 The Fix Plan - -I propose a "Modernize CLI Agent Support" initiative to transform these static integrations into fully interactive ones. - -#### 1. Enhance CLI Runtime Capability -* **File:** `packages/agents/runtime/base.ts` -* Update `runCliJsonCommand` to support an optional `interactive` mode that keeps `stdin` open. -* Add `writeToSessionStdin(sessionId, text)` to `CliAgentRuntime` to allow piping Slack/IM responses back to the running process. -* Increase the default `timeoutMs` from 5 minutes to 30 minutes to accommodate complex tasks. - -#### 2. Unlock Event Streaming -* **File:** `packages/agents/registry.ts` -* Update all CLI-based providers (`claudecode`, `gemini`, `goose`, etc.) to set `supportsEventStream: true`. The underlying infrastructure already exists to parse their events, but it's currently disabled. -* **File:** `packages/agents/adapter.ts` -* Remove hardcoded `supportsEventStream: true` and instead delegate to the active provider's capability. - -#### 3. Implement Interactive Question Bridging -* **File:** `packages/agents/registry.ts` -* Add `replyToQuestion(sessionId, answers)` to the `AgentProvider` interface. -* **File:** `packages/agents/adapter.ts` -* Update the `replyToQuestion` implementation to remove the "opencode-only" restriction. For CLI agents, it will now bridge the user's response to the process's `stdin`. - -#### 4. Update Agent Adapters (e.g., Claude) -* **File:** `packages/agents/claude/client.ts` -* Update `sendMessage` to leverage the interactive mode and longer timeouts, ensuring it can wait for and receive user input during a task. - -This change will make the entire "agent orchestrator" feel significantly more responsive and capable, allowing it to handle complex, multi-step interactive sessions with any supported CLI agent. -``` diff --git a/packages/live-status-harness/reports/kiro.md b/packages/live-status-harness/reports/kiro.md deleted file mode 100644 index ea97e371..00000000 --- a/packages/live-status-harness/reports/kiro.md +++ /dev/null @@ -1,50 +0,0 @@ -# Live Status Harness Report - kiro - -Generated: 2026-02-25T05:17:38.758Z -Provider: kiro -Working directory: /root/ode-new/.worktree/ode_1771995140.206739 - -- Run ID: kiro_1771995699537_0cb4ab4a -- Source: Reused Redis stream data -- Events captured: 16 -- Status updates rendered: 13 - -## Final Live Status Message - -```text -*Kiro is running...* (15m 56s) -_Waiting_ - -*Tasks* -`completed` Generate codebase overview -`completed` Searching for symbols matching: "runCliJsonCommand" - -*Tool execution* -- `Read` -- `Grep` runCliJsonCommand (*.ts) in . -- `Read` packages/agents/runtime/base.ts (offset 69, limit 21) -- `Grep` timeoutMs (*.ts) in . -``` - -## Result Message - -```text -✅ Explored codebase -✅ Identified critical issue -## Most Important Change -Timeout handling is broken - when a CLI command times out, the process is killed but the promise is rejected before the "close" event fires. This creates a race condition where: -1. Timeout fires → kills process → rejects promise -2. Process closes → tries to resolve promise (already settled) -3. Cleanup handlers never run properly -This affects all 7 CLI-based agents (Claude, Codex, Kimi, Kiro, Kilo, Qwen, Goose, Gemini) and can cause: -- Zombie processes -- Resource leaks -- Incomplete session cleanup -- Lost error context -## Fix Plan -3 steps: -1. Refactor timeout logic - Move rejection to the "close" handler, track timeout state separately -2. Add cleanup guarantee - Ensure stderr/stdout buffers are flushed even on timeout -3. Test coverage - Add unit test for timeout scenario -The fix is in packages/agents/runtime/base.ts - one function, ~30 lines changed. -``` diff --git a/packages/live-status-harness/scripts/capture-stream.ts b/packages/live-status-harness/scripts/capture-stream.ts index 36aeeb87..e8afca00 100644 --- a/packages/live-status-harness/scripts/capture-stream.ts +++ b/packages/live-status-harness/scripts/capture-stream.ts @@ -1,10 +1,16 @@ import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; +import { pathToFileURL } from "node:url"; import { createOpencodeClient } from "@opencode-ai/sdk/v2"; import type { OpenCodeMessageContext } from "@/agents"; +import { + extractOpenCodeChildSession, + normalizeOpenCodeGlobalEvent, +} from "@/agents/opencode/events"; import { buildPromptParts, buildSystemPrompt } from "@/agents/shared"; import { getAgentProvider, type AgentProviderId } from "@/agents/registry"; import { isAgentProviderId } from "@/shared/agent-provider"; +import { createAgentInput } from "@/shared/agent-protocol"; import type { OpenCodeMessage, OpenCodeOptions } from "@/agents/types"; import { extractEventSessionId } from "@/utils"; import { buildHarnessRunId, HarnessRedisStore } from "../redis-store"; @@ -178,7 +184,7 @@ async function main(): Promise { const userId = parseArg("user") || DEFAULT_USER_ID; const prompt = await loadPrompt(parseArg("prompt-file")); const model = parseModelArg(parseArg("model")); - const agent = parseArg("agent") || (provider === "gemini" ? "plan" : undefined); + const agent = parseArg("agent"); const runId = parseArg("run-id") || buildHarnessRunId(provider); const startedAt = Date.now(); @@ -223,23 +229,35 @@ async function main(): Promise { await store.saveRunMeta(runMeta); let streamClosed = false; + const relatedSessionIds = new Set([sessionId]); + const childTitles = new Map(); const events = await client.global.event(); const streamTask = (async () => { for await (const globalEvent of events.stream) { if (streamClosed) break; - const payload = (globalEvent as { payload?: unknown }).payload ?? globalEvent; - const payloadRecord = payload && typeof payload === "object" - ? payload as Record - : undefined; + const normalized = normalizeOpenCodeGlobalEvent(globalEvent, { + rootSessionId: sessionId, + childTitle: (childSessionId) => childTitles.get(childSessionId), + }); + if (!normalized) continue; + const payloadRecord = normalized.payload; + const child = extractOpenCodeChildSession(payloadRecord); + if ( + child + && (!child.parentSessionId || relatedSessionIds.has(child.parentSessionId)) + ) { + relatedSessionIds.add(child.sessionId); + if (child.title) childTitles.set(child.sessionId, child.title); + } const eventSessionId = extractEventSessionId(payloadRecord); - if (eventSessionId && eventSessionId !== sessionId) continue; + if (eventSessionId && !relatedSessionIds.has(eventSessionId)) continue; const captured: HarnessCapturedEvent = { runId, sessionId, provider, timestamp: Date.now(), index: eventCount, - event: globalEvent, + event: normalized, }; eventCount += 1; pendingWrites.push(store.appendEvent(captured)); @@ -247,7 +265,19 @@ async function main(): Promise { })(); try { - const parts = buildPromptParts(channelId, prompt, model ? { model } : undefined, context); + const parts = buildPromptParts( + channelId, + createAgentInput(prompt), + model ? { model } : undefined, + context + ).map((part) => part.type === "text" + ? part + : { + type: "file" as const, + mime: part.mimeType, + filename: part.filename, + url: pathToFileURL(part.path).href, + }); const system = buildSystemPrompt(context.slack); const response = await client.session.prompt({ sessionID: sessionId, @@ -327,7 +357,7 @@ async function main(): Promise { responses = await providerClient.sendMessage( channelId, session.sessionId, - prompt, + createAgentInput(prompt), cwd, options, context diff --git a/packages/live-status-harness/scripts/generate-report.ts b/packages/live-status-harness/scripts/generate-report.ts index b503a5d5..0ed44064 100644 --- a/packages/live-status-harness/scripts/generate-report.ts +++ b/packages/live-status-harness/scripts/generate-report.ts @@ -206,9 +206,6 @@ async function runProvider( if (!reusedRun && !options.runId) { const captureArgs = ["--provider", provider, "--run-id", runId, "--cwd", options.cwd]; - if (provider === "gemini") { - captureArgs.push("--agent", "plan"); - } if (provider === "codebuddy") { captureArgs.push("--model", "codebuddy/gpt-5.1"); } diff --git a/packages/live-status-harness/test/fixtures/claude-basic-run.json b/packages/live-status-harness/test/fixtures/claude-basic-run.json index 76cb74b4..ded78dde 100644 --- a/packages/live-status-harness/test/fixtures/claude-basic-run.json +++ b/packages/live-status-harness/test/fixtures/claude-basic-run.json @@ -9,7 +9,7 @@ "threadId": "T_TEST", "sessionId": "session-1", "startedAt": 1700000000000, - "eventCount": 3 + "eventCount": 4 }, "events": [ { @@ -80,6 +80,31 @@ } } } + }, + { + "runId": "claudecode_1700000000000_deadbeef", + "sessionId": "session-1", + "provider": "claudecode", + "timestamp": 1700000000400, + "index": 3, + "event": { + "type": "claude.raw.user", + "properties": { + "record": { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "tool-1", + "content": "README contents", + "is_error": false + } + ] + } + } + } + } } ] } diff --git a/packages/live-status-harness/test/fixtures/claude-subagent-run.json b/packages/live-status-harness/test/fixtures/claude-subagent-run.json new file mode 100644 index 00000000..f09a6baa --- /dev/null +++ b/packages/live-status-harness/test/fixtures/claude-subagent-run.json @@ -0,0 +1,130 @@ +{ + "meta": { + "runId": "claude_subagent_fixture", + "provider": "claudecode", + "prompt": "fixed", + "promptHash": "hash", + "cwd": "/tmp/repo", + "channelId": "C_TEST", + "threadId": "T_TEST", + "sessionId": "claude-root", + "startedAt": 1700000000000, + "eventCount": 5 + }, + "events": [ + { + "runId": "claude_subagent_fixture", + "sessionId": "claude-root", + "provider": "claudecode", + "timestamp": 1700000000000, + "index": 0, + "event": { + "type": "claude.raw.assistant", + "properties": { + "record": { + "type": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "id": "call_agent_1", + "name": "Agent", + "input": { + "description": "Read package metadata", + "prompt": "Read package.json" + } + }] + }, + "parent_tool_use_id": null + } + } + } + }, + { + "runId": "claude_subagent_fixture", + "sessionId": "claude-root", + "provider": "claudecode", + "timestamp": 1700000001000, + "index": 1, + "event": { + "type": "claude.raw.system", + "properties": { + "record": { + "type": "system", + "subtype": "task_started", + "task_id": "task_1", + "tool_use_id": "call_agent_1", + "description": "Read package metadata", + "subagent_type": "general-purpose" + } + } + } + }, + { + "runId": "claude_subagent_fixture", + "sessionId": "claude-root", + "provider": "claudecode", + "timestamp": 1700000002000, + "index": 2, + "event": { + "type": "claude.raw.assistant", + "properties": { + "record": { + "type": "assistant", + "parent_tool_use_id": "call_agent_1", + "task_description": "Read package metadata", + "message": { + "content": [{ + "type": "tool_use", + "id": "child_read", + "name": "Read", + "input": { "file_path": "/tmp/repo/package.json" } + }] + } + } + } + } + }, + { + "runId": "claude_subagent_fixture", + "sessionId": "claude-root", + "provider": "claudecode", + "timestamp": 1700000003000, + "index": 3, + "event": { + "type": "claude.raw.system", + "properties": { + "record": { + "type": "system", + "subtype": "task_progress", + "task_id": "task_1", + "tool_use_id": "call_agent_1", + "description": "Reading package.json", + "summary": "Checking package metadata", + "last_tool_name": "Read", + "usage": { "total_tokens": 20, "tool_uses": 1, "duration_ms": 3000 } + } + } + } + }, + { + "runId": "claude_subagent_fixture", + "sessionId": "claude-root", + "provider": "claudecode", + "timestamp": 1700000004000, + "index": 4, + "event": { + "type": "claude.raw.system", + "properties": { + "record": { + "type": "system", + "subtype": "task_notification", + "task_id": "task_1", + "tool_use_id": "call_agent_1", + "status": "completed", + "summary": "ode 0.2.0" + } + } + } + } + ] +} diff --git a/packages/live-status-harness/test/fixtures/codex-app-subagent-run.json b/packages/live-status-harness/test/fixtures/codex-app-subagent-run.json new file mode 100644 index 00000000..dff04ce3 --- /dev/null +++ b/packages/live-status-harness/test/fixtures/codex-app-subagent-run.json @@ -0,0 +1,189 @@ +{ + "meta": { + "runId": "codex_app_subagent_fixture", + "provider": "codex", + "prompt": "fixed", + "promptHash": "hash", + "cwd": "/tmp/repo", + "channelId": "C_TEST", + "threadId": "T_TEST", + "sessionId": "ode-session", + "startedAt": 1700000000000, + "eventCount": 7 + }, + "events": [ + { + "runId": "codex_app_subagent_fixture", + "sessionId": "ode-session", + "provider": "codex", + "timestamp": 1700000000000, + "index": 0, + "event": { + "type": "message.part.updated", + "properties": { + "part": { + "id": "root_commentary", + "sessionID": "ode-session", + "type": "text", + "text": "Delegating now." + } + } + } + }, + { + "runId": "codex_app_subagent_fixture", + "sessionId": "ode-session", + "provider": "codex", + "timestamp": 1700000001000, + "index": 1, + "event": { + "type": "codex.app.item.completed", + "properties": { + "notification": { + "method": "item/completed", + "params": { + "threadId": "thread_root", + "item": { + "type": "subAgentActivity", + "kind": "started", + "agentThreadId": "thread_child", + "agentPath": "/root/package_identity" + } + } + }, + "odeContext": { + "rootThreadId": "thread_root", + "sourceThreadId": "thread_root", + "childThread": false + } + } + } + }, + { + "runId": "codex_app_subagent_fixture", + "sessionId": "ode-session", + "provider": "codex", + "timestamp": 1700000001001, + "index": 2, + "event": { + "type": "message.part.updated", + "properties": { + "part": { + "id": "codex-subagent:thread_child", + "sessionID": "ode-session", + "type": "tool", + "tool": "subagent", + "state": { + "status": "running", + "title": "package_identity", + "input": { "description": "package_identity" }, + "metadata": { + "provider": "codex", + "sourceThreadId": "thread_child", + "childSession": true, + "startedAtMs": 1700000001000 + } + } + } + } + } + }, + { + "runId": "codex_app_subagent_fixture", + "sessionId": "ode-session", + "provider": "codex", + "timestamp": 1700000002000, + "index": 3, + "event": { + "type": "codex.app.item.agentMessage.delta", + "properties": { + "notification": { + "method": "item/agentMessage/delta", + "params": { + "threadId": "thread_child", + "turnId": "turn_child", + "itemId": "child_msg", + "delta": "ode 0.2.0" + } + }, + "odeContext": { + "rootThreadId": "thread_root", + "sourceThreadId": "thread_child", + "childThread": true + } + } + } + }, + { + "runId": "codex_app_subagent_fixture", + "sessionId": "ode-session", + "provider": "codex", + "timestamp": 1700000003000, + "index": 4, + "event": { + "type": "codex.app.turn.completed", + "properties": { + "notification": { + "method": "turn/completed", + "params": { + "threadId": "thread_child", + "turn": { "id": "turn_child", "status": "completed" } + } + }, + "odeContext": { + "rootThreadId": "thread_root", + "sourceThreadId": "thread_child", + "childThread": true + } + } + } + }, + { + "runId": "codex_app_subagent_fixture", + "sessionId": "ode-session", + "provider": "codex", + "timestamp": 1700000003001, + "index": 5, + "event": { + "type": "message.part.updated", + "properties": { + "part": { + "id": "codex-subagent:thread_child", + "sessionID": "ode-session", + "type": "tool", + "tool": "subagent", + "state": { + "status": "completed", + "title": "package_identity", + "input": { "description": "package_identity" }, + "metadata": { + "provider": "codex", + "sourceThreadId": "thread_child", + "childSession": true, + "startedAtMs": 1700000001000 + } + } + } + } + } + }, + { + "runId": "codex_app_subagent_fixture", + "sessionId": "ode-session", + "provider": "codex", + "timestamp": 1700000004000, + "index": 6, + "event": { + "type": "message.part.updated", + "properties": { + "part": { + "id": "root_final", + "sessionID": "ode-session", + "type": "text", + "text": "The package is ode, version 0.2.0." + } + } + } + } + ] +} diff --git a/packages/live-status-harness/test/fixtures/kiro-basic-run.json b/packages/live-status-harness/test/fixtures/kiro-basic-run.json deleted file mode 100644 index 12ac8dcd..00000000 --- a/packages/live-status-harness/test/fixtures/kiro-basic-run.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "meta": { - "runId": "kiro_1700000000000_deadbeef", - "provider": "kiro", - "prompt": "fixed", - "promptHash": "hash", - "cwd": "/tmp/repo", - "channelId": "C_TEST", - "threadId": "T_TEST", - "sessionId": "session-1", - "startedAt": 1700000000000, - "eventCount": 3 - }, - "events": [ - { - "runId": "kiro_1700000000000_deadbeef", - "sessionId": "session-1", - "provider": "kiro", - "timestamp": 1700000000100, - "index": 0, - "event": { - "type": "session.status", - "properties": { - "status": { - "type": "busy" - } - } - } - }, - { - "runId": "kiro_1700000000000_deadbeef", - "sessionId": "session-1", - "provider": "kiro", - "timestamp": 1700000000200, - "index": 1, - "event": { - "type": "message.part.updated", - "properties": { - "part": { - "id": "kiro-text", - "type": "text", - "text": "I will start by checking the core runtime flow." - } - } - } - }, - { - "runId": "kiro_1700000000000_deadbeef", - "sessionId": "session-1", - "provider": "kiro", - "timestamp": 1700000000300, - "index": 2, - "event": { - "type": "session.status", - "properties": { - "status": { - "type": "idle" - } - } - } - } - ] -} diff --git a/packages/live-status-harness/test/fixtures/opencode-child-sync-run.json b/packages/live-status-harness/test/fixtures/opencode-child-sync-run.json new file mode 100644 index 00000000..17adfdcb --- /dev/null +++ b/packages/live-status-harness/test/fixtures/opencode-child-sync-run.json @@ -0,0 +1,156 @@ +{ + "meta": { + "runId": "opencode_child_sync_fixture", + "provider": "opencode", + "prompt": "fixed", + "promptHash": "hash", + "cwd": "/tmp/repo", + "channelId": "C_TEST", + "threadId": "T_TEST", + "sessionId": "root-1", + "startedAt": 1700000000000, + "eventCount": 4 + }, + "events": [ + { + "runId": "opencode_child_sync_fixture", + "sessionId": "root-1", + "provider": "opencode", + "timestamp": 1700000000100, + "index": 0, + "event": { + "payload": { + "type": "message.part.updated", + "properties": { + "sessionID": "root-1", + "part": { + "id": "task-1", + "sessionID": "root-1", + "type": "tool", + "tool": "task", + "state": { + "status": "running", + "title": "Audit repository docs", + "time": { "start": 1700000000000 }, + "metadata": { + "parentSessionId": "root-1", + "sessionId": "child-1" + } + } + }, + "odeContext": { + "rootSessionID": "root-1", + "sourceSessionID": "root-1", + "childSession": false, + "transportType": "event" + } + } + } + } + }, + { + "runId": "opencode_child_sync_fixture", + "sessionId": "root-1", + "provider": "opencode", + "timestamp": 1700000000200, + "index": 1, + "event": { + "payload": { + "type": "message.part.updated", + "properties": { + "sessionID": "child-1", + "part": { + "id": "read-1", + "sessionID": "child-1", + "type": "tool", + "tool": "read", + "state": { + "status": "running", + "title": "README.md", + "time": { "start": 1700000000200 } + } + }, + "odeContext": { + "rootSessionID": "root-1", + "sourceSessionID": "child-1", + "childSession": true, + "childTitle": "Audit repository docs", + "transportType": "sync", + "syncSequence": 1 + } + } + } + } + }, + { + "runId": "opencode_child_sync_fixture", + "sessionId": "root-1", + "provider": "opencode", + "timestamp": 1700000000300, + "index": 2, + "event": { + "payload": { + "type": "message.part.updated", + "properties": { + "sessionID": "child-1", + "part": { + "id": "read-1", + "sessionID": "child-1", + "type": "tool", + "tool": "read", + "state": { + "status": "completed", + "title": "README.md", + "time": { "start": 1700000000200, "end": 1700000000300 } + } + }, + "odeContext": { + "rootSessionID": "root-1", + "sourceSessionID": "child-1", + "childSession": true, + "childTitle": "Audit repository docs", + "transportType": "sync", + "syncSequence": 2 + } + } + } + } + }, + { + "runId": "opencode_child_sync_fixture", + "sessionId": "root-1", + "provider": "opencode", + "timestamp": 1700000000400, + "index": 3, + "event": { + "payload": { + "type": "message.part.updated", + "properties": { + "sessionID": "root-1", + "part": { + "id": "task-1", + "sessionID": "root-1", + "type": "tool", + "tool": "task", + "state": { + "status": "completed", + "title": "Audit repository docs", + "time": { "start": 1700000000000, "end": 1700000000400 }, + "metadata": { + "parentSessionId": "root-1", + "sessionId": "child-1" + } + } + }, + "odeContext": { + "rootSessionID": "root-1", + "sourceSessionID": "root-1", + "childSession": false, + "transportType": "event" + } + } + } + } + } + ] +} diff --git a/packages/live-status-harness/test/render-status.test.ts b/packages/live-status-harness/test/render-status.test.ts index 057f0d0e..4ee5db02 100644 --- a/packages/live-status-harness/test/render-status.test.ts +++ b/packages/live-status-harness/test/render-status.test.ts @@ -33,16 +33,42 @@ describe("live status harness renderer", () => { expect(joined).toContain("Drafting response"); }); - it("renders kiro busy to idle live status from fixture", async () => { - const fixtureFile = Bun.file(`${import.meta.dir}/fixtures/kiro-basic-run.json`); + it("keeps Codex child output scoped to its subagent", async () => { + const fixtureFile = Bun.file(`${import.meta.dir}/fixtures/codex-app-subagent-run.json`); const fixture = JSON.parse(await fixtureFile.text()) as FixtureShape; const statuses = renderStatusesFromRun(fixture.meta, fixture.events); const joined = statuses.map((status) => status.text).join("\n\n"); + const final = statuses.at(-1)?.text ?? ""; - expect(statuses.length).toBeGreaterThanOrEqual(2); - expect(joined).toContain("Working"); - expect(joined).toContain("Waiting"); + expect(joined).toContain("`subagent` package_identity"); + expect(final).toContain("The package is ode, version 0.2.0."); + expect(final).not.toContain("**Latest output**\node 0.2.0"); + }); + + it("renders Claude task progress without flattening child tools", async () => { + const fixtureFile = Bun.file(`${import.meta.dir}/fixtures/claude-subagent-run.json`); + const fixture = JSON.parse(await fixtureFile.text()) as FixtureShape; + + const statuses = renderStatusesFromRun(fixture.meta, fixture.events); + const joined = statuses.map((status) => status.text).join("\n\n"); + + expect(joined).toContain("Waiting for subagent: Read package metadata — Checking package metadata"); + expect(joined).toContain("`subagent` Read package metadata"); + expect(joined).not.toContain("`Read` package.json"); + expect(joined).toContain("Finished subagent: Read package metadata"); + }); + + it("renders OpenCode child-session progress from normalized sync events", async () => { + const fixtureFile = Bun.file(`${import.meta.dir}/fixtures/opencode-child-sync-run.json`); + const fixture = JSON.parse(await fixtureFile.text()) as FixtureShape; + + const statuses = renderStatusesFromRun(fixture.meta, fixture.events); + const joined = statuses.map((status) => status.text).join("\n\n"); + + expect(joined).toContain("Waiting for subagent: Audit repository docs"); + expect(joined).toContain("~ `read`"); + expect(joined).toContain("- `task` Audit repository docs"); }); it("renders kilo live status from fixture", async () => { @@ -157,61 +183,6 @@ describe("live status harness renderer", () => { expect(joined).toContain("Finished tool: subagent"); }); - it("renders gemini live status from synthetic fixture", () => { - const now = Date.now(); - const meta: HarnessRunMeta = { - runId: "run-gemini-test", - provider: "gemini", - prompt: "test", - promptHash: "hash", - cwd: "/tmp/repo", - channelId: "C1", - threadId: "T1", - sessionId: "gemini_s1", - startedAt: now, - eventCount: 3, - }; - const events: HarnessCapturedEvent[] = [ - { - runId: "run-gemini-test", - sessionId: "gemini_s1", - provider: "gemini", - timestamp: now, - index: 0, - event: { type: "gemini.raw.init", properties: { record: { type: "init" } } }, - }, - { - runId: "run-gemini-test", - sessionId: "gemini_s1", - provider: "gemini", - timestamp: now + 1, - index: 1, - event: { - type: "gemini.raw.tool_use", - properties: { record: { type: "tool_use", tool_name: "read_file", tool_id: "tool-1" } }, - }, - }, - { - runId: "run-gemini-test", - sessionId: "gemini_s1", - provider: "gemini", - timestamp: now + 2, - index: 2, - event: { - type: "gemini.raw.message", - properties: { record: { type: "message", role: "assistant", content: "Done", delta: true } }, - }, - }, - ]; - - const statuses = renderStatusesFromRun(meta, events); - const joined = statuses.map((status) => status.text).join("\n\n"); - - expect(statuses.length).toBeGreaterThanOrEqual(2); - expect(joined).toContain("Gemini is running..."); - expect(joined).toContain("Running tool: read_file"); - }); - it("renders todos and waiting status from wrapped payload events", () => { const now = Date.now(); const meta: HarnessRunMeta = { @@ -314,8 +285,8 @@ describe("live status harness renderer", () => { const statuses = renderStatusesFromRun(meta, events); const finalText = statuses[statuses.length - 1]?.text || ""; - expect(finalText).toContain("*Tasks*"); + expect(finalText).toContain("**Plan**"); expect(finalText).toContain("- [~] Verify harness parser"); - expect(finalText).toContain("_Waiting_"); + expect(finalText).toContain("*Waiting*"); }); }); diff --git a/packages/live-status-harness/test/truncation-stability.test.ts b/packages/live-status-harness/test/truncation-stability.test.ts index 54d7c4fc..999a0667 100644 --- a/packages/live-status-harness/test/truncation-stability.test.ts +++ b/packages/live-status-harness/test/truncation-stability.test.ts @@ -36,7 +36,6 @@ describe("live status renderer is stable under event truncation", () => { const fixtures = [ "claude-basic-run.json", "codex-basic-run.json", - "kiro-basic-run.json", "kilo-basic-run.json", "qwen-basic-run.json", "goose-basic-run.json", diff --git a/packages/shared/agent-protocol.ts b/packages/shared/agent-protocol.ts new file mode 100644 index 00000000..9c5eac96 --- /dev/null +++ b/packages/shared/agent-protocol.ts @@ -0,0 +1,236 @@ +import type { AgentProviderId } from "./agent-provider"; + +export const ODE_RUN_EVENT_SCHEMA_VERSION = 1 as const; + +export type AgentTransport = + | "native-app-server" + | "native-sdk" + | "server-sdk" + | "acp" + | "cli-json"; + +export type InboundAttachmentKind = "image" | "text" | "document" | "binary"; + +/** + * An IM attachment after Ode has downloaded it into its private local store. + * Platform credentials and expiring remote URLs must never escape the IM + * adapter; agent adapters consume only this stable local descriptor. + */ +export type InboundAttachment = Readonly<{ + id: string; + sourcePlatform: "slack" | "discord" | "lark"; + sourceMessageId: string; + filename: string; + mimeType: string; + size: number; + localPath: string; + sha256: string; + kind: InboundAttachmentKind; +}>; + +export type AgentInputPart = + | Readonly<{ type: "text"; text: string }> + | Readonly<{ + type: "image"; + path: string; + filename: string; + mimeType: string; + size: number; + }> + | Readonly<{ + type: "resource"; + path: string; + filename: string; + mimeType: string; + size: number; + text?: string; + }> + | Readonly<{ + type: "fileRef"; + path: string; + filename: string; + mimeType: string; + size: number; + }>; + +export type AgentInput = Readonly<{ + parts: readonly AgentInputPart[]; +}>; + +export type AgentCapabilities = Readonly<{ + sessions: Readonly<{ + create: boolean; + resume: boolean; + load: boolean; + list: boolean; + delete: boolean; + close: boolean; + fork: boolean; + }>; + input: Readonly<{ + text: boolean; + image: boolean; + resource: boolean; + fileRef: boolean; + }>; + events: Readonly<{ + message: boolean; + reasoningSummary: boolean; + plan: boolean; + tool: boolean; + command: boolean; + fileDiff: boolean; + usage: boolean; + }>; + interaction: Readonly<{ + approval: boolean; + question: boolean; + cancel: boolean; + }>; +}>; + +export type AgentSessionBinding = Readonly<{ + odeSessionId: string; + providerId: AgentProviderId; + transport: AgentTransport; + nativeSessionId: string; + protocolVersion?: string; + capabilities: AgentCapabilities; + createdAt: number; + updatedAt: number; +}>; + +export type OdeRunEventType = + | "run.started" + | "run.progress" + | "run.completed" + | "run.failed" + | "message.delta" + | "message.completed" + | "reasoning.summary.delta" + | "plan.updated" + | "tool.started" + | "tool.progress" + | "tool.completed" + | "tool.failed" + | "command.output.delta" + | "file.diff.updated" + | "approval.requested" + | "approval.resolved" + | "question.requested" + | "question.resolved" + | "usage.updated" + | "attachment.received" + | "provider.raw"; + +/** + * Append-only event stored by the Ode runtime. `rawEvent` is deliberately + * retained while adapters migrate so fixture replay and unknown future + * provider events remain debuggable without leaking into renderers. + */ +export type OdeRunEvent = Readonly<{ + id: string; + schemaVersion: typeof ODE_RUN_EVENT_SCHEMA_VERSION; + timestamp: number; + type: OdeRunEventType; + providerId: AgentProviderId; + sessionId: string; + runId?: string; + itemId?: string; + data: Readonly>; + rawEvent?: Readonly>; +}>; + +export const LEGACY_AGENT_CAPABILITIES: AgentCapabilities = { + sessions: { + create: true, + resume: true, + load: false, + list: false, + delete: false, + close: false, + fork: false, + }, + input: { + text: true, + image: false, + resource: false, + fileRef: true, + }, + events: { + message: true, + reasoningSummary: false, + plan: false, + tool: true, + command: false, + fileDiff: false, + usage: false, + }, + interaction: { + approval: false, + question: false, + cancel: true, + }, +}; + +export function createAgentInput( + text: string, + attachments: readonly InboundAttachment[] = [] +): AgentInput { + const parts: AgentInputPart[] = []; + const trimmed = text.trim(); + if (trimmed) { + parts.push({ type: "text", text: trimmed }); + } + + for (const attachment of attachments) { + const common = { + path: attachment.localPath, + filename: attachment.filename, + mimeType: attachment.mimeType, + size: attachment.size, + }; + if (attachment.kind === "image") { + parts.push({ type: "image", ...common }); + } else if (attachment.kind === "text" || attachment.kind === "document") { + parts.push({ type: "resource", ...common }); + } else { + parts.push({ type: "fileRef", ...common }); + } + } + + return { parts }; +} + +export function getAgentInputText(input: AgentInput): string { + return input.parts + .filter((part): part is Extract => part.type === "text") + .map((part) => part.text) + .join("\n\n") + .trim(); +} + +export function getAgentInputAttachments( + input: AgentInput +): Array> { + return input.parts.filter( + (part): part is Exclude => part.type !== "text" + ); +} + +export function renderAgentInputAsText(input: AgentInput): string { + const text = getAgentInputText(input); + const attachments = getAgentInputAttachments(input); + if (attachments.length === 0) return text; + + const attachmentLines = attachments.map((part) => + `- ${part.filename} (${part.mimeType}, ${part.size} bytes): ${part.path}` + ); + const instruction = text || "Please inspect the attached files and continue based on the thread context."; + return [ + instruction, + "", + ...attachmentLines, + "", + ].join("\n"); +} diff --git a/packages/shared/agent-provider.ts b/packages/shared/agent-provider.ts index 04b694d6..9b5d445f 100644 --- a/packages/shared/agent-provider.ts +++ b/packages/shared/agent-provider.ts @@ -3,11 +3,9 @@ export const AGENT_PROVIDERS = [ "claudecode", "codex", "kimi", - "kiro", "kilo", "qwen", "goose", - "gemini", "pi", "openhands", "codebuddy", @@ -21,11 +19,9 @@ export const AGENT_PROVIDER_LABELS: Record = { claudecode: "Claude Code", codex: "Codex", kimi: "Kimi", - kiro: "Kiro", kilo: "Kilo", qwen: "Qwen Code", goose: "Goose", - gemini: "Gemini", pi: "Pi", openhands: "OpenHands", codebuddy: "CodeBuddy", @@ -37,11 +33,9 @@ export const AGENT_PROVIDER_COMMANDS: Record = { claudecode: "claude", codex: "codex", kimi: "kimi", - kiro: "kiro-cli", kilo: "kilo", qwen: "qwen", goose: "goose", - gemini: "gemini", pi: "pi", openhands: "openhands", codebuddy: "codebuddy", @@ -75,11 +69,6 @@ export const AGENT_PROVIDER_MANIFEST: Record | undefined return getSessionIdFromRecord(event); } + +export function extractEventRootSessionId( + event: Record | undefined +): string | undefined { + if (!event) return undefined; + const context = event.odeContext && typeof event.odeContext === "object" + ? event.odeContext as Record + : undefined; + if (typeof context?.rootSessionID === "string") return context.rootSessionID; + if (typeof context?.rootSessionId === "string") return context.rootSessionId; + return undefined; +} diff --git a/packages/utils/session-inspector.ts b/packages/utils/session-inspector.ts index 0d6d925a..c8013980 100644 --- a/packages/utils/session-inspector.ts +++ b/packages/utils/session-inspector.ts @@ -3,12 +3,10 @@ import { extractClaudeRecord, } from "@/agents/claude/session-state"; import { applyCodexRecordToState, extractCodexRecord } from "@/agents/codex/session-state"; -import { applyKiroRecordToState, extractKiroRecord } from "@/agents/kiro/session-state"; import { applyKimiRecordToState, extractKimiRecord } from "@/agents/kimi/session-state"; import { applyKiloRecordToState, extractKiloRecord } from "@/agents/kilo/session-state"; import { applyQwenRecordToState, extractQwenRecord } from "@/agents/qwen/session-state"; import { applyGooseRecordToState, extractGooseRecord } from "@/agents/goose/session-state"; -import { applyGeminiRecordToState, extractGeminiRecord } from "@/agents/gemini/session-state"; import { applyPiRecordToState, extractPiRecord } from "@/agents/pi/session-state"; import { applyOpenHandsRecordToState, extractOpenHandsRecord } from "@/agents/openhands/session-state"; import { applyCodeBuddyRecordToState, extractCodeBuddyRecord } from "@/agents/codebuddy/session-state"; @@ -19,6 +17,7 @@ import { type StreamToolState, } from "@/agents/session-state/shared"; import type { AgentProviderId } from "@/shared/agent-provider"; +import { getAgentProviderLabel } from "@/shared/agent-provider"; export type SessionEvent = { timestamp: number; @@ -78,7 +77,7 @@ type ProviderParser = { eventData: Record, eventProps: Record ) => unknown | null; - apply: (record: unknown) => void; + apply: (record: unknown, timestamp: number) => void; }; function applySessionUpdatedEvent(state: SessionMessageState, eventProps: Record): void { @@ -241,7 +240,11 @@ function extractTokenUsage(value: unknown, fallbackCost?: unknown): SessionToken }; } -function applyMetadataFromRecord(state: SessionMessageState, source: unknown): void { +function applyMetadataFromRecord( + state: SessionMessageState, + source: unknown, + provider?: AgentProviderId +): void { const record = asRecord(source); if (!record) return; const part = asRecord(record.part); @@ -286,7 +289,10 @@ function applyMetadataFromRecord(state: SessionMessageState, source: unknown): v ?? extractTokenUsage(record.info, record.cost); if (tokenUsage) { const currentTotal = state.tokenUsage?.total ?? 0; - if (tokenUsage.total > 0 || currentTotal <= 0) { + const shouldApply = provider === "claudecode" + ? currentTotal <= 0 || tokenUsage.total >= currentTotal + : tokenUsage.total > 0 || currentTotal <= 0; + if (shouldApply) { state.tokenUsage = tokenUsage; } } @@ -307,7 +313,8 @@ function extractMessageInfo(eventProps: Record): Record, - messageRoles?: Map + messageRoles?: Map, + provider?: AgentProviderId ): void { const info = extractMessageInfo(eventProps); if (!info) return; @@ -320,7 +327,7 @@ function applyMessageUpdatedEvent( } } - applyMetadataFromRecord(state, info); + applyMetadataFromRecord(state, info, provider); } function isOpencodeThinkingStatusWithContent(status: string): boolean { @@ -394,6 +401,20 @@ function applyMessagePartUpdatedEvent( if (part.type === "tool") { const toolState = (part.state || {}) as Record; + const toolTime = asRecord(toolState.time); + const eventContext = asRecord(eventProps.odeContext); + const rawMetadata = asRecord(toolState.metadata) ?? {}; + const metadata: Record = { + ...rawMetadata, + ...(typeof toolTime?.start === "number" ? { startedAtMs: toolTime.start } : {}), + ...(typeof eventContext?.sourceSessionID === "string" + ? { sourceSessionId: eventContext.sourceSessionID } + : {}), + ...(eventContext?.childSession === true ? { childSession: true } : {}), + ...(typeof eventContext?.childTitle === "string" + ? { childTitle: eventContext.childTitle } + : {}), + }; const existingIdx = state.tools.findIndex((t) => t.id === part.id); const toolInfo: SessionTool = { id: typeof part.id === "string" ? part.id : "unknown-tool", @@ -405,7 +426,7 @@ function applyMessagePartUpdatedEvent( : undefined, output: typeof toolState.output === "string" ? toolState.output : undefined, error: typeof toolState.error === "string" ? toolState.error : undefined, - metadata: toolState.metadata as Record | undefined, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, }; if (existingIdx >= 0) { @@ -418,6 +439,24 @@ function applyMessagePartUpdatedEvent( return; } + if (toolInfo.name.trim().toLowerCase() === "subagent") { + const title = toolInfo.title?.trim(); + const progress = typeof toolInfo.metadata?.progress === "string" + ? toolInfo.metadata.progress.trim() + : typeof toolInfo.metadata?.lastTool === "string" + ? toolInfo.metadata.lastTool.trim() + : ""; + const label = title ? `: ${title}${progress ? ` — ${progress}` : ""}` : ""; + if (toolInfo.status === "running" || toolInfo.status === "pending") { + updatePhaseStatus(state, `Running subagent${label}`, provider); + } else if (toolInfo.status === "completed") { + updatePhaseStatus(state, `Finished subagent${title ? `: ${title}` : ""}`, provider); + } else if (toolInfo.status === "error") { + updatePhaseStatus(state, `Subagent failed${title ? `: ${title}` : ""}`, provider); + } + return; + } + if (toolInfo.status === "running" || toolInfo.status === "pending") { updatePhaseStatus(state, `Running tool: ${toolInfo.name}`, provider); } else if (toolInfo.status === "completed") { @@ -564,9 +603,7 @@ export function buildSessionMessageState( const codexToolById = new Map(); const kimiToolById = new Map(); const kiloToolById = new Map(); - const geminiToolById = new Map(); const openHandsToolById = new Map(); - const kiroTodoById = new Map(); // Map of messageID -> role ("user" | "assistant" | ...) built from // `message.updated` events. Used to avoid treating user prompt TextParts // as assistant output (OpenCode emits TextPart for both roles and @@ -584,20 +621,19 @@ export function buildSessionMessageState( codexToolById.set(existingTool.id, { ...existingTool }); kimiToolById.set(existingTool.id, { ...existingTool }); kiloToolById.set(existingTool.id, { ...existingTool }); - geminiToolById.set(existingTool.id, { ...existingTool }); openHandsToolById.set(existingTool.id, { ...existingTool }); } - for (const existingTodo of state.todos) { - const key = existingTodo.content || `todo-${kiroTodoById.size}`; - kiroTodoById.set(key, { ...existingTodo }); - } - const providerParsers: ProviderParser[] = [ { extract: extractClaudeRecord, - apply: (record) => { - applyClaudeRecordToState(state, record as Parameters[1], sharedStreamState); + apply: (record, timestamp) => { + applyClaudeRecordToState( + state, + record as Parameters[1], + sharedStreamState, + timestamp + ); }, }, { @@ -606,12 +642,6 @@ export function buildSessionMessageState( applyCodexRecordToState(state, record as Parameters[1], codexToolById); }, }, - { - extract: extractKiroRecord, - apply: (record) => { - applyKiroRecordToState(state, record as Parameters[1], kiroTodoById); - }, - }, { extract: extractKimiRecord, apply: (record) => { @@ -636,12 +666,6 @@ export function buildSessionMessageState( applyGooseRecordToState(state, record as Parameters[1], sharedStreamState); }, }, - { - extract: extractGeminiRecord, - apply: (record) => { - applyGeminiRecordToState(state, record as Parameters[1], geminiToolById); - }, - }, { extract: extractPiRecord, apply: (record) => { @@ -673,19 +697,29 @@ export function buildSessionMessageState( const eventProps = getEventProperties(eventData); const type = event.type; - applyMetadataFromRecord(state, eventData); - applyMetadataFromRecord(state, eventProps); - applyMetadataFromRecord(state, eventProps.record); - applyMetadataFromRecord(state, eventProps.message); - applyMetadataFromRecord(state, eventProps.info); - applyMetadataFromRecord(state, eventProps.part); - applyMetadataFromRecord(state, eventProps.event); + applyMetadataFromRecord(state, eventData, provider); + applyMetadataFromRecord(state, eventProps, provider); + applyMetadataFromRecord(state, eventProps.record, provider); + applyMetadataFromRecord(state, eventProps.message, provider); + applyMetadataFromRecord(state, eventProps.info, provider); + applyMetadataFromRecord(state, eventProps.part, provider); + applyMetadataFromRecord(state, eventProps.event, provider); + + if (eventProps.protocolKnown === false) { + const protocolLabel = asNonEmptyString(eventProps.protocolLabel) + ?? asNonEmptyString(eventProps.streamEventType) + ?? asNonEmptyString(eventProps.recordType) + ?? type; + const providerLabel = provider ? getAgentProviderLabel(provider) : "Coding CLI"; + state.phaseStatus = `${providerLabel} integration update required: ${protocolLabel}`; + continue; + } let handledByProvider = false; for (const parser of providerParsers) { const record = parser.extract(type, eventData, eventProps); if (!record) continue; - parser.apply(record); + parser.apply(record, event.timestamp); handledByProvider = true; break; } @@ -698,7 +732,7 @@ export function buildSessionMessageState( } if (type === "message.updated") { - applyMessageUpdatedEvent(state, eventProps, messageRoles); + applyMessageUpdatedEvent(state, eventProps, messageRoles, provider); } if (type === "session.status") { diff --git a/packages/utils/status-stream.ts b/packages/utils/status-stream.ts deleted file mode 100644 index c9545799..00000000 --- a/packages/utils/status-stream.ts +++ /dev/null @@ -1,437 +0,0 @@ -// --------------------------------------------------------------------------- -// SessionMessageState → Slack streaming-API chunk diff. -// -// Companion to packages/utils/status.ts (which renders the whole status as -// markdown text for chat.update). This module instead produces incremental -// `task_update` / `plan_update` chunks that Slack's chat.appendStream API -// renders as animated task cards in a plan block. -// -// Usage from the kernel: -// const differ = createStatusStreamDiffer(); -// // on each progress tick: -// const chunks = differ.diff(state, request); -// if (chunks.length > 0) await im.appendStatusStream(...chunks); -// -// Design notes: -// - We render one complete live-status card: run context, current phase, -// tasks, and tool calling all update in place inside the same -// Slack plan card. -// - We use stable synthetic task_update ids (meta/context, group:tasks, -// etc.) instead of raw tool ids. Slack updates rows with the same id, so -// long runs stay compact instead of appending an unbounded tool log. -// - We only emit a chunk when a row's effective shape (title/status/output) -// actually changes — Slack drops near-duplicate appends but emitting them -// anyway wastes the Tier-4 budget (100/min). -// - The plan title summarizes the full status and intentionally avoids -// per-tool phases such as "Running tool: Bash"; those live in the phase -// row so the card header does not flicker. -// - All free-text fields are pre-truncated to Slack's 256-char chunk limit -// inside serializeStreamChunk (api.ts) — here we focus on shape & diffing. -// --------------------------------------------------------------------------- - -import type { SessionMessageState, SessionTodo, SessionTool } from "./session-inspector"; -import type { StatusStreamChunk } from "@/core/types"; -import { - TOOL_DISPLAY_CONFIG, - type StatusMessageFormat, -} from "@/config/web"; -import { formatElapsedTime, trimToolPath } from "./status"; - -type TaskStatus = "pending" | "in_progress" | "complete" | "error"; - -type RowFingerprint = { - title: string; - status: TaskStatus; - details?: string; - output?: string; -}; - -type TaskRow = RowFingerprint & { - id: string; -}; - -const MAX_TODO_ROWS = 5; - -function mapToolStatus(status: string): TaskStatus { - switch (status) { - case "running": - return "in_progress"; - case "pending": - return "pending"; - case "error": - return "error"; - case "completed": - default: - return "complete"; - } -} - -function mapTodoStatus(status: string): TaskStatus { - switch ((status || "").toLowerCase()) { - case "completed": - case "complete": - case "done": - return "complete"; - case "in_progress": - case "in progress": - case "running": - return "in_progress"; - case "error": - case "failed": - return "error"; - case "pending": - default: - return "pending"; - } -} - -function formatCompactCount(value: number): string { - if (!Number.isFinite(value)) return "0"; - const sign = value < 0 ? "-" : ""; - let current = Math.abs(value); - let unitIndex = 0; - const units = ["", "k", "m", "b", "t"]; - - while (current >= 1000 && unitIndex < units.length - 1) { - current /= 1000; - unitIndex += 1; - } - - if (unitIndex === 0) return `${sign}${Math.round(current)}`; - - const rounded = current >= 10 - ? Math.round(current) - : Math.round(current * 10) / 10; - return `${sign}${rounded}${units[unitIndex]}`; -} - -function truncateField(value: string, maxLength = 220): string { - const trimmed = value.trim(); - if (trimmed.length <= maxLength) return trimmed; - return `${trimmed.slice(0, maxLength - 1)}…`; -} - -function compactFinalTextPreview(text: string | undefined, maxLength = 110): string | undefined { - const compact = text - ?.split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .join(" ") - .replace(/[`*_#]/g, "") - .trim(); - if (!compact) return undefined; - return truncateField(compact, maxLength); -} - -function compactPath(path: string): string { - const home = process.env.HOME; - const trimmed = path.trim(); - if (!trimmed) return trimmed; - if (home && trimmed.startsWith(`${home}/`)) { - return `~/${trimmed.slice(home.length + 1)}`; - } - return trimmed; -} - -function buildPlanTitle(state: SessionMessageState, startedAt: number): string { - const title = state.sessionTitle?.trim() || state.agent?.trim() || "Working"; - const elapsed = formatElapsedTime(startedAt); - return truncateField([title, state.agent?.trim(), state.model?.trim(), elapsed].filter(Boolean).join(" · "), 160); -} - -function buildContextRow(agentLabel: string | undefined, runMode: string | undefined, workingPath: string): TaskRow { - const mode = runMode?.trim() || "build mode"; - const details = [agentLabel?.trim(), mode].filter(Boolean).join(" · "); - return { - id: "meta:context", - title: "Run context", - status: "complete", - details, - output: truncateField(compactPath(workingPath), 180), - }; -} - -function buildCurrentStatusRow(state: SessionMessageState): TaskRow { - const phase = state.phaseStatus?.trim() || "Working"; - const thinking = state.thinkingText?.trim(); - const detail = thinking && /\bthinking\b/i.test(phase) - ? `${phase}: ${thinking}` - : phase; - const waitingForUser = /\b(waiting|question|approval|permission|confirm|choose|select|input)\b/i.test(phase); - const finalizing = /\b(done|complete|completed|finalizing|finalized)\b/i.test(phase); - return { - id: "meta:phase", - title: "Current status", - status: waitingForUser ? "in_progress" : finalizing ? "complete" : "in_progress", - details: truncateField(detail, 220), - }; -} - -function getToolDisplayName(name: string): string { - switch ((name || "").toLowerCase()) { - case "read_file": - case "read_many_files": - return "read"; - case "write_file": - return "write"; - case "run_shell_command": - return "bash"; - case "grep_search": - return "grep"; - default: - return name || "tool"; - } -} - -/** - * Build a short, human-readable task title from a SessionTool. Mirrors the - * one-liners that buildToolLines() shows in the plain-text status, e.g. - * bash `git status` - * read packages/core/kernel/request-run.ts - * grep "TODO" in packages/ - * - * Stays well under Slack's 256-char chunk cap; the streaming layer truncates - * again as a safety net. - */ -function buildTaskTitle(tool: SessionTool, workingPath: string): string { - const display = getToolDisplayName(tool.name); - const input = (tool.input || {}) as Record; - const lowered = (tool.name || "").toLowerCase(); - - if (lowered === "bash" || lowered === "run_shell_command") { - const cmd = String(input.command || input.cmd || "").trim(); - if (cmd) return `bash: ${cmd.slice(0, 180)}`; - } - if (lowered === "read" || lowered === "read_file" || lowered === "read_many_files") { - const file = String(input.filePath || input.file_path || input.absolute_path || ""); - if (file) return `read ${trimToolPath(file, workingPath)}`; - } - if (lowered === "edit" || lowered === "write" || lowered === "write_file") { - const file = String(input.filePath || input.file_path || input.absolute_path || ""); - if (file) return `${display} ${trimToolPath(file, workingPath)}`; - } - if (lowered === "grep" || lowered === "rg" || lowered === "ripgrep" || lowered === "grep_search") { - const pattern = String(input.pattern || ""); - const path = trimToolPath(String(input.path || "."), workingPath); - if (pattern) return `grep ${pattern} in ${path}`.trim(); - } - if (lowered === "glob") { - const pattern = String(input.pattern || ""); - const path = trimToolPath(String(input.path || "."), workingPath); - if (pattern) return `glob ${pattern} in ${path}`.trim(); - } - - // Fall back to the SDK-provided title or the bare tool name. - const title = tool.title?.trim(); - return title ? `${display} ${trimToolPath(title, workingPath)}` : display; -} - -function fingerprintsEqual(a: RowFingerprint, b: RowFingerprint): boolean { - return ( - a.title === b.title && - a.status === b.status && - (a.details ?? "") === (b.details ?? "") && - (a.output ?? "") === (b.output ?? "") - ); -} - -function selectRecentTools( - tools: SessionTool[], - statusMessageFormat: StatusMessageFormat -): { hiddenCount: number; visibleTools: SessionTool[] } { - const { itemLimit } = TOOL_DISPLAY_CONFIG[statusMessageFormat]; - const visible = tools.filter((tool) => tool.id); - const hiddenCount = Math.max(0, visible.length - itemLimit); - return { - hiddenCount, - visibleTools: hiddenCount > 0 ? visible.slice(-itemLimit) : visible, - }; -} - -function aggregateStatuses(statuses: TaskStatus[]): TaskStatus { - if (statuses.includes("error")) return "error"; - if (statuses.includes("in_progress")) return "in_progress"; - if (statuses.length > 0 && statuses.every((status) => status === "complete")) return "complete"; - return "pending"; -} - -function formatTodoStatus(status: string): string { - switch (mapTodoStatus(status)) { - case "complete": - return "done"; - case "in_progress": - return "in progress"; - case "error": - return "error"; - case "pending": - default: - return "pending"; - } -} - -function formatToolStatus(status: string): string { - switch (mapToolStatus(status)) { - case "complete": - return "done"; - case "in_progress": - return "running"; - case "error": - return "error"; - case "pending": - default: - return "pending"; - } -} - -function buildTasksRow(todos: SessionTodo[]): TaskRow { - const visibleTodos = todos.slice(0, MAX_TODO_ROWS); - const details = visibleTodos.length > 0 - ? visibleTodos.map((todo) => `- ${formatTodoStatus(todo.status)}: ${todo.content || "Task"}`).join("\n") - : "- pending: waiting for task updates"; - return { - id: "group:tasks", - title: "Tasks", - status: aggregateStatuses(visibleTodos.map((todo) => mapTodoStatus(todo.status))), - details: truncateField(details, 220), - }; -} - -function buildToolsRow( - tools: SessionTool[], - workingPath: string, - statusMessageFormat: StatusMessageFormat -): TaskRow { - const { hiddenCount, visibleTools } = selectRecentTools(tools, statusMessageFormat); - const toolLines = visibleTools.map((tool) => `- ${formatToolStatus(tool.status)}: ${buildTaskTitle(tool, workingPath)}`); - const details = toolLines.length > 0 - ? [ - ...(hiddenCount > 0 ? [`- done: previous ${hiddenCount} tool calls completed`] : []), - ...toolLines, - ].join("\n") - : "- pending: no active tool call"; - return { - id: "group:tools", - title: "Tool calling", - status: aggregateStatuses(visibleTools.map((tool) => mapToolStatus(tool.status))), - details: truncateField(details, 220), - }; -} - -function buildTaskRows(input: StatusStreamDiffInput): TaskRow[] { - const { state, workingPath, agentLabel, runMode, statusMessageFormat = "medium" } = input; - return [ - buildContextRow(agentLabel, runMode, workingPath), - buildCurrentStatusRow(state), - buildTasksRow(state.todos), - buildToolsRow(state.tools, workingPath, statusMessageFormat), - ]; -} - -export type StatusStreamDiffInput = { - state: SessionMessageState; - workingPath: string; - startedAt: number; - agentLabel?: string; - runMode?: string; - statusMessageFormat?: StatusMessageFormat; -}; - -export type StatusStreamDiffResult = { - /** Chunks to send to chat.appendStream. May be empty (no-op). */ - chunks: StatusStreamChunk[]; - /** - * Call AFTER chat.appendStream confirms success — this advances the - * differ's internal fingerprint cache so the same chunks aren't sent - * again. If the append fails, do NOT call commit: next diff() will - * re-emit the unconfirmed chunks (Slack will dedupe idempotent - * task_update payloads, and rate/network failures recover instead of - * leaving task cards permanently stale). - */ - commit(): void; -}; - -export type StatusStreamDiffer = { - /** - * Compute the chunks needed to bring the Slack-side stream in sync with - * the latest SessionMessageState, plus a `commit()` callback the caller - * runs after the network append succeeds. When the chunks list is empty - * the caller should skip both the appendStream round-trip and commit(). - */ - diff(input: StatusStreamDiffInput): StatusStreamDiffResult; - /** - * Compose a short final summary line for the terminal plan_update chunk - * we emit just before chat.stopStream. Kept separate from `diff()` - * because stopping is a one-shot terminal transition. - */ - finalize(input: StatusStreamDiffInput, finalText?: string): string; -}; - -export function createStatusStreamDiffer(): StatusStreamDiffer { - const lastFingerprints = new Map(); - let lastPlanTitle: string | undefined; - - return { - diff({ state, workingPath, startedAt, agentLabel, runMode, statusMessageFormat }) { - const chunks: StatusStreamChunk[] = []; - // Pending updates accumulated this tick. Only applied to - // lastFingerprints / lastPlanTitle when commit() runs, so a network - // failure leaves the old state in place and the next tick re-emits - // the same delta. - const pendingFingerprints: Array<[string, RowFingerprint]> = []; - let pendingPlanTitle: string | undefined; - let planTitleChanged = false; - - const planTitle = buildPlanTitle(state, startedAt); - if (planTitle !== lastPlanTitle) { - chunks.push({ type: "plan_update", title: planTitle }); - pendingPlanTitle = planTitle; - planTitleChanged = true; - } - - for (const row of buildTaskRows({ state, workingPath, startedAt, agentLabel, runMode, statusMessageFormat })) { - const { id, ...fp } = row; - const prev = lastFingerprints.get(id); - if (prev && fingerprintsEqual(prev, fp)) continue; - pendingFingerprints.push([id, fp]); - chunks.push({ - type: "task_update", - id, - title: fp.title, - status: fp.status, - ...(fp.details ? { details: fp.details } : {}), - ...(fp.output ? { output: fp.output } : {}), - }); - } - - return { - chunks, - commit() { - if (planTitleChanged) lastPlanTitle = pendingPlanTitle; - for (const [id, fp] of pendingFingerprints) { - lastFingerprints.set(id, fp); - } - }, - }; - }, - - finalize({ state, startedAt }, finalText) { - const elapsed = formatElapsedTime(startedAt); - const usage = state.tokenUsage; - const tokenSuffix = usage && usage.total > 0 - ? ` · ${formatCompactCount(usage.total)} tokens` - : ""; - const costSuffix = usage && typeof usage.cost === "number" && usage.cost > 0 - ? ` · $${usage.cost.toFixed(3)}` - : ""; - const titlePart = state.sessionTitle ? `${state.sessionTitle} · ` : ""; - const resultPart = compactFinalTextPreview(finalText); - const statusPart = `Done in ${elapsed}${tokenSuffix}${costSuffix}`; - return truncateField( - resultPart - ? `${titlePart}Result: ${resultPart} · ${statusPart}` - : `${titlePart}${statusPart}`, - 240 - ); - }, - }; -} diff --git a/packages/utils/status.test.ts b/packages/utils/status.test.ts index 48ed4521..73f58a24 100644 --- a/packages/utils/status.test.ts +++ b/packages/utils/status.test.ts @@ -50,7 +50,30 @@ describe("status message formatting", () => { }; const text = buildStatusMessageByProvider("goose", request, "/tmp/repo", state, "medium"); - expect(text).toContain("_Running tool: subagent_"); + expect(text).toContain("*Running tool: subagent*"); expect(text).not.toContain("Waiting for subagent output"); }); + + it("recognizes OpenCode task tools as subagents", () => { + const state: SessionMessageState = { + sessionTitle: "OpenCode is running...", + phaseStatus: "Running tool: task", + currentText: "Inspecting packages", + tools: [ + { + id: "tool-task", + name: "task", + title: "Audit repo state vs docs", + status: "running", + metadata: { startedAtMs: Date.now() - 40_000 }, + }, + ], + todos: [], + startedAt: Date.now() - 60_000, + }; + + const text = buildStatusMessageByProvider("opencode", request, "/tmp/repo", state, "medium"); + expect(text).toContain("Waiting for subagent: Audit repo state vs docs"); + expect(text).toContain("Inspecting packages"); + }); }); diff --git a/packages/utils/status.ts b/packages/utils/status.ts index 03b86d86..1a35681c 100644 --- a/packages/utils/status.ts +++ b/packages/utils/status.ts @@ -165,7 +165,8 @@ function resolveLongRunningSubagentPhase(state: SessionMessageState): string | u .reverse() .find((tool) => { const name = typeof tool.name === "string" ? tool.name.trim().toLowerCase() : ""; - return (tool.status === "running" || tool.status === "pending") && name === "subagent"; + const isSubagent = name === "subagent" || name === "subtask" || name === "task"; + return (tool.status === "running" || tool.status === "pending") && isSubagent; }); if (!runningSubagent) return undefined; @@ -177,7 +178,15 @@ function resolveLongRunningSubagentPhase(state: SessionMessageState): string | u const elapsedMs = Date.now() - startedAtMs; if (elapsedMs < SUBAGENT_WAIT_THRESHOLD_MS) return undefined; - return `Waiting for subagent output (${formatElapsedTime(startedAtMs)})`; + const title = runningSubagent.title?.trim(); + const progress = typeof runningSubagent.metadata?.progress === "string" + ? runningSubagent.metadata.progress.trim() + : typeof runningSubagent.metadata?.lastTool === "string" + ? runningSubagent.metadata.lastTool.trim() + : ""; + return title + ? `Waiting for subagent: ${title}${progress ? ` — ${progress}` : ""} (${formatElapsedTime(startedAtMs)})` + : `Waiting for subagent output (${formatElapsedTime(startedAtMs)})`; } function normalizeToolName(name: string): string { @@ -330,16 +339,22 @@ export function buildLiveStatusMessage( const headerDetails = buildHeaderDetails(state); if (state.sessionTitle) { - lines.push(`*${state.sessionTitle}* (${headerDetails})`); + lines.push(`**${state.sessionTitle}** · ${headerDetails}`); } else { - lines.push(`(${headerDetails})`); + lines.push(headerDetails); } const longRunningSubagentPhase = resolveLongRunningSubagentPhase(state); if (longRunningSubagentPhase) { - lines.push(`_${longRunningSubagentPhase}_`); + lines.push(`*${longRunningSubagentPhase}*`); } else if (state.phaseStatus) { - lines.push(`_${state.phaseStatus}_`); + lines.push(`*${state.phaseStatus}*`); + } + + if (state.thinkingText?.trim()) { + const thinking = state.thinkingText.trim().replace(/\s+/g, " "); + const preview = thinking.length > 420 ? `${thinking.slice(0, 419)}…` : thinking; + lines.push("", "**Reasoning**", `> ${preview}`); } if (state.todos.length > 0) { @@ -347,15 +362,21 @@ export function buildLiveStatusMessage( content: todo.content, status: todo.status, })); - lines.push("", "*Tasks*", ...formatTodoLines(todos)); + lines.push("", "**Plan**", ...formatTodoLines(todos)); } const toolLines = buildToolLines(state, workingPath, statusMessageFormat); if (toolLines.length > 0) { - lines.push(""); + lines.push("", "**Activity**"); lines.push(...toolLines); } + if (state.currentText?.trim()) { + const current = state.currentText.trim(); + const preview = current.length > 900 ? `${current.slice(0, 899)}…` : current; + lines.push("", "**Latest output**", preview); + } + return lines.join("\n"); } diff --git a/packages/utils/test/status-stream.test.ts b/packages/utils/test/status-stream.test.ts deleted file mode 100644 index bac497bd..00000000 --- a/packages/utils/test/status-stream.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { createStatusStreamDiffer } from "../status-stream"; -import type { SessionMessageState } from "../session-inspector"; - -function baseState(overrides: Partial = {}): SessionMessageState { - return { - sessionTitle: "Test", - phaseStatus: "Working", - currentText: "", - tools: [], - todos: [], - startedAt: Date.now(), - ...overrides, - }; -} - -const cwd = "/tmp/repo"; - -describe("createStatusStreamDiffer", () => { - it("emits a plan_update plus complete-card rows for the initial status", () => { - const differ = createStatusStreamDiffer(); - const { chunks, commit } = differ.diff({ - state: baseState(), - workingPath: cwd, - startedAt: Date.now(), - }); - expect(chunks).toHaveLength(5); - expect(chunks[0]).toMatchObject({ type: "plan_update" }); - expect(chunks[0]?.type === "plan_update" ? chunks[0].title : "").toMatch(/^Test · \d+s$/); - expect(chunks[1]).toMatchObject({ - type: "task_update", - id: "meta:context", - title: "Run context", - status: "complete", - details: "build mode", - output: cwd, - }); - expect(chunks[2]).toMatchObject({ - type: "task_update", - id: "meta:phase", - title: "Current status", - status: "in_progress", - }); - expect(chunks[3]).toMatchObject({ - type: "task_update", - id: "group:tasks", - title: "Tasks", - }); - expect(chunks[4]).toMatchObject({ - type: "task_update", - id: "group:tools", - title: "Tool calling", - }); - commit(); - }); - - it("emits nothing on a second diff when nothing changed (after commit)", () => { - const differ = createStatusStreamDiffer(); - const first = differ.diff({ state: baseState(), workingPath: cwd, startedAt: 0 }); - first.commit(); - const second = differ.diff({ state: baseState(), workingPath: cwd, startedAt: 0 }); - expect(second.chunks).toHaveLength(0); - }); - - it("shows the requested run mode in the Run context row", () => { - const differ = createStatusStreamDiffer(); - const { chunks } = differ.diff({ - state: baseState(), - workingPath: cwd, - startedAt: 0, - agentLabel: "OpenCode", - runMode: "plan mode", - }); - const contextChunk = chunks.find((chunk) => chunk.type === "task_update" && chunk.id === "meta:context"); - expect(contextChunk).toMatchObject({ - type: "task_update", - id: "meta:context", - title: "Run context", - details: "OpenCode · plan mode", - output: cwd, - }); - }); - - it("retries the same chunks on the next tick when commit() is skipped", () => { - // Regression: a transient appendStream failure used to leave the - // differ's fingerprint cache advanced anyway, so the next tick would - // emit no chunks and the failed update was permanently lost. - const differ = createStatusStreamDiffer(); - const first = differ.diff({ state: baseState(), workingPath: cwd, startedAt: 0 }); - expect(first.chunks).toHaveLength(5); - // Simulate appendStream throwing — caller does NOT invoke commit(). - const second = differ.diff({ state: baseState(), workingPath: cwd, startedAt: 0 }); - expect(second.chunks).toEqual(first.chunks); - }); - - it("emits a task_update when a tool appears, then nothing when unchanged", () => { - const differ = createStatusStreamDiffer(); - const state = baseState({ - tools: [{ - id: "tool-1", - name: "bash", - status: "running", - input: { command: "git status" }, - }], - }); - const first = differ.diff({ state, workingPath: cwd, startedAt: 0 }); - first.commit(); - const taskChunk = first.chunks.find((c) => c.type === "task_update"); - expect(taskChunk).toBeDefined(); - if (taskChunk?.type === "task_update") { - expect(taskChunk.id).toBe("meta:context"); - } - const toolChunk = first.chunks.find((c) => c.type === "task_update" && c.id === "group:tools"); - expect(toolChunk).toBeDefined(); - if (toolChunk?.type === "task_update") { - expect(toolChunk.status).toBe("in_progress"); - expect(toolChunk.title).toBe("Tool calling"); - expect(toolChunk.details).toContain("git status"); - } - - const second = differ.diff({ state, workingPath: cwd, startedAt: 0 }); - expect(second.chunks).toHaveLength(0); - }); - - it("emits a follow-up task_update when a tool transitions to complete", () => { - const differ = createStatusStreamDiffer(); - const running = baseState({ - tools: [{ - id: "tool-1", - name: "bash", - status: "running", - input: { command: "git status" }, - }], - }); - const completed = baseState({ - tools: [{ - id: "tool-1", - name: "bash", - status: "completed", - input: { command: "git status" }, - output: "3 files modified", - }], - }); - differ.diff({ state: running, workingPath: cwd, startedAt: 0 }).commit(); - const { chunks, commit } = differ.diff({ - state: completed, - workingPath: cwd, - startedAt: 0, - }); - commit(); - const transition = chunks.find((c) => c.type === "task_update" && c.id === "group:tools"); - expect(transition).toBeDefined(); - if (transition?.type === "task_update") { - expect(transition.status).toBe("complete"); - expect(transition.output).toBeUndefined(); - expect(transition.details).toContain("git status"); - } - }); - - it("keeps tool-specific phases in the phase row instead of the plan title", () => { - const differ = createStatusStreamDiffer(); - differ.diff({ - state: baseState({ phaseStatus: "Working" }), - workingPath: cwd, - startedAt: 0, - }).commit(); - const { chunks, commit } = differ.diff({ - state: baseState({ phaseStatus: "Running tool: bash" }), - workingPath: cwd, - startedAt: 0, - }); - commit(); - expect(chunks).not.toContainEqual({ type: "plan_update", title: "Running tool: bash" }); - expect(chunks).toContainEqual({ type: "task_update", id: "meta:phase", title: "Current status", status: "in_progress", details: "Running tool: bash" }); - }); - - it("does not render cumulative assistant draft text as the current status", () => { - const differ = createStatusStreamDiffer(); - const { chunks } = differ.diff({ - state: baseState({ - phaseStatus: "Working", - currentText: "Working: first update. Working: second update. Working: third update.", - }), - workingPath: cwd, - startedAt: 0, - }); - - const phaseChunk = chunks.find((chunk) => chunk.type === "task_update" && chunk.id === "meta:phase"); - expect(phaseChunk).toMatchObject({ - type: "task_update", - id: "meta:phase", - title: "Current status", - status: "in_progress", - details: "Working", - }); - }); - - it("renders todos ahead of recent tool slots", () => { - const differ = createStatusStreamDiffer(); - const { chunks } = differ.diff({ - state: baseState({ - todos: [ - { content: "Inspect current implementation", status: "completed" }, - { content: "Patch Slack stream layout", status: "in_progress" }, - ], - tools: [{ - id: "tool-1", - name: "bash", - status: "running", - input: { command: "bun test" }, - }], - }), - workingPath: cwd, - startedAt: 0, - }); - - const taskIds = chunks - .filter((chunk) => chunk.type === "task_update") - .map((chunk) => chunk.id); - expect(taskIds).toEqual(["meta:context", "meta:phase", "group:tasks", "group:tools"]); - const taskChunk = chunks.find((chunk) => chunk.type === "task_update" && chunk.id === "group:tasks"); - expect(taskChunk?.type === "task_update" ? taskChunk.details : "").toContain("Inspect current implementation"); - expect(taskChunk?.type === "task_update" ? taskChunk.details : "").toContain("Patch Slack stream layout"); - }); - - it("keeps recent tools grouped in one stable row instead of appending raw tool ids forever", () => { - const differ = createStatusStreamDiffer(); - const makeTools = (count: number) => Array.from({ length: count }, (_, index) => ({ - id: `tool-${index + 1}`, - name: "bash", - status: "completed", - input: { command: `echo ${index + 1}` }, - output: `done ${index + 1}`, - })); - - const first = differ.diff({ - state: baseState({ tools: makeTools(6) }), - workingPath: cwd, - startedAt: 0, - }); - first.commit(); - const firstToolIds = first.chunks - .filter((chunk) => chunk.type === "task_update") - .filter((chunk) => chunk.id === "group:tools") - .map((chunk) => chunk.id); - expect(firstToolIds).toEqual(["group:tools"]); - const firstToolChunk = first.chunks.find((chunk) => chunk.type === "task_update" && chunk.id === "group:tools"); - const firstDetails = firstToolChunk?.type === "task_update" ? firstToolChunk.details ?? "" : ""; - expect(firstDetails).not.toContain("previous"); - expect(firstDetails).toContain("echo 1"); - expect(firstDetails).toContain("echo 6"); - - const second = differ.diff({ - state: baseState({ tools: makeTools(7) }), - workingPath: cwd, - startedAt: 0, - }); - const secondToolIds = second.chunks - .filter((chunk) => chunk.type === "task_update") - .filter((chunk) => chunk.id === "group:tools") - .map((chunk) => chunk.id); - expect(secondToolIds).toEqual(["group:tools"]); - const secondToolChunk = second.chunks.find((chunk) => chunk.type === "task_update" && chunk.id === "group:tools"); - const secondDetails = secondToolChunk?.type === "task_update" ? secondToolChunk.details ?? "" : ""; - expect(secondDetails).toContain("- done: previous 1 tool calls completed"); - expect(secondDetails).not.toContain("echo 1"); - expect(secondDetails).toContain("echo 2"); - expect(secondDetails).toContain("echo 7"); - }); - - it("uses the configured live-status format limit for Slack AI Card tool rows", () => { - const makeTools = (count: number) => Array.from({ length: count }, (_, index) => ({ - id: `tool-${index + 1}`, - name: "bash", - status: "completed", - input: { command: `echo ${index + 1}` }, - })); - - const minimum = createStatusStreamDiffer().diff({ - state: baseState({ tools: makeTools(6) }), - workingPath: cwd, - startedAt: 0, - statusMessageFormat: "minimum", - }); - const minimumToolChunk = minimum.chunks.find((chunk) => chunk.type === "task_update" && chunk.id === "group:tools"); - const minimumDetails = minimumToolChunk?.type === "task_update" ? minimumToolChunk.details ?? "" : ""; - expect(minimumDetails).toContain("- done: previous 2 tool calls completed"); - expect(minimumDetails).not.toContain("echo 1"); - expect(minimumDetails).not.toContain("echo 2"); - expect(minimumDetails).toContain("echo 3"); - expect(minimumDetails).toContain("echo 6"); - - const aggressive = createStatusStreamDiffer().diff({ - state: baseState({ tools: makeTools(9) }), - workingPath: cwd, - startedAt: 0, - statusMessageFormat: "aggressive", - }); - const aggressiveToolChunk = aggressive.chunks.find((chunk) => chunk.type === "task_update" && chunk.id === "group:tools"); - const aggressiveDetails = aggressiveToolChunk?.type === "task_update" ? aggressiveToolChunk.details ?? "" : ""; - expect(aggressiveDetails).toContain("- done: previous 1 tool calls completed"); - expect(aggressiveDetails).not.toContain("echo 1"); - expect(aggressiveDetails).toContain("echo 2"); - expect(aggressiveDetails).toContain("echo 9"); - }); - - it("builds a plain-text final title for Slack plan_update chunks", () => { - const differ = createStatusStreamDiffer(); - const title = differ.finalize({ - state: baseState({ - sessionTitle: "Claude Code", - tokenUsage: { input: 2600, output: 3000, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 5600 }, - }), - workingPath: cwd, - startedAt: Date.now() - 75_000, - }); - - expect(title).toStartWith("Claude Code · Done in "); - expect(title).toContain("5.6k tokens"); - expect(title).not.toContain("*"); - }); - - it("puts a final result preview in the collapsed Slack card title", () => { - const differ = createStatusStreamDiffer(); - const title = differ.finalize({ - state: baseState({ - sessionTitle: "Claude Code", - tokenUsage: { input: 12_000, output: 4_400, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 16_400 }, - }), - workingPath: cwd, - startedAt: Date.now() - 180_000, - }, "### Result\n\n`sample-project` checked successfully. This is a deliberately long result preview that should be shortened before it reaches Slack."); - - expect(title).toStartWith("Claude Code · Result: Result sample-project checked successfully."); - expect(title).toContain("Done in "); - expect(title).toContain("16k tokens"); - expect(title).not.toContain("`"); - expect(title).not.toContain("#"); - }); -}); diff --git a/packages/web-ui/src/lib/local-setting/store.ts b/packages/web-ui/src/lib/local-setting/store.ts index f6e12bed..9fc7699b 100644 --- a/packages/web-ui/src/lib/local-setting/store.ts +++ b/packages/web-ui/src/lib/local-setting/store.ts @@ -149,12 +149,7 @@ function normalizeConfig(input: DashboardConfig): DashboardConfig { updates: { autoUpgrade: input.updates?.autoUpgrade !== false, }, - workspaces: (input.workspaces ?? []).map((workspace) => ({ - ...workspace, - slackStatusMode: workspace.type === "slack" && workspace.slackStatusMode === "legacy" - ? "legacy" - : "ai_card", - })), + workspaces: input.workspaces ?? [], agents: { opencode: { enabled: input.agents?.opencode?.enabled ?? true, @@ -170,9 +165,6 @@ function normalizeConfig(input: DashboardConfig): DashboardConfig { kimi: { enabled: input.agents?.kimi?.enabled ?? true, }, - kiro: { - enabled: input.agents?.kiro?.enabled ?? true, - }, kilo: { enabled: input.agents?.kilo?.enabled ?? true, models: input.agents?.kilo?.models ?? [], @@ -183,9 +175,6 @@ function normalizeConfig(input: DashboardConfig): DashboardConfig { goose: { enabled: input.agents?.goose?.enabled ?? true, }, - gemini: { - enabled: input.agents?.gemini?.enabled ?? true, - }, pi: { enabled: input.agents?.pi?.enabled ?? true, models: input.agents?.pi?.models ?? [], @@ -429,10 +418,6 @@ function updateConfigWithAgentCheckResult(config: DashboardConfig, result: CliCh ...config.agents.kimi, enabled: result.kimi ?? config.agents.kimi.enabled, }, - kiro: { - ...config.agents.kiro, - enabled: result.kiro ?? config.agents.kiro.enabled, - }, kilo: { ...config.agents.kilo, enabled: result.kilo ?? config.agents.kilo.enabled, @@ -446,10 +431,6 @@ function updateConfigWithAgentCheckResult(config: DashboardConfig, result: CliCh ...config.agents.goose, enabled: result.goose ?? config.agents.goose.enabled, }, - gemini: { - ...config.agents.gemini, - enabled: result.gemini ?? config.agents.gemini.enabled, - }, pi: { ...config.agents.pi, enabled: result.pi ?? config.agents.pi.enabled, diff --git a/packages/web-ui/src/lib/session-inspector/SessionDetail.svelte b/packages/web-ui/src/lib/session-inspector/SessionDetail.svelte index b86b4624..8190a76b 100644 --- a/packages/web-ui/src/lib/session-inspector/SessionDetail.svelte +++ b/packages/web-ui/src/lib/session-inspector/SessionDetail.svelte @@ -155,11 +155,9 @@ if (meta?.agentProvider === "claudecode" || meta?.agentProvider === "claude") return "claudecode"; if (meta?.agentProvider === "codex" || meta?.sessionId?.startsWith("codex_")) return "codex"; if (meta?.agentProvider === "kimi" || meta?.sessionId?.startsWith("kimi_")) return "kimi"; - if (meta?.agentProvider === "kiro" || meta?.sessionId?.startsWith("kiro_")) return "kiro"; if (meta?.agentProvider === "kilo" || meta?.sessionId?.startsWith("kilo_")) return "kilo"; if (meta?.agentProvider === "qwen" || meta?.sessionId?.startsWith("qwen_")) return "qwen"; if (meta?.agentProvider === "goose" || meta?.sessionId?.startsWith("goose_")) return "goose"; - if (meta?.agentProvider === "gemini" || meta?.sessionId?.startsWith("gemini_")) return "gemini"; if (meta?.agentProvider === "pi" || meta?.sessionId?.startsWith("pi_")) return "pi"; if (meta?.agentProvider === "openhands" || meta?.sessionId?.startsWith("openhands_")) return "openhands"; if (meta?.agentProvider === "codebuddy" || meta?.sessionId?.startsWith("codebuddy_")) return "codebuddy"; diff --git a/packages/web-ui/src/routes/(settings)/agents/+page.svelte b/packages/web-ui/src/routes/(settings)/agents/+page.svelte index 0f12d849..2ef5f9e3 100644 --- a/packages/web-ui/src/routes/(settings)/agents/+page.svelte +++ b/packages/web-ui/src/routes/(settings)/agents/+page.svelte @@ -15,11 +15,9 @@ claudecode: "https://docs.anthropic.com/en/docs/claude-code/overview", codex: "https://github.com/openai/codex", kimi: "https://www.moonshot.ai/kimi-code", - kiro: "https://kiro.dev", kilo: "https://github.com/Kilo-Org/kilo", qwen: "https://github.com/QwenLM/qwen-code", goose: "https://block.github.io/goose/", - gemini: "https://github.com/google-gemini/gemini-cli", pi: "https://github.com/earendil-works/pi", openhands: "https://docs.openhands.dev", codebuddy: "https://www.codebuddy.ai/docs/cli/overview", @@ -31,11 +29,9 @@ claudecode: "Claude Code", codex: "Codex CLI", kimi: "Kimi CLI", - kiro: "Kiro CLI", kilo: "Kilo CLI", qwen: "Qwen CLI", goose: "Goose CLI", - gemini: "Gemini CLI", pi: "Pi CLI", openhands: "OpenHands CLI", codebuddy: "CodeBuddy CLI", diff --git a/packages/web-ui/src/routes/(settings)/inbox/[threadId]/+page.svelte b/packages/web-ui/src/routes/(settings)/inbox/[threadId]/+page.svelte index 92fa6c71..e67908f2 100644 --- a/packages/web-ui/src/routes/(settings)/inbox/[threadId]/+page.svelte +++ b/packages/web-ui/src/routes/(settings)/inbox/[threadId]/+page.svelte @@ -79,6 +79,24 @@ totalPages: number; }; + type OdeRunEvent = { + id: string; + schemaVersion: number; + timestamp: number; + type: string; + providerId: string; + sessionId: string; + runId?: string; + itemId?: string; + data: Record; + }; + + type RunEventPage = { + items: OdeRunEvent[]; + total: number; + limit: number; + }; + const threadId = $derived(decodeURIComponent(($page.params as Record).threadId ?? "")); let thread = $state(null); @@ -89,8 +107,10 @@ pageSize: 10, totalPages: 1, }); + let runEventPage = $state({ items: [], total: 0, limit: 100 }); let isThreadLoading = $state(false); let isDetailLoading = $state(false); + let isEventLoading = $state(false); let statusMessage = $state(""); function t(en: string, zh: string): string { @@ -165,8 +185,39 @@ return JSON.stringify(context, null, 2); } + function formatRunEvent(event: OdeRunEvent): string { + const data = event.data; + if (event.type === "run.progress") return String(data.phase ?? "Working"); + if (event.type === "message.delta" || event.type === "reasoning.summary.delta") { + return String(data.text ?? ""); + } + if (event.type.startsWith("tool.")) { + const title = typeof data.title === "string" && data.title.trim() + ? data.title + : String(data.name ?? "tool"); + const metadata = data.metadata && typeof data.metadata === "object" + ? data.metadata as Record + : null; + const child = metadata?.childSession === true + ? ` · ${String(metadata.childTitle ?? t("subagent", "子代理"))}` + : ""; + return `${title}${child}`; + } + if (event.type === "plan.updated") { + const items = Array.isArray(data.items) ? data.items : []; + return `${items.length} ${t("plan items", "项计划")}`; + } + if (event.type === "usage.updated") { + return `${String(data.total ?? 0)} tokens`; + } + if (event.type === "run.failed") return String(data.message ?? "Failed"); + if (event.type === "run.completed") return String(data.text ?? data.reason ?? "Completed"); + if (event.type === "run.started") return String(data.transport ?? "Started"); + return JSON.stringify(data); + } + async function loadThreadSummary(): Promise { - if (!threadId) return; + if (!threadId || isThreadLoading) return; isThreadLoading = true; try { const response = await fetch(`/api/message-threads/${encodeURIComponent(threadId)}/summary`); @@ -187,7 +238,7 @@ } async function loadDetails(nextPage = detailPage.page): Promise { - if (!threadId) return; + if (!threadId || isDetailLoading) return; isDetailLoading = true; try { const response = await fetch( @@ -209,13 +260,45 @@ } } + async function loadRunEvents(): Promise { + if (!threadId || isEventLoading) return; + isEventLoading = true; + try { + const response = await fetch( + `/api/message-threads/${encodeURIComponent(threadId)}/events?limit=${runEventPage.limit}`, + ); + const payload = (await response.json()) as { + ok?: boolean; + error?: string; + result?: RunEventPage; + }; + if (!response.ok || !payload.ok || !payload.result) { + throw new Error(payload.error || "Failed to load run events"); + } + runEventPage = payload.result; + } catch (error) { + statusMessage = `Run events load failed: ${error instanceof Error ? error.message : String(error)}`; + } finally { + isEventLoading = false; + } + } + async function refresh(): Promise { statusMessage = ""; - await Promise.all([loadThreadSummary(), loadDetails(detailPage.page)]); + await Promise.all([loadThreadSummary(), loadDetails(detailPage.page), loadRunEvents()]); } onMount(() => { void refresh(); + const pollTimer = window.setInterval(() => { + void (async () => { + const wasPending = (thread?.pendingDetailCount ?? 0) > 0; + await loadThreadSummary(); + if (!wasPending && (thread?.pendingDetailCount ?? 0) <= 0) return; + await Promise.all([loadRunEvents(), loadDetails(detailPage.page)]); + })(); + }, 2_000); + return () => window.clearInterval(pollTimer); }); @@ -277,6 +360,41 @@ {/if} +
+
+
+

{t("Live run events", "实时运行事件")}

+ {runEventPage.total} + {#if thread.pendingDetailCount > 0} + {t("Auto-refreshing", "自动刷新中")} + {/if} +
+ {#if isEventLoading} + {t("Updating...", "更新中...")} + {/if} +
+ + {#if runEventPage.items.length === 0} +

+ {t("No run events recorded yet.", "暂无运行事件。")} +

+ {:else} +
+ {#each [...runEventPage.items].reverse() as event (event.id)} +
+
+ {event.type} + + {formatTimestamp(event.timestamp)} + +
+

{formatRunEvent(event)}

+
+ {/each} +
+ {/if} +
+ {#if detailPage.items.length === 0 && !isDetailLoading}

{t("No details for this thread yet.", "该会话暂无 detail。")}

{:else} diff --git a/packages/web-ui/src/routes/(settings)/workspace/[workspaceName]/+page.svelte b/packages/web-ui/src/routes/(settings)/workspace/[workspaceName]/+page.svelte index 13e5edbc..211e9b73 100644 --- a/packages/web-ui/src/routes/(settings)/workspace/[workspaceName]/+page.svelte +++ b/packages/web-ui/src/routes/(settings)/workspace/[workspaceName]/+page.svelte @@ -113,7 +113,7 @@ function onWorkspaceFieldInput( workspaceId: string, - field: "name" | "domain" | "slackAppToken" | "slackBotToken" | "slackStatusMode" | "discordBotToken" | "larkAppKey" | "larkAppId" | "larkAppSecret", + field: "name" | "domain" | "slackAppToken" | "slackBotToken" | "discordBotToken" | "larkAppKey" | "larkAppId" | "larkAppSecret", value: string ): void { localSettingStore.updateWorkspace(workspaceId, (workspace) => ({ @@ -131,11 +131,6 @@ onWorkspaceFieldInput(workspaceId, field, (event.currentTarget as HTMLInputElement).value); } - function onSlackStatusModeChange(workspaceId: string, event: Event): void { - const value = (event.currentTarget as HTMLSelectElement).value === "legacy" ? "legacy" : "ai_card"; - onWorkspaceFieldInput(workspaceId, "slackStatusMode", value); - } - function onChannelProviderChange(workspaceId: string, channelId: string, event: Event): void { const selected = (event.currentTarget as HTMLSelectElement).value; const provider = parseAgentProvider(selected); @@ -385,17 +380,6 @@ /> -
- - -
{:else if selectedWorkspace.type === "discord"}
From efefe75c99879a64b5e791b5d14e97e4f400a6a4 Mon Sep 17 00:00:00 2001 From: Kai Liu Date: Sun, 2 Aug 2026 23:28:20 +0800 Subject: [PATCH 2/2] chore: release version 2.0.0 --- package.json | 2 +- packages/web-ui/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 524db977..0df58be5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ode", - "version": "0.2.0", + "version": "2.0.0", "description": "Coding anywhere with your coding agents connected", "module": "packages/core/index.ts", "type": "module", diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index 6ada336a..453e4b2b 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -1,7 +1,7 @@ { "name": "ode-web", "private": true, - "version": "0.0.1", + "version": "2.0.0", "type": "module", "scripts": { "dev": "vite dev",