failproofai 1.0.0-beta.2 → 1.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +6 -6
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js +2 -2
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js +1 -1
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
- package/.next/standalone/.next/server/app/policies/page.js +2 -2
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[externals]__0h9_83k._.js +3 -0
- package/.next/standalone/.next/server/chunks/[externals]__1ifpkx-._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-dh5jy._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0f8nu8d._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0h3lfmp._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0l4vuqm._.js +1 -1
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0longh-._.js → [root-of-the-server]__0szqzl8._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__10oa-v9._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__18d-9oi._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__16le-kd._.js → [root-of-the-server]__1p7s8-d._.js} +2 -2
- package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -1
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -1
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[externals]__1aq139v._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__0cmdyn_._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0uno39i._.js → [root-of-the-server]__0n_uvv0._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01od0m6._.js → [root-of-the-server]__0rag5dg._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apb7k_._.js → [root-of-the-server]__0rtdfof._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__0sdrz-j._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qapotl._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_1e19atl._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0oy_-36.js → node_modules_html-to-image_es_index_1q9xxca.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
- package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 06jakt_xhoolk.js} +1 -1
- package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
- package/.next/standalone/.next/static/chunks/0oe2-hp0_2tzs.js +6 -0
- package/.next/standalone/.next/static/chunks/{1hdhteui932bq.js → 0qmo2t50kpb30.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 0ue9ak_xexiqh.js} +1 -1
- package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
- package/.next/standalone/.next/static/chunks/1fw3pxobk_otl.js +1 -0
- package/.next/standalone/.next/static/chunks/23kev26by1e4f.js +1 -0
- package/.next/standalone/.next/static/chunks/2c90h1g6x8z8y.js +1 -0
- package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 2zb3m1tzq6h98.js} +1 -1
- package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
- package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 31cqiv_cpgj9m.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 3h5_3u46gu9jx.js} +1 -1
- package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
- package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 43a1ywqsg9bq9.js} +1 -1
- package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
- package/.next/standalone/Cargo.lock +1992 -198
- package/.next/standalone/Cargo.toml +1 -1
- package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
- package/.next/standalone/app/components/pause-notices.tsx +111 -0
- package/.next/standalone/app/policies/hooks-client.tsx +81 -6
- package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
- package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
- package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
- package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
- package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
- package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
- package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
- package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
- package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
- package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
- package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
- package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
- package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
- package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
- package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
- package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
- package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
- package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
- package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
- package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
- package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
- package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
- package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
- package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
- package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
- package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
- package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
- package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
- package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
- package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
- package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
- package/.next/standalone/package.json +15 -9
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +129 -0
- package/dist/cli.mjs +2255 -779
- package/dist/worker.mjs +365 -52
- package/package.json +15 -9
- package/scripts/build-daemon-packages.mjs +210 -0
- package/scripts/daemon-platforms.mjs +42 -0
- package/scripts/prune-standalone.mjs +6 -0
- package/scripts/publish-aliases.mjs +62 -41
- package/src/hooks/builtin-policies.ts +139 -0
- package/src/hooks/cloud-connection.ts +205 -0
- package/src/hooks/cloud-enrollment-cli.ts +227 -0
- package/src/hooks/cloud-enrollment.ts +173 -0
- package/src/hooks/cloud-managed-policies.ts +138 -0
- package/src/hooks/collector-config.ts +226 -0
- package/src/hooks/configure-wizard.ts +221 -5
- package/src/hooks/custom-hooks-loader.ts +69 -25
- package/src/hooks/daemon-client.ts +18 -0
- package/src/hooks/daemon-download.ts +137 -27
- package/src/hooks/daemon-service.ts +23 -8
- package/src/hooks/handler.ts +144 -6
- package/src/hooks/hook-activity-store.ts +50 -0
- package/src/hooks/loader-utils.ts +6 -4
- package/src/hooks/policy-types.ts +35 -0
- package/src/hooks/session-pause-cli.ts +157 -0
- package/src/hooks/session-pause.ts +236 -0
- package/src/hooks/tui.ts +115 -0
- package/.next/standalone/.next/server/chunks/[externals]__14odj07._.js +0 -3
- package/.next/standalone/.next/server/chunks/[externals]__1s61mel._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[externals]__1_g_b3t._.js +0 -3
- package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
- package/.next/standalone/.next/static/chunks/2mazwb1qmur1p.js +0 -1
- package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
- package/.next/standalone/.next/static/chunks/31feojxhjyjpl.js +0 -1
- package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
- package/.next/standalone/.next/static/chunks/3s7myk_co0gbh.js +0 -1
- package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,1478 @@
|
|
|
1
|
+
//! opencode source: transform correctness and poll behaviour, against REAL
|
|
2
|
+
//! SQLite databases.
|
|
3
|
+
//!
|
|
4
|
+
//! Nothing here is a hand-shaped fixture. [`SCHEMA`] is the verbatim DDL of a
|
|
5
|
+
//! live `~/.local/share/opencode/opencode.db` (opencode 1.18.11), including
|
|
6
|
+
//! `session_message` — the dead legacy table this source must never read — and
|
|
7
|
+
//! the `part.data` / `message.data` payloads, ids and epoch-millisecond
|
|
8
|
+
//! timestamps are copied from real rows of that capture.
|
|
9
|
+
//!
|
|
10
|
+
//! The headline is [`a_part_that_mutates_from_running_to_completed_is_re_read`]:
|
|
11
|
+
//! opencode fills a tool call's result into the SAME row ~12 seconds after it
|
|
12
|
+
//! appears, so that test replays the observed mutation and asserts both that
|
|
13
|
+
//! the result ships and that a rowid watermark would have had nothing to hand
|
|
14
|
+
//! back.
|
|
15
|
+
//!
|
|
16
|
+
//! Databases are created in WAL mode and read through
|
|
17
|
+
//! [`sqlitepoll::open_readonly`], which is exactly the pairing production uses.
|
|
18
|
+
|
|
19
|
+
use std::fs;
|
|
20
|
+
use std::path::{Path, PathBuf};
|
|
21
|
+
use std::time::{SystemTime, UNIX_EPOCH};
|
|
22
|
+
|
|
23
|
+
use fpai_collect::sources::opencode::{self, transform};
|
|
24
|
+
use fpai_collect::sqlitepoll::{self, PollOutcome, PollRequest, Watermark};
|
|
25
|
+
use rusqlite::Connection;
|
|
26
|
+
use serde_json::{Value, json};
|
|
27
|
+
|
|
28
|
+
/// Verbatim DDL from the capture, minus the tables this source never reads
|
|
29
|
+
/// (`account*`, `credential`, `event*`, `permission`, `todo`, `workspace`, …).
|
|
30
|
+
///
|
|
31
|
+
/// `session_message` is kept deliberately: it is named exactly like the table
|
|
32
|
+
/// this source wants, it is permanently empty, and four shipped migrations
|
|
33
|
+
/// `DELETE FROM` it. A poller that reached for it by name would read nothing
|
|
34
|
+
/// and report success forever.
|
|
35
|
+
const SCHEMA: &str = r#"
|
|
36
|
+
CREATE TABLE `project` (
|
|
37
|
+
`id` text PRIMARY KEY,
|
|
38
|
+
`worktree` text NOT NULL,
|
|
39
|
+
`vcs` text,
|
|
40
|
+
`name` text,
|
|
41
|
+
`icon_url` text,
|
|
42
|
+
`icon_url_override` text,
|
|
43
|
+
`icon_color` text,
|
|
44
|
+
`time_created` integer NOT NULL,
|
|
45
|
+
`time_updated` integer NOT NULL,
|
|
46
|
+
`time_initialized` integer,
|
|
47
|
+
`sandboxes` text NOT NULL,
|
|
48
|
+
`commands` text
|
|
49
|
+
);
|
|
50
|
+
CREATE TABLE `session` (
|
|
51
|
+
`id` text PRIMARY KEY,
|
|
52
|
+
`project_id` text NOT NULL,
|
|
53
|
+
`workspace_id` text,
|
|
54
|
+
`parent_id` text,
|
|
55
|
+
`slug` text NOT NULL,
|
|
56
|
+
`directory` text NOT NULL,
|
|
57
|
+
`path` text,
|
|
58
|
+
`title` text NOT NULL,
|
|
59
|
+
`version` text NOT NULL,
|
|
60
|
+
`share_url` text,
|
|
61
|
+
`summary_additions` integer,
|
|
62
|
+
`summary_deletions` integer,
|
|
63
|
+
`summary_files` integer,
|
|
64
|
+
`summary_diffs` text,
|
|
65
|
+
`metadata` text,
|
|
66
|
+
`cost` real DEFAULT 0 NOT NULL,
|
|
67
|
+
`tokens_input` integer DEFAULT 0 NOT NULL,
|
|
68
|
+
`tokens_output` integer DEFAULT 0 NOT NULL,
|
|
69
|
+
`tokens_reasoning` integer DEFAULT 0 NOT NULL,
|
|
70
|
+
`tokens_cache_read` integer DEFAULT 0 NOT NULL,
|
|
71
|
+
`tokens_cache_write` integer DEFAULT 0 NOT NULL,
|
|
72
|
+
`revert` text,
|
|
73
|
+
`permission` text,
|
|
74
|
+
`agent` text,
|
|
75
|
+
`model` text,
|
|
76
|
+
`time_created` integer NOT NULL,
|
|
77
|
+
`time_updated` integer NOT NULL,
|
|
78
|
+
`time_compacting` integer,
|
|
79
|
+
`time_archived` integer,
|
|
80
|
+
CONSTRAINT `fk_session_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
|
|
81
|
+
);
|
|
82
|
+
CREATE INDEX `session_project_idx` ON `session` (`project_id`);
|
|
83
|
+
CREATE TABLE `message` (
|
|
84
|
+
`id` text PRIMARY KEY,
|
|
85
|
+
`session_id` text NOT NULL,
|
|
86
|
+
`time_created` integer NOT NULL,
|
|
87
|
+
`time_updated` integer NOT NULL,
|
|
88
|
+
`data` text NOT NULL,
|
|
89
|
+
CONSTRAINT `fk_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
|
|
90
|
+
);
|
|
91
|
+
CREATE INDEX `message_session_time_created_id_idx` ON `message` (`session_id`,`time_created`,`id`);
|
|
92
|
+
CREATE TABLE `part` (
|
|
93
|
+
`id` text PRIMARY KEY,
|
|
94
|
+
`message_id` text NOT NULL,
|
|
95
|
+
`session_id` text NOT NULL,
|
|
96
|
+
`time_created` integer NOT NULL,
|
|
97
|
+
`time_updated` integer NOT NULL,
|
|
98
|
+
`data` text NOT NULL,
|
|
99
|
+
CONSTRAINT `fk_part_message_id_message_id_fk` FOREIGN KEY (`message_id`) REFERENCES `message`(`id`) ON DELETE CASCADE
|
|
100
|
+
);
|
|
101
|
+
CREATE INDEX `part_message_id_id_idx` ON `part` (`message_id`,`id`);
|
|
102
|
+
CREATE INDEX `part_session_idx` ON `part` (`session_id`);
|
|
103
|
+
CREATE TABLE `session_message` (
|
|
104
|
+
`id` text PRIMARY KEY,
|
|
105
|
+
`session_id` text NOT NULL,
|
|
106
|
+
`type` text NOT NULL,
|
|
107
|
+
`seq` integer NOT NULL,
|
|
108
|
+
`time_created` integer NOT NULL,
|
|
109
|
+
`time_updated` integer NOT NULL,
|
|
110
|
+
`data` text NOT NULL,
|
|
111
|
+
CONSTRAINT `fk_session_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
|
|
112
|
+
);
|
|
113
|
+
"#;
|
|
114
|
+
|
|
115
|
+
// Real ids and directories from the capture.
|
|
116
|
+
const SESSION: &str = "ses_0395f98c4ffe9Axu1kXPL9lnVR";
|
|
117
|
+
const DIRECTORY: &str = "/tmp/probe-opencode";
|
|
118
|
+
const AGENT: &str = "opencode-probe-opencode";
|
|
119
|
+
const USER_MSG: &str = "msg_fc6ab275b001QFpPqoEGCunJ50";
|
|
120
|
+
const ASSISTANT_MSG: &str = "msg_fc6ab29a1001XwwrJYstY20PH0";
|
|
121
|
+
const TOOL_PART: &str = "prt_fc6ab3d28001jvAlnWvL07p0le";
|
|
122
|
+
const CALL_ID: &str = "toolu_bdrk_016VM3PbpoehGTXrHGuRo4jG";
|
|
123
|
+
const MODEL: &str = "claude-sonnet-4-6";
|
|
124
|
+
|
|
125
|
+
// The observed lifetime of the 12-second tool call, to the millisecond.
|
|
126
|
+
const PROMPT_MS: i64 = 1_785_744_533_339;
|
|
127
|
+
const TOOL_CREATED_MS: i64 = 1_785_744_538_920;
|
|
128
|
+
const TOOL_RUNNING_MS: i64 = 1_785_744_539_061;
|
|
129
|
+
const TOOL_DONE_MS: i64 = 1_785_744_551_135;
|
|
130
|
+
|
|
131
|
+
fn tmpdir(name: &str) -> PathBuf {
|
|
132
|
+
let d = std::env::temp_dir().join(format!(
|
|
133
|
+
"fpai-oc-{}-{}-{}",
|
|
134
|
+
name,
|
|
135
|
+
std::process::id(),
|
|
136
|
+
SystemTime::now()
|
|
137
|
+
.duration_since(UNIX_EPOCH)
|
|
138
|
+
.unwrap()
|
|
139
|
+
.as_nanos()
|
|
140
|
+
));
|
|
141
|
+
fs::create_dir_all(&d).unwrap();
|
|
142
|
+
d
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/// A writable WAL database carrying the real schema — the shape production
|
|
146
|
+
/// reads, not a simplification of it.
|
|
147
|
+
fn make_db(dir: &Path) -> (PathBuf, Connection) {
|
|
148
|
+
let path = dir.join("opencode.db");
|
|
149
|
+
let conn = Connection::open(&path).unwrap();
|
|
150
|
+
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
|
|
151
|
+
conn.execute_batch(SCHEMA).unwrap();
|
|
152
|
+
conn.execute(
|
|
153
|
+
"INSERT INTO project VALUES ('global','/',NULL,NULL,NULL,NULL,NULL,1,1,NULL,'[]',NULL)",
|
|
154
|
+
[],
|
|
155
|
+
)
|
|
156
|
+
.unwrap();
|
|
157
|
+
(path, conn)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/// `path` is passed explicitly on purpose: it is the column that must never be
|
|
161
|
+
/// used, so every test states what misleading value it holds.
|
|
162
|
+
fn insert_session(conn: &Connection, id: &str, directory: &str, path: &str, tc: i64, tu: i64) {
|
|
163
|
+
conn.execute(
|
|
164
|
+
"INSERT INTO session (id, project_id, slug, directory, path, title, version, \
|
|
165
|
+
cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, \
|
|
166
|
+
tokens_cache_write, time_created, time_updated) \
|
|
167
|
+
VALUES (?1,'global','clever-nebula',?2,?3,'Directory files & README review','1.18.11', \
|
|
168
|
+
0, 13, 431, 0, 75048, 30323, ?4, ?5)",
|
|
169
|
+
rusqlite::params![id, directory, path, tc, tu],
|
|
170
|
+
)
|
|
171
|
+
.unwrap();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
fn touch_session(conn: &Connection, id: &str, tu: i64) {
|
|
175
|
+
conn.execute(
|
|
176
|
+
"UPDATE session SET time_updated = ?2 WHERE id = ?1",
|
|
177
|
+
rusqlite::params![id, tu],
|
|
178
|
+
)
|
|
179
|
+
.unwrap();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
fn insert_message(conn: &Connection, id: &str, session: &str, tc: i64, tu: i64, data: &Value) {
|
|
183
|
+
conn.execute(
|
|
184
|
+
"INSERT INTO message (id, session_id, time_created, time_updated, data) \
|
|
185
|
+
VALUES (?1,?2,?3,?4,?5)",
|
|
186
|
+
rusqlite::params![id, session, tc, tu, data.to_string()],
|
|
187
|
+
)
|
|
188
|
+
.unwrap();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/// Verbatim user-message metadata: note the NESTED `model` object, which is a
|
|
192
|
+
/// different shape from an assistant message's flat `modelID`.
|
|
193
|
+
fn user_message(conn: &Connection, id: &str, session: &str, tc: i64) {
|
|
194
|
+
insert_message(
|
|
195
|
+
conn,
|
|
196
|
+
id,
|
|
197
|
+
session,
|
|
198
|
+
tc,
|
|
199
|
+
tc,
|
|
200
|
+
&json!({"role":"user","time":{"created":tc},"agent":"build",
|
|
201
|
+
"model":{"providerID":"aikin","modelID":MODEL},"summary":{"diffs":[]}}),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
fn assistant_message(conn: &Connection, id: &str, session: &str, tc: i64, finish: Option<&str>) {
|
|
206
|
+
let mut data = json!({"parentID":USER_MSG,"role":"assistant","mode":"build","agent":"build",
|
|
207
|
+
"path":{"cwd":DIRECTORY,"root":"/"},"cost":0,
|
|
208
|
+
"tokens":{"total":15334,"input":3,"output":79,"reasoning":0,
|
|
209
|
+
"cache":{"write":45,"read":15207}},
|
|
210
|
+
"modelID":MODEL,"providerID":"aikin","time":{"created":tc}});
|
|
211
|
+
if let Some(f) = finish {
|
|
212
|
+
data["finish"] = json!(f);
|
|
213
|
+
}
|
|
214
|
+
insert_message(conn, id, session, tc, tc, &data);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
fn insert_part(
|
|
218
|
+
conn: &Connection,
|
|
219
|
+
id: &str,
|
|
220
|
+
message: &str,
|
|
221
|
+
session: &str,
|
|
222
|
+
tc: i64,
|
|
223
|
+
tu: i64,
|
|
224
|
+
data: &Value,
|
|
225
|
+
) {
|
|
226
|
+
conn.execute(
|
|
227
|
+
"INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) \
|
|
228
|
+
VALUES (?1,?2,?3,?4,?5,?6)",
|
|
229
|
+
rusqlite::params![id, message, session, tc, tu, data.to_string()],
|
|
230
|
+
)
|
|
231
|
+
.unwrap();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/// The UPDATE opencode issues when a tool finishes: same row, new `data`, new
|
|
235
|
+
/// `time_updated`.
|
|
236
|
+
fn update_part(conn: &Connection, id: &str, tu: i64, data: &Value) {
|
|
237
|
+
let n = conn
|
|
238
|
+
.execute(
|
|
239
|
+
"UPDATE part SET time_updated = ?2, data = ?3 WHERE id = ?1",
|
|
240
|
+
rusqlite::params![id, tu, data.to_string()],
|
|
241
|
+
)
|
|
242
|
+
.unwrap();
|
|
243
|
+
assert_eq!(n, 1, "the mutation must hit exactly the row under test");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
fn rowid_of(conn: &Connection, id: &str) -> i64 {
|
|
247
|
+
conn.query_row("SELECT rowid FROM part WHERE id = ?1", [id], |r| r.get(0))
|
|
248
|
+
.unwrap()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
fn text_part(text: &str) -> Value {
|
|
252
|
+
json!({"type":"text","text":text})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/// An assistant text part, which unlike a user's carries streaming timings.
|
|
256
|
+
fn streamed_text_part(text: &str, start: i64, end: Option<i64>) -> Value {
|
|
257
|
+
let mut time = json!({ "start": start });
|
|
258
|
+
if let Some(e) = end {
|
|
259
|
+
time["end"] = json!(e);
|
|
260
|
+
}
|
|
261
|
+
json!({"type":"text","text":text,"time":time})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
fn running_tool_part() -> Value {
|
|
265
|
+
json!({"type":"tool","tool":"bash","callID":CALL_ID,
|
|
266
|
+
"state":{"status":"running",
|
|
267
|
+
"input":{"command":"sleep 12 && echo SECOND_MARKER","timeout":20000},
|
|
268
|
+
"time":{"start":TOOL_CREATED_MS}}})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
fn completed_tool_part() -> Value {
|
|
272
|
+
json!({"type":"tool","tool":"bash","callID":CALL_ID,
|
|
273
|
+
"state":{"status":"completed",
|
|
274
|
+
"input":{"command":"sleep 12 && echo SECOND_MARKER","timeout":20000},
|
|
275
|
+
"output":"SECOND_MARKER\n",
|
|
276
|
+
"metadata":{"output":"SECOND_MARKER\n","exit":0,"truncated":false},
|
|
277
|
+
"title":"sleep 12 && echo SECOND_MARKER",
|
|
278
|
+
"time":{"start":TOOL_CREATED_MS,"end":TOOL_DONE_MS - 1}}})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
fn poll_at(db: &Path, watermark: i64, max_rows: u64) -> PollOutcome {
|
|
282
|
+
let conn = sqlitepoll::open_readonly(db).unwrap();
|
|
283
|
+
let req = PollRequest {
|
|
284
|
+
watermark,
|
|
285
|
+
max_rows,
|
|
286
|
+
environment: "local".into(),
|
|
287
|
+
agent_id: opencode::DEFAULT_AGENT_ID.into(),
|
|
288
|
+
};
|
|
289
|
+
(opencode::FORMAT.poll)(&conn, &req).unwrap()
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
fn poll_all(db: &Path) -> Vec<Value> {
|
|
293
|
+
poll_at(db, 0, 1000).events
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
fn of_type<'a>(events: &'a [Value], kind: &str) -> Vec<&'a Value> {
|
|
297
|
+
events.iter().filter(|e| e["type"] == kind).collect()
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/// A session with one finished turn: prompt, assistant answer, nothing pending.
|
|
301
|
+
fn seed_turn(conn: &Connection) {
|
|
302
|
+
insert_session(
|
|
303
|
+
conn,
|
|
304
|
+
SESSION,
|
|
305
|
+
DIRECTORY,
|
|
306
|
+
"tmp/probe-opencode",
|
|
307
|
+
PROMPT_MS - 1,
|
|
308
|
+
PROMPT_MS + 10,
|
|
309
|
+
);
|
|
310
|
+
user_message(conn, USER_MSG, SESSION, PROMPT_MS);
|
|
311
|
+
insert_part(
|
|
312
|
+
conn,
|
|
313
|
+
"prt_user_text",
|
|
314
|
+
USER_MSG,
|
|
315
|
+
SESSION,
|
|
316
|
+
PROMPT_MS,
|
|
317
|
+
PROMPT_MS,
|
|
318
|
+
&text_part("List the files in this directory"),
|
|
319
|
+
);
|
|
320
|
+
assistant_message(conn, ASSISTANT_MSG, SESSION, PROMPT_MS + 5, Some("stop"));
|
|
321
|
+
insert_part(
|
|
322
|
+
conn,
|
|
323
|
+
"prt_assistant_text",
|
|
324
|
+
ASSISTANT_MSG,
|
|
325
|
+
SESSION,
|
|
326
|
+
PROMPT_MS + 5,
|
|
327
|
+
PROMPT_MS + 10,
|
|
328
|
+
&streamed_text_part(
|
|
329
|
+
"calc.py, opencode.json, README.md",
|
|
330
|
+
PROMPT_MS + 5,
|
|
331
|
+
Some(PROMPT_MS + 9),
|
|
332
|
+
),
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── the mutation this whole source exists for ────────────────────────────
|
|
337
|
+
|
|
338
|
+
#[test]
|
|
339
|
+
fn a_part_that_mutates_from_running_to_completed_is_re_read() {
|
|
340
|
+
// Replays the observed lifetime of rowid 20: NEW at status=running with an
|
|
341
|
+
// empty output, then UPDATEd in place 12.0s later with the real result.
|
|
342
|
+
let dir = tmpdir("mutate");
|
|
343
|
+
let (db, conn) = make_db(&dir);
|
|
344
|
+
seed_turn(&conn);
|
|
345
|
+
assistant_message(
|
|
346
|
+
&conn,
|
|
347
|
+
"msg_tool_turn",
|
|
348
|
+
SESSION,
|
|
349
|
+
TOOL_CREATED_MS,
|
|
350
|
+
Some("tool-calls"),
|
|
351
|
+
);
|
|
352
|
+
insert_part(
|
|
353
|
+
&conn,
|
|
354
|
+
TOOL_PART,
|
|
355
|
+
"msg_tool_turn",
|
|
356
|
+
SESSION,
|
|
357
|
+
TOOL_CREATED_MS,
|
|
358
|
+
TOOL_RUNNING_MS,
|
|
359
|
+
&running_tool_part(),
|
|
360
|
+
);
|
|
361
|
+
touch_session(&conn, SESSION, TOOL_RUNNING_MS);
|
|
362
|
+
|
|
363
|
+
let first = poll_at(&db, 0, 1000);
|
|
364
|
+
let first_call = of_type(&first.events, "tool_use");
|
|
365
|
+
assert_eq!(
|
|
366
|
+
first_call.len(),
|
|
367
|
+
1,
|
|
368
|
+
"the call is known as soon as it starts"
|
|
369
|
+
);
|
|
370
|
+
assert!(
|
|
371
|
+
of_type(&first.events, "tool_result").is_empty(),
|
|
372
|
+
"the result does not exist yet — state.output is empty while running"
|
|
373
|
+
);
|
|
374
|
+
let call_before = first_call[0].clone();
|
|
375
|
+
let watermark = first.watermark;
|
|
376
|
+
|
|
377
|
+
// What a rowid watermark would have to work with.
|
|
378
|
+
let rowid_before = rowid_of(&conn, TOOL_PART);
|
|
379
|
+
let max_rowid: i64 = conn
|
|
380
|
+
.query_row("SELECT max(rowid) FROM part", [], |r| r.get(0))
|
|
381
|
+
.unwrap();
|
|
382
|
+
|
|
383
|
+
update_part(&conn, TOOL_PART, TOOL_DONE_MS, &completed_tool_part());
|
|
384
|
+
|
|
385
|
+
assert_eq!(
|
|
386
|
+
rowid_of(&conn, TOOL_PART),
|
|
387
|
+
rowid_before,
|
|
388
|
+
"opencode UPDATEs the row in place; its rowid never moves"
|
|
389
|
+
);
|
|
390
|
+
let appended: i64 = conn
|
|
391
|
+
.query_row(
|
|
392
|
+
"SELECT count(*) FROM part WHERE rowid > ?1",
|
|
393
|
+
[max_rowid],
|
|
394
|
+
|r| r.get(0),
|
|
395
|
+
)
|
|
396
|
+
.unwrap();
|
|
397
|
+
assert_eq!(
|
|
398
|
+
appended, 0,
|
|
399
|
+
"a rowid watermark has nothing new to hand back — every tool call in \
|
|
400
|
+
this product would ship with an empty result, forever, while looking \
|
|
401
|
+
like it works"
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
let second = poll_at(&db, watermark, 1000);
|
|
405
|
+
let results = of_type(&second.events, "tool_result");
|
|
406
|
+
assert_eq!(
|
|
407
|
+
results.len(),
|
|
408
|
+
1,
|
|
409
|
+
"the updated-at watermark re-reads the row"
|
|
410
|
+
);
|
|
411
|
+
assert_eq!(results[0]["output"], "SECOND_MARKER\n");
|
|
412
|
+
assert_eq!(results[0]["tool_call_id"], CALL_ID);
|
|
413
|
+
assert_eq!(results[0]["tool_name"], "bash");
|
|
414
|
+
|
|
415
|
+
// The re-read also re-emits the call. It must be byte-identical, or the
|
|
416
|
+
// server's content dedup cannot collapse it and every completed tool
|
|
417
|
+
// leaves two calls on the timeline.
|
|
418
|
+
let call_after = of_type(&second.events, "tool_use");
|
|
419
|
+
assert_eq!(call_after.len(), 1);
|
|
420
|
+
assert_eq!(
|
|
421
|
+
serde_json::to_string(call_after[0]).unwrap(),
|
|
422
|
+
serde_json::to_string(&call_before).unwrap(),
|
|
423
|
+
"a re-read call must hash the same as the first read"
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
fs::remove_dir_all(&dir).ok();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
#[test]
|
|
430
|
+
fn a_running_tool_part_yields_only_the_call() {
|
|
431
|
+
// The result is not merely missing from our output — it does not exist.
|
|
432
|
+
let dir = tmpdir("running");
|
|
433
|
+
let (db, conn) = make_db(&dir);
|
|
434
|
+
seed_turn(&conn);
|
|
435
|
+
assistant_message(
|
|
436
|
+
&conn,
|
|
437
|
+
"msg_tool_turn",
|
|
438
|
+
SESSION,
|
|
439
|
+
TOOL_CREATED_MS,
|
|
440
|
+
Some("tool-calls"),
|
|
441
|
+
);
|
|
442
|
+
insert_part(
|
|
443
|
+
&conn,
|
|
444
|
+
TOOL_PART,
|
|
445
|
+
"msg_tool_turn",
|
|
446
|
+
SESSION,
|
|
447
|
+
TOOL_CREATED_MS,
|
|
448
|
+
TOOL_RUNNING_MS,
|
|
449
|
+
&running_tool_part(),
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
let ev = poll_all(&db);
|
|
453
|
+
assert_eq!(of_type(&ev, "tool_use").len(), 1);
|
|
454
|
+
assert!(of_type(&ev, "tool_result").is_empty());
|
|
455
|
+
// The call is complete even so: a running tool's input is already parsed.
|
|
456
|
+
assert_eq!(
|
|
457
|
+
of_type(&ev, "tool_use")[0]["input"]["command"],
|
|
458
|
+
"sleep 12 && echo SECOND_MARKER"
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
fs::remove_dir_all(&dir).ok();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
#[test]
|
|
465
|
+
fn one_completed_tool_row_yields_the_call_and_its_result_in_order() {
|
|
466
|
+
// Call and result share ONE row — opencode never writes a second one — so
|
|
467
|
+
// both events come from the same read, and the index offset keeps them in
|
|
468
|
+
// order even when a tool finishes inside the millisecond it started.
|
|
469
|
+
let dir = tmpdir("bothlegs");
|
|
470
|
+
let (db, conn) = make_db(&dir);
|
|
471
|
+
seed_turn(&conn);
|
|
472
|
+
assistant_message(
|
|
473
|
+
&conn,
|
|
474
|
+
"msg_tool_turn",
|
|
475
|
+
SESSION,
|
|
476
|
+
TOOL_CREATED_MS,
|
|
477
|
+
Some("tool-calls"),
|
|
478
|
+
);
|
|
479
|
+
let instant = json!({"type":"tool","tool":"bash","callID":CALL_ID,
|
|
480
|
+
"state":{"status":"completed","input":{"command":"echo hi"},"output":"hi\n",
|
|
481
|
+
"time":{"start":TOOL_CREATED_MS,"end":TOOL_CREATED_MS}}});
|
|
482
|
+
insert_part(
|
|
483
|
+
&conn,
|
|
484
|
+
TOOL_PART,
|
|
485
|
+
"msg_tool_turn",
|
|
486
|
+
SESSION,
|
|
487
|
+
TOOL_CREATED_MS,
|
|
488
|
+
TOOL_CREATED_MS,
|
|
489
|
+
&instant,
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
let ev = poll_all(&db);
|
|
493
|
+
let call = of_type(&ev, "tool_use")[0];
|
|
494
|
+
let result = of_type(&ev, "tool_result")[0];
|
|
495
|
+
assert!(
|
|
496
|
+
call["timestamp"].as_str().unwrap() < result["timestamp"].as_str().unwrap(),
|
|
497
|
+
"a result that sorted before its own call is visibly wrong on a timeline"
|
|
498
|
+
);
|
|
499
|
+
assert_eq!(call["timestamp"], "2026-08-03T08:08:58.920000Z");
|
|
500
|
+
assert_eq!(result["timestamp"], "2026-08-03T08:08:58.920001Z");
|
|
501
|
+
assert_eq!(result["opencode_block_index"], 1);
|
|
502
|
+
assert_eq!(call["opencode_part_id"], TOOL_PART);
|
|
503
|
+
assert_eq!(result["opencode_part_id"], TOOL_PART);
|
|
504
|
+
|
|
505
|
+
fs::remove_dir_all(&dir).ok();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
#[test]
|
|
509
|
+
fn an_errored_tool_row_carries_a_non_empty_error() {
|
|
510
|
+
let dir = tmpdir("toolerr");
|
|
511
|
+
let (db, conn) = make_db(&dir);
|
|
512
|
+
seed_turn(&conn);
|
|
513
|
+
assistant_message(
|
|
514
|
+
&conn,
|
|
515
|
+
"msg_tool_turn",
|
|
516
|
+
SESSION,
|
|
517
|
+
TOOL_CREATED_MS,
|
|
518
|
+
Some("tool-calls"),
|
|
519
|
+
);
|
|
520
|
+
let failed = json!({"type":"tool","tool":"bash","callID":CALL_ID,
|
|
521
|
+
"state":{"status":"error","input":{"command":"false"},
|
|
522
|
+
"error":"Command failed with exit code 1",
|
|
523
|
+
"time":{"start":TOOL_CREATED_MS,"end":TOOL_DONE_MS}}});
|
|
524
|
+
insert_part(
|
|
525
|
+
&conn,
|
|
526
|
+
TOOL_PART,
|
|
527
|
+
"msg_tool_turn",
|
|
528
|
+
SESSION,
|
|
529
|
+
TOOL_CREATED_MS,
|
|
530
|
+
TOOL_DONE_MS,
|
|
531
|
+
&failed,
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
let ev = poll_all(&db);
|
|
535
|
+
let result = of_type(&ev, "tool_result")[0];
|
|
536
|
+
assert_eq!(result["error"], "Command failed with exit code 1");
|
|
537
|
+
assert_eq!(result["error_type"], "opencode_tool_error");
|
|
538
|
+
assert_eq!(
|
|
539
|
+
result["output"], "Command failed with exit code 1",
|
|
540
|
+
"an errored tool has no output; the message is the whole result"
|
|
541
|
+
);
|
|
542
|
+
assert_eq!(result["opencode_tool_status"], "error");
|
|
543
|
+
|
|
544
|
+
fs::remove_dir_all(&dir).ok();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
#[test]
|
|
548
|
+
fn a_pending_tool_row_reports_nothing_until_its_input_exists() {
|
|
549
|
+
// `pending` is the call before its arguments have finished streaming.
|
|
550
|
+
// Shipping it would put a blank call in the product, and the row comes
|
|
551
|
+
// back the moment it moves to `running`.
|
|
552
|
+
let dir = tmpdir("pending");
|
|
553
|
+
let (db, conn) = make_db(&dir);
|
|
554
|
+
seed_turn(&conn);
|
|
555
|
+
assistant_message(
|
|
556
|
+
&conn,
|
|
557
|
+
"msg_tool_turn",
|
|
558
|
+
SESSION,
|
|
559
|
+
TOOL_CREATED_MS,
|
|
560
|
+
Some("tool-calls"),
|
|
561
|
+
);
|
|
562
|
+
insert_part(
|
|
563
|
+
&conn,
|
|
564
|
+
TOOL_PART,
|
|
565
|
+
"msg_tool_turn",
|
|
566
|
+
SESSION,
|
|
567
|
+
TOOL_CREATED_MS,
|
|
568
|
+
TOOL_CREATED_MS,
|
|
569
|
+
&json!({"type":"tool","tool":"bash","callID":CALL_ID,"state":{"status":"pending"}}),
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
let ev = poll_all(&db);
|
|
573
|
+
assert!(of_type(&ev, "tool_use").is_empty());
|
|
574
|
+
|
|
575
|
+
update_part(&conn, TOOL_PART, TOOL_RUNNING_MS, &running_tool_part());
|
|
576
|
+
let ev = poll_all(&db);
|
|
577
|
+
assert_eq!(
|
|
578
|
+
of_type(&ev, "tool_use").len(),
|
|
579
|
+
1,
|
|
580
|
+
"it ships once it is real"
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
fs::remove_dir_all(&dir).ok();
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ── text parts ───────────────────────────────────────────────────────────
|
|
587
|
+
|
|
588
|
+
#[test]
|
|
589
|
+
fn a_user_part_becomes_a_request_and_an_assistant_part_a_response() {
|
|
590
|
+
// A part carries no role at all — it comes from the joined message row.
|
|
591
|
+
let dir = tmpdir("roles");
|
|
592
|
+
let (db, conn) = make_db(&dir);
|
|
593
|
+
seed_turn(&conn);
|
|
594
|
+
|
|
595
|
+
let ev = poll_all(&db);
|
|
596
|
+
let req = of_type(&ev, "model_request");
|
|
597
|
+
let resp = of_type(&ev, "model_response");
|
|
598
|
+
assert_eq!(req.len(), 1);
|
|
599
|
+
assert_eq!(resp.len(), 1);
|
|
600
|
+
assert_eq!(
|
|
601
|
+
req[0]["messages"][0]["content"],
|
|
602
|
+
"List the files in this directory"
|
|
603
|
+
);
|
|
604
|
+
assert_eq!(req[0]["messages"][0]["role"], "user");
|
|
605
|
+
assert_eq!(req[0]["opencode_cwd"], DIRECTORY);
|
|
606
|
+
// The model comes from the user message's NESTED `model` object; the
|
|
607
|
+
// assistant's from its flat `modelID`. Both must resolve or half the rows
|
|
608
|
+
// render as an empty summary.
|
|
609
|
+
assert_eq!(req[0]["model"], MODEL);
|
|
610
|
+
assert_eq!(resp[0]["model"], MODEL);
|
|
611
|
+
assert_eq!(resp[0]["content"], "calc.py, opencode.json, README.md");
|
|
612
|
+
assert_eq!(resp[0]["role"], "assistant");
|
|
613
|
+
assert_eq!(resp[0]["opencode_provider"], "aikin");
|
|
614
|
+
assert_eq!(resp[0]["opencode_message_id"], ASSISTANT_MSG);
|
|
615
|
+
|
|
616
|
+
fs::remove_dir_all(&dir).ok();
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
#[test]
|
|
620
|
+
fn a_still_streaming_text_part_is_withheld_until_it_settles() {
|
|
621
|
+
// An assistant text part is written empty and grown token by token. Without
|
|
622
|
+
// the `time.end` gate, one poll per few seconds of a long answer leaves a
|
|
623
|
+
// row per poll in the product, each a longer prefix of the same text.
|
|
624
|
+
let dir = tmpdir("streaming");
|
|
625
|
+
let (db, conn) = make_db(&dir);
|
|
626
|
+
insert_session(&conn, SESSION, DIRECTORY, "", PROMPT_MS - 1, PROMPT_MS);
|
|
627
|
+
user_message(&conn, USER_MSG, SESSION, PROMPT_MS);
|
|
628
|
+
insert_part(
|
|
629
|
+
&conn,
|
|
630
|
+
"prt_u",
|
|
631
|
+
USER_MSG,
|
|
632
|
+
SESSION,
|
|
633
|
+
PROMPT_MS,
|
|
634
|
+
PROMPT_MS,
|
|
635
|
+
&text_part("go"),
|
|
636
|
+
);
|
|
637
|
+
assistant_message(&conn, ASSISTANT_MSG, SESSION, PROMPT_MS + 5, None);
|
|
638
|
+
insert_part(
|
|
639
|
+
&conn,
|
|
640
|
+
"prt_stream",
|
|
641
|
+
ASSISTANT_MSG,
|
|
642
|
+
SESSION,
|
|
643
|
+
PROMPT_MS + 5,
|
|
644
|
+
PROMPT_MS + 6,
|
|
645
|
+
&streamed_text_part("The comm", PROMPT_MS + 5, None),
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
let first = poll_at(&db, 0, 1000);
|
|
649
|
+
assert!(
|
|
650
|
+
of_type(&first.events, "model_response").is_empty(),
|
|
651
|
+
"a half-written answer must not ship"
|
|
652
|
+
);
|
|
653
|
+
// A user prompt has no `time` object at all and is settled on arrival.
|
|
654
|
+
assert_eq!(of_type(&first.events, "model_request").len(), 1);
|
|
655
|
+
|
|
656
|
+
update_part(
|
|
657
|
+
&conn,
|
|
658
|
+
"prt_stream",
|
|
659
|
+
PROMPT_MS + 40,
|
|
660
|
+
&streamed_text_part(
|
|
661
|
+
"The command completed successfully.",
|
|
662
|
+
PROMPT_MS + 5,
|
|
663
|
+
Some(PROMPT_MS + 39),
|
|
664
|
+
),
|
|
665
|
+
);
|
|
666
|
+
let second = poll_at(&db, first.watermark, 1000);
|
|
667
|
+
assert_eq!(
|
|
668
|
+
of_type(&second.events, "model_response")[0]["content"],
|
|
669
|
+
"The command completed successfully."
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
fs::remove_dir_all(&dir).ok();
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
#[test]
|
|
676
|
+
fn step_and_unknown_part_types_emit_nothing() {
|
|
677
|
+
// `step-start`/`step-finish` bracket every single turn — they outnumber
|
|
678
|
+
// content parts on the real capture 10:24. An unknown future type must be
|
|
679
|
+
// ignored the same way rather than breaking the poll.
|
|
680
|
+
let dir = tmpdir("steps");
|
|
681
|
+
let (db, conn) = make_db(&dir);
|
|
682
|
+
seed_turn(&conn);
|
|
683
|
+
insert_part(
|
|
684
|
+
&conn,
|
|
685
|
+
"prt_ss",
|
|
686
|
+
ASSISTANT_MSG,
|
|
687
|
+
SESSION,
|
|
688
|
+
PROMPT_MS + 11,
|
|
689
|
+
PROMPT_MS + 11,
|
|
690
|
+
&json!({"type":"step-start"}),
|
|
691
|
+
);
|
|
692
|
+
insert_part(
|
|
693
|
+
&conn,
|
|
694
|
+
"prt_sf",
|
|
695
|
+
ASSISTANT_MSG,
|
|
696
|
+
SESSION,
|
|
697
|
+
PROMPT_MS + 12,
|
|
698
|
+
PROMPT_MS + 12,
|
|
699
|
+
&json!({"type":"step-finish","reason":"stop","tokens":{"total":15373},"cost":0}),
|
|
700
|
+
);
|
|
701
|
+
insert_part(
|
|
702
|
+
&conn,
|
|
703
|
+
"prt_future",
|
|
704
|
+
ASSISTANT_MSG,
|
|
705
|
+
SESSION,
|
|
706
|
+
PROMPT_MS + 13,
|
|
707
|
+
PROMPT_MS + 13,
|
|
708
|
+
&json!({"type":"some-future-type","payload":{}}),
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
let out = poll_at(&db, 0, 1000);
|
|
712
|
+
assert_eq!(out.rows_seen, 6, "the rows are read");
|
|
713
|
+
assert_eq!(
|
|
714
|
+
out.events
|
|
715
|
+
.iter()
|
|
716
|
+
.filter(|e| e["type"] == "model_request" || e["type"] == "model_response")
|
|
717
|
+
.count(),
|
|
718
|
+
2,
|
|
719
|
+
"…and only the two content parts become events"
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
fs::remove_dir_all(&dir).ok();
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// ── agent id: directory, never path ──────────────────────────────────────
|
|
726
|
+
|
|
727
|
+
#[test]
|
|
728
|
+
fn the_agent_id_comes_from_the_directory_and_never_from_path() {
|
|
729
|
+
// `session.path` is relative to a git worktree and is the EMPTY STRING at
|
|
730
|
+
// the repository root — the case that matters most. Here it holds a value
|
|
731
|
+
// that would produce a plausible-looking wrong answer.
|
|
732
|
+
let dir = tmpdir("agentid");
|
|
733
|
+
let (db, conn) = make_db(&dir);
|
|
734
|
+
insert_session(
|
|
735
|
+
&conn,
|
|
736
|
+
SESSION,
|
|
737
|
+
"/home/u/failproofai",
|
|
738
|
+
"some/other/worktree/leaf",
|
|
739
|
+
PROMPT_MS - 1,
|
|
740
|
+
PROMPT_MS + 10,
|
|
741
|
+
);
|
|
742
|
+
user_message(&conn, USER_MSG, SESSION, PROMPT_MS);
|
|
743
|
+
insert_part(
|
|
744
|
+
&conn,
|
|
745
|
+
"prt_u",
|
|
746
|
+
USER_MSG,
|
|
747
|
+
SESSION,
|
|
748
|
+
PROMPT_MS,
|
|
749
|
+
PROMPT_MS,
|
|
750
|
+
&text_part("hi"),
|
|
751
|
+
);
|
|
752
|
+
|
|
753
|
+
let ev = poll_all(&db);
|
|
754
|
+
assert!(!ev.is_empty());
|
|
755
|
+
for e in &ev {
|
|
756
|
+
assert_eq!(e["agent_id"], "opencode-failproofai");
|
|
757
|
+
}
|
|
758
|
+
let dumped = serde_json::to_string(&ev).unwrap();
|
|
759
|
+
assert!(
|
|
760
|
+
!dumped.contains("worktree"),
|
|
761
|
+
"session.path must not reach the event stream at all"
|
|
762
|
+
);
|
|
763
|
+
|
|
764
|
+
fs::remove_dir_all(&dir).ok();
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
#[test]
|
|
768
|
+
fn an_empty_path_at_a_git_root_still_resolves_the_project() {
|
|
769
|
+
// The exact live observation: a session started at a git worktree root has
|
|
770
|
+
// `path = ""`. Anything deriving the project from it produces nothing.
|
|
771
|
+
let dir = tmpdir("gitroot");
|
|
772
|
+
let (db, conn) = make_db(&dir);
|
|
773
|
+
insert_session(
|
|
774
|
+
&conn,
|
|
775
|
+
SESSION,
|
|
776
|
+
"/tmp/probe-opencode-git",
|
|
777
|
+
"",
|
|
778
|
+
PROMPT_MS - 1,
|
|
779
|
+
PROMPT_MS + 10,
|
|
780
|
+
);
|
|
781
|
+
user_message(&conn, USER_MSG, SESSION, PROMPT_MS);
|
|
782
|
+
insert_part(
|
|
783
|
+
&conn,
|
|
784
|
+
"prt_u",
|
|
785
|
+
USER_MSG,
|
|
786
|
+
SESSION,
|
|
787
|
+
PROMPT_MS,
|
|
788
|
+
PROMPT_MS,
|
|
789
|
+
&text_part("GITPROBE"),
|
|
790
|
+
);
|
|
791
|
+
|
|
792
|
+
assert_eq!(poll_all(&db)[0]["agent_id"], "opencode-probe-opencode-git");
|
|
793
|
+
|
|
794
|
+
fs::remove_dir_all(&dir).ok();
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
#[test]
|
|
798
|
+
fn a_directory_with_no_usable_project_falls_back_to_the_configured_id() {
|
|
799
|
+
let dir = tmpdir("rootdir");
|
|
800
|
+
let (db, conn) = make_db(&dir);
|
|
801
|
+
insert_session(&conn, SESSION, "/", "", PROMPT_MS - 1, PROMPT_MS + 10);
|
|
802
|
+
user_message(&conn, USER_MSG, SESSION, PROMPT_MS);
|
|
803
|
+
insert_part(
|
|
804
|
+
&conn,
|
|
805
|
+
"prt_u",
|
|
806
|
+
USER_MSG,
|
|
807
|
+
SESSION,
|
|
808
|
+
PROMPT_MS,
|
|
809
|
+
PROMPT_MS,
|
|
810
|
+
&text_part("hi"),
|
|
811
|
+
);
|
|
812
|
+
|
|
813
|
+
assert_eq!(poll_all(&db)[0]["agent_id"], opencode::DEFAULT_AGENT_ID);
|
|
814
|
+
|
|
815
|
+
fs::remove_dir_all(&dir).ok();
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
#[test]
|
|
819
|
+
fn the_agent_id_is_sanitized_the_same_way_claudes_is() {
|
|
820
|
+
// The two run over the same project directories on the same machine; a
|
|
821
|
+
// project that sanitized differently under each would appear as two
|
|
822
|
+
// unrelated agents.
|
|
823
|
+
assert_eq!(transform::sanitize_id_part("my project!"), "my-project");
|
|
824
|
+
assert_eq!(transform::sanitize_id_part("--a--b--"), "a-b");
|
|
825
|
+
assert_eq!(
|
|
826
|
+
transform::sanitize_id_part("keep.dots_and_underscores"),
|
|
827
|
+
"keep.dots_and_underscores"
|
|
828
|
+
);
|
|
829
|
+
assert_eq!(transform::sanitize_id_part(&"x".repeat(80)).len(), 48);
|
|
830
|
+
assert_eq!(
|
|
831
|
+
transform::agent_id_from_directory("/home/u/repo/"),
|
|
832
|
+
Some("opencode-repo".to_string())
|
|
833
|
+
);
|
|
834
|
+
assert_eq!(transform::agent_id_from_directory("/"), None);
|
|
835
|
+
assert_eq!(transform::agent_id_from_directory(""), None);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ── session bracketing ───────────────────────────────────────────────────
|
|
839
|
+
|
|
840
|
+
#[test]
|
|
841
|
+
fn a_finished_session_is_bracketed_by_a_start_and_an_end() {
|
|
842
|
+
let dir = tmpdir("bracket");
|
|
843
|
+
let (db, conn) = make_db(&dir);
|
|
844
|
+
seed_turn(&conn);
|
|
845
|
+
|
|
846
|
+
let ev = poll_all(&db);
|
|
847
|
+
let start = of_type(&ev, "agent_start");
|
|
848
|
+
let end = of_type(&ev, "agent_end");
|
|
849
|
+
assert_eq!(start.len(), 1);
|
|
850
|
+
assert_eq!(end.len(), 1);
|
|
851
|
+
assert_eq!(start[0]["goal"], "List the files in this directory");
|
|
852
|
+
assert_eq!(start[0]["opencode_cwd"], DIRECTORY);
|
|
853
|
+
assert_eq!(start[0]["opencode_version"], "1.18.11");
|
|
854
|
+
assert_eq!(start[0]["opencode_slug"], "clever-nebula");
|
|
855
|
+
assert_eq!(start[0]["opencode_part_id"], SESSION);
|
|
856
|
+
// Session totals, namespaced: a consumer that summed generic
|
|
857
|
+
// `input_tokens` across events would count every turn once per end.
|
|
858
|
+
assert_eq!(end[0]["opencode_tokens"]["input"], 13);
|
|
859
|
+
assert_eq!(end[0]["opencode_tokens"]["cache_read"], 75048);
|
|
860
|
+
assert!(end[0].get("input_tokens").is_none());
|
|
861
|
+
assert!(
|
|
862
|
+
start[0]["timestamp"].as_str().unwrap() < end[0]["timestamp"].as_str().unwrap(),
|
|
863
|
+
"the start must precede the end"
|
|
864
|
+
);
|
|
865
|
+
// Index 999 puts the end after content sharing its millisecond.
|
|
866
|
+
assert!(end[0]["timestamp"].as_str().unwrap().ends_with("999Z"));
|
|
867
|
+
|
|
868
|
+
fs::remove_dir_all(&dir).ok();
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
#[test]
|
|
872
|
+
fn a_session_mid_turn_gets_a_start_but_no_end() {
|
|
873
|
+
// `finish:"tool-calls"` means the assistant stopped only to run tools and
|
|
874
|
+
// another message is coming. Ending the session there would show it as
|
|
875
|
+
// over while it is still working.
|
|
876
|
+
let dir = tmpdir("midturn");
|
|
877
|
+
let (db, conn) = make_db(&dir);
|
|
878
|
+
insert_session(
|
|
879
|
+
&conn,
|
|
880
|
+
SESSION,
|
|
881
|
+
DIRECTORY,
|
|
882
|
+
"",
|
|
883
|
+
PROMPT_MS - 1,
|
|
884
|
+
TOOL_RUNNING_MS,
|
|
885
|
+
);
|
|
886
|
+
user_message(&conn, USER_MSG, SESSION, PROMPT_MS);
|
|
887
|
+
insert_part(
|
|
888
|
+
&conn,
|
|
889
|
+
"prt_u",
|
|
890
|
+
USER_MSG,
|
|
891
|
+
SESSION,
|
|
892
|
+
PROMPT_MS,
|
|
893
|
+
PROMPT_MS,
|
|
894
|
+
&text_part("run it"),
|
|
895
|
+
);
|
|
896
|
+
assistant_message(
|
|
897
|
+
&conn,
|
|
898
|
+
ASSISTANT_MSG,
|
|
899
|
+
SESSION,
|
|
900
|
+
TOOL_CREATED_MS,
|
|
901
|
+
Some("tool-calls"),
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
let ev = poll_all(&db);
|
|
905
|
+
assert_eq!(of_type(&ev, "agent_start").len(), 1);
|
|
906
|
+
assert!(of_type(&ev, "agent_end").is_empty());
|
|
907
|
+
|
|
908
|
+
// A user message as the latest one is mid-turn too — the assistant has not
|
|
909
|
+
// even started.
|
|
910
|
+
let dir2 = tmpdir("midturn2");
|
|
911
|
+
let (db2, conn2) = make_db(&dir2);
|
|
912
|
+
insert_session(&conn2, SESSION, DIRECTORY, "", PROMPT_MS - 1, PROMPT_MS);
|
|
913
|
+
user_message(&conn2, USER_MSG, SESSION, PROMPT_MS);
|
|
914
|
+
insert_part(
|
|
915
|
+
&conn2,
|
|
916
|
+
"prt_u",
|
|
917
|
+
USER_MSG,
|
|
918
|
+
SESSION,
|
|
919
|
+
PROMPT_MS,
|
|
920
|
+
PROMPT_MS,
|
|
921
|
+
&text_part("run it"),
|
|
922
|
+
);
|
|
923
|
+
assert!(of_type(&poll_all(&db2), "agent_end").is_empty());
|
|
924
|
+
|
|
925
|
+
fs::remove_dir_all(&dir).ok();
|
|
926
|
+
fs::remove_dir_all(&dir2).ok();
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
#[test]
|
|
930
|
+
fn a_session_that_was_opened_and_never_used_emits_nothing() {
|
|
931
|
+
// opencode inserts the session row the moment a session is selected. An
|
|
932
|
+
// `agent_start` there would put an empty session in the product — and
|
|
933
|
+
// would risk a SECOND one later once the first prompt gave it a goal.
|
|
934
|
+
let dir = tmpdir("unused");
|
|
935
|
+
let (db, conn) = make_db(&dir);
|
|
936
|
+
insert_session(&conn, SESSION, DIRECTORY, "", PROMPT_MS - 1, PROMPT_MS - 1);
|
|
937
|
+
|
|
938
|
+
let out = poll_at(&db, 0, 1000);
|
|
939
|
+
assert_eq!(out.rows_seen, 1, "the row is read");
|
|
940
|
+
assert!(out.events.is_empty(), "…and produces nothing");
|
|
941
|
+
assert_eq!(out.watermark, PROMPT_MS - 1, "the watermark still advances");
|
|
942
|
+
|
|
943
|
+
fs::remove_dir_all(&dir).ok();
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
#[test]
|
|
947
|
+
fn the_start_is_byte_stable_across_the_many_re_reads_of_a_session_row() {
|
|
948
|
+
// A session row moves on every turn, so it is re-read constantly. The
|
|
949
|
+
// start must hash the same each time or one session leaves a trail of
|
|
950
|
+
// starts. `session.title` is the trap: opencode backfills an LLM-generated
|
|
951
|
+
// one seconds in, so it is deliberately not carried.
|
|
952
|
+
let dir = tmpdir("stablestart");
|
|
953
|
+
let (db, conn) = make_db(&dir);
|
|
954
|
+
seed_turn(&conn);
|
|
955
|
+
let first = of_type(&poll_all(&db), "agent_start")[0].clone();
|
|
956
|
+
|
|
957
|
+
conn.execute(
|
|
958
|
+
"UPDATE session SET title = 'A newly generated title', time_updated = ?1, \
|
|
959
|
+
tokens_output = 999 WHERE id = ?2",
|
|
960
|
+
rusqlite::params![PROMPT_MS + 5000, SESSION],
|
|
961
|
+
)
|
|
962
|
+
.unwrap();
|
|
963
|
+
let second = of_type(&poll_all(&db), "agent_start")[0].clone();
|
|
964
|
+
|
|
965
|
+
assert_eq!(
|
|
966
|
+
serde_json::to_string(&first).unwrap(),
|
|
967
|
+
serde_json::to_string(&second).unwrap(),
|
|
968
|
+
"a re-read start must be byte-identical so the server collapses it"
|
|
969
|
+
);
|
|
970
|
+
|
|
971
|
+
fs::remove_dir_all(&dir).ok();
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// ── ordering, watermark, limits ──────────────────────────────────────────
|
|
975
|
+
|
|
976
|
+
#[test]
|
|
977
|
+
fn rows_come_back_in_time_updated_order_not_insertion_order() {
|
|
978
|
+
// The engine orders on the same column the watermark advances along; a
|
|
979
|
+
// batch ordered any other way could ship a row and then move the watermark
|
|
980
|
+
// past an earlier one.
|
|
981
|
+
let dir = tmpdir("order");
|
|
982
|
+
let (db, conn) = make_db(&dir);
|
|
983
|
+
seed_turn(&conn);
|
|
984
|
+
// Inserted newest-first, and the LATER row also has the LOWER rowid.
|
|
985
|
+
insert_part(
|
|
986
|
+
&conn,
|
|
987
|
+
"prt_z_late",
|
|
988
|
+
ASSISTANT_MSG,
|
|
989
|
+
SESSION,
|
|
990
|
+
PROMPT_MS + 50,
|
|
991
|
+
PROMPT_MS + 90,
|
|
992
|
+
&streamed_text_part("late", PROMPT_MS + 50, Some(PROMPT_MS + 89)),
|
|
993
|
+
);
|
|
994
|
+
insert_part(
|
|
995
|
+
&conn,
|
|
996
|
+
"prt_a_early",
|
|
997
|
+
ASSISTANT_MSG,
|
|
998
|
+
SESSION,
|
|
999
|
+
PROMPT_MS + 20,
|
|
1000
|
+
PROMPT_MS + 30,
|
|
1001
|
+
&streamed_text_part("early", PROMPT_MS + 20, Some(PROMPT_MS + 29)),
|
|
1002
|
+
);
|
|
1003
|
+
|
|
1004
|
+
let ev = poll_all(&db);
|
|
1005
|
+
let texts: Vec<&str> = of_type(&ev, "model_response")
|
|
1006
|
+
.iter()
|
|
1007
|
+
.map(|e| e["content"].as_str().unwrap())
|
|
1008
|
+
.collect();
|
|
1009
|
+
assert_eq!(
|
|
1010
|
+
texts,
|
|
1011
|
+
vec!["calc.py, opencode.json, README.md", "early", "late"]
|
|
1012
|
+
);
|
|
1013
|
+
|
|
1014
|
+
// The batch is ordered by the column the watermark advances along —
|
|
1015
|
+
// `time_updated` — with the session row ahead of parts that share its
|
|
1016
|
+
// millisecond. Deliberately NOT by event timestamp: an `agent_start` is
|
|
1017
|
+
// backdated to `session.time_created` and a `tool_result` post-dated to
|
|
1018
|
+
// `state.time.end`, so the spooled order is not monotonic in time and the
|
|
1019
|
+
// server sorts on the timestamp anyway.
|
|
1020
|
+
let origins: Vec<&str> = ev
|
|
1021
|
+
.iter()
|
|
1022
|
+
.map(|e| e["opencode_part_id"].as_str().unwrap())
|
|
1023
|
+
.collect();
|
|
1024
|
+
assert_eq!(
|
|
1025
|
+
origins,
|
|
1026
|
+
vec![
|
|
1027
|
+
"prt_user_text", // time_updated PROMPT_MS
|
|
1028
|
+
SESSION, // …+10, session before part on a tie
|
|
1029
|
+
SESSION,
|
|
1030
|
+
"prt_assistant_text", // …+10
|
|
1031
|
+
"prt_a_early", // …+30, despite the higher rowid
|
|
1032
|
+
"prt_z_late", // …+90
|
|
1033
|
+
]
|
|
1034
|
+
);
|
|
1035
|
+
|
|
1036
|
+
fs::remove_dir_all(&dir).ok();
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
#[test]
|
|
1040
|
+
fn the_watermark_advances_and_a_second_poll_returns_nothing() {
|
|
1041
|
+
let dir = tmpdir("watermark");
|
|
1042
|
+
let (db, conn) = make_db(&dir);
|
|
1043
|
+
seed_turn(&conn);
|
|
1044
|
+
|
|
1045
|
+
let first = poll_at(&db, 0, 1000);
|
|
1046
|
+
assert!(!first.events.is_empty());
|
|
1047
|
+
assert_eq!(first.watermark, PROMPT_MS + 10);
|
|
1048
|
+
|
|
1049
|
+
let second = poll_at(&db, first.watermark, 1000);
|
|
1050
|
+
assert!(second.events.is_empty());
|
|
1051
|
+
assert_eq!(second.rows_seen, 0);
|
|
1052
|
+
assert_eq!(second.watermark, first.watermark, "an idle poll holds it");
|
|
1053
|
+
assert!(!second.more);
|
|
1054
|
+
|
|
1055
|
+
fs::remove_dir_all(&dir).ok();
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
#[test]
|
|
1059
|
+
fn hitting_the_row_limit_reports_more_so_a_backlog_drains() {
|
|
1060
|
+
let dir = tmpdir("limit");
|
|
1061
|
+
let (db, conn) = make_db(&dir);
|
|
1062
|
+
seed_turn(&conn);
|
|
1063
|
+
for i in 0..10 {
|
|
1064
|
+
let ts = PROMPT_MS + 100 + i * 10;
|
|
1065
|
+
insert_part(
|
|
1066
|
+
&conn,
|
|
1067
|
+
&format!("prt_bulk_{i:02}"),
|
|
1068
|
+
ASSISTANT_MSG,
|
|
1069
|
+
SESSION,
|
|
1070
|
+
ts,
|
|
1071
|
+
ts,
|
|
1072
|
+
&streamed_text_part(&format!("chunk {i}"), ts, Some(ts)),
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
let out = poll_at(&db, 0, 3);
|
|
1077
|
+
assert!(out.more, "a backlog must drain rather than trickle");
|
|
1078
|
+
assert!(out.rows_seen > 3);
|
|
1079
|
+
assert!(out.watermark < PROMPT_MS + 190);
|
|
1080
|
+
|
|
1081
|
+
// Drain the rest exactly the way the engine does.
|
|
1082
|
+
let mut wm = out.watermark;
|
|
1083
|
+
let mut seen = out.events.len();
|
|
1084
|
+
for _ in 0..20 {
|
|
1085
|
+
let next = poll_at(&db, wm, 3);
|
|
1086
|
+
wm = next.watermark;
|
|
1087
|
+
seen += next.events.len();
|
|
1088
|
+
if !next.more {
|
|
1089
|
+
break;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
assert_eq!(poll_at(&db, wm, 3).events.len(), 0, "the drain terminates");
|
|
1093
|
+
assert_eq!(
|
|
1094
|
+
seen,
|
|
1095
|
+
poll_at(&db, 0, 1000).events.len(),
|
|
1096
|
+
"draining in pages ships exactly what one big poll would"
|
|
1097
|
+
);
|
|
1098
|
+
|
|
1099
|
+
fs::remove_dir_all(&dir).ok();
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
#[test]
|
|
1103
|
+
fn the_limit_never_cuts_a_millisecond_in_half() {
|
|
1104
|
+
// `time_updated` is milliseconds and rows routinely share one. Cutting
|
|
1105
|
+
// between two of them and then asking for `time_updated > :last` skips the
|
|
1106
|
+
// rest of that millisecond forever — a silent, permanent hole.
|
|
1107
|
+
let dir = tmpdir("msgroup");
|
|
1108
|
+
let (db, conn) = make_db(&dir);
|
|
1109
|
+
insert_session(&conn, SESSION, DIRECTORY, "", PROMPT_MS - 1, PROMPT_MS - 1);
|
|
1110
|
+
user_message(&conn, USER_MSG, SESSION, PROMPT_MS);
|
|
1111
|
+
insert_part(
|
|
1112
|
+
&conn,
|
|
1113
|
+
"prt_u",
|
|
1114
|
+
USER_MSG,
|
|
1115
|
+
SESSION,
|
|
1116
|
+
PROMPT_MS,
|
|
1117
|
+
PROMPT_MS,
|
|
1118
|
+
&text_part("go"),
|
|
1119
|
+
);
|
|
1120
|
+
assistant_message(&conn, ASSISTANT_MSG, SESSION, PROMPT_MS + 5, Some("stop"));
|
|
1121
|
+
// Three parts sharing ONE millisecond, straddling a limit of 2.
|
|
1122
|
+
for i in 0..3 {
|
|
1123
|
+
insert_part(
|
|
1124
|
+
&conn,
|
|
1125
|
+
&format!("prt_same_{i}"),
|
|
1126
|
+
ASSISTANT_MSG,
|
|
1127
|
+
SESSION,
|
|
1128
|
+
PROMPT_MS + 20,
|
|
1129
|
+
PROMPT_MS + 20,
|
|
1130
|
+
&streamed_text_part(&format!("same {i}"), PROMPT_MS + 20, Some(PROMPT_MS + 20)),
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
let first = poll_at(&db, 0, 2);
|
|
1135
|
+
assert!(first.more);
|
|
1136
|
+
assert!(
|
|
1137
|
+
first.watermark < PROMPT_MS + 20,
|
|
1138
|
+
"the watermark stops BEFORE the straddled millisecond, not inside it"
|
|
1139
|
+
);
|
|
1140
|
+
|
|
1141
|
+
let mut texts: Vec<String> = first
|
|
1142
|
+
.events
|
|
1143
|
+
.iter()
|
|
1144
|
+
.filter(|e| e["type"] == "model_response")
|
|
1145
|
+
.map(|e| e["content"].as_str().unwrap().to_string())
|
|
1146
|
+
.collect();
|
|
1147
|
+
let second = poll_at(&db, first.watermark, 2);
|
|
1148
|
+
texts.extend(
|
|
1149
|
+
second
|
|
1150
|
+
.events
|
|
1151
|
+
.iter()
|
|
1152
|
+
.filter(|e| e["type"] == "model_response")
|
|
1153
|
+
.map(|e| e["content"].as_str().unwrap().to_string()),
|
|
1154
|
+
);
|
|
1155
|
+
texts.sort();
|
|
1156
|
+
assert_eq!(texts, vec!["same 0", "same 1", "same 2"], "no row is lost");
|
|
1157
|
+
|
|
1158
|
+
fs::remove_dir_all(&dir).ok();
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
#[test]
|
|
1162
|
+
fn a_millisecond_bigger_than_the_whole_limit_still_advances() {
|
|
1163
|
+
// The degenerate case of the rule above: trimming to a millisecond
|
|
1164
|
+
// boundary would leave nothing to ship and the watermark could never move
|
|
1165
|
+
// past it. That millisecond is taken whole instead.
|
|
1166
|
+
let dir = tmpdir("degenerate");
|
|
1167
|
+
let (db, conn) = make_db(&dir);
|
|
1168
|
+
insert_session(&conn, SESSION, DIRECTORY, "", PROMPT_MS - 1, PROMPT_MS - 1);
|
|
1169
|
+
user_message(&conn, USER_MSG, SESSION, PROMPT_MS);
|
|
1170
|
+
insert_part(
|
|
1171
|
+
&conn,
|
|
1172
|
+
"prt_u",
|
|
1173
|
+
USER_MSG,
|
|
1174
|
+
SESSION,
|
|
1175
|
+
PROMPT_MS,
|
|
1176
|
+
PROMPT_MS,
|
|
1177
|
+
&text_part("go"),
|
|
1178
|
+
);
|
|
1179
|
+
assistant_message(&conn, ASSISTANT_MSG, SESSION, PROMPT_MS + 5, Some("stop"));
|
|
1180
|
+
for i in 0..4 {
|
|
1181
|
+
insert_part(
|
|
1182
|
+
&conn,
|
|
1183
|
+
&format!("prt_burst_{i}"),
|
|
1184
|
+
ASSISTANT_MSG,
|
|
1185
|
+
SESSION,
|
|
1186
|
+
PROMPT_MS + 20,
|
|
1187
|
+
PROMPT_MS + 20,
|
|
1188
|
+
&streamed_text_part(&format!("burst {i}"), PROMPT_MS + 20, Some(PROMPT_MS + 20)),
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Walk up to the burst with a limit of one, then hit it.
|
|
1193
|
+
let mut wm = 0;
|
|
1194
|
+
let mut responses = 0;
|
|
1195
|
+
for _ in 0..12 {
|
|
1196
|
+
let out = poll_at(&db, wm, 1);
|
|
1197
|
+
responses += out
|
|
1198
|
+
.events
|
|
1199
|
+
.iter()
|
|
1200
|
+
.filter(|e| e["type"] == "model_response")
|
|
1201
|
+
.count();
|
|
1202
|
+
assert!(out.watermark >= wm, "the watermark never goes backwards");
|
|
1203
|
+
if out.watermark == wm && !out.more {
|
|
1204
|
+
break;
|
|
1205
|
+
}
|
|
1206
|
+
wm = out.watermark;
|
|
1207
|
+
}
|
|
1208
|
+
assert_eq!(
|
|
1209
|
+
responses, 4,
|
|
1210
|
+
"a burst wider than the limit still ships whole"
|
|
1211
|
+
);
|
|
1212
|
+
assert_eq!(wm, PROMPT_MS + 20);
|
|
1213
|
+
|
|
1214
|
+
fs::remove_dir_all(&dir).ok();
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// ── dedup discrimination and determinism ─────────────────────────────────
|
|
1218
|
+
|
|
1219
|
+
#[test]
|
|
1220
|
+
fn identical_parts_are_discriminated_by_their_part_id() {
|
|
1221
|
+
// Two identical calls in one turn are two real events. Without a
|
|
1222
|
+
// discriminator the server's content dedup collapses them into one and the
|
|
1223
|
+
// product under-reports what the agent did.
|
|
1224
|
+
let dir = tmpdir("dedup");
|
|
1225
|
+
let (db, conn) = make_db(&dir);
|
|
1226
|
+
seed_turn(&conn);
|
|
1227
|
+
assistant_message(
|
|
1228
|
+
&conn,
|
|
1229
|
+
"msg_tool_turn",
|
|
1230
|
+
SESSION,
|
|
1231
|
+
TOOL_CREATED_MS,
|
|
1232
|
+
Some("tool-calls"),
|
|
1233
|
+
);
|
|
1234
|
+
let twin = json!({"type":"tool","tool":"bash","callID":CALL_ID,
|
|
1235
|
+
"state":{"status":"completed","input":{"command":"ls"},"output":"a\n",
|
|
1236
|
+
"time":{"start":TOOL_CREATED_MS,"end":TOOL_CREATED_MS}}});
|
|
1237
|
+
insert_part(
|
|
1238
|
+
&conn,
|
|
1239
|
+
"prt_twin_a",
|
|
1240
|
+
"msg_tool_turn",
|
|
1241
|
+
SESSION,
|
|
1242
|
+
TOOL_CREATED_MS,
|
|
1243
|
+
TOOL_CREATED_MS,
|
|
1244
|
+
&twin,
|
|
1245
|
+
);
|
|
1246
|
+
insert_part(
|
|
1247
|
+
&conn,
|
|
1248
|
+
"prt_twin_b",
|
|
1249
|
+
"msg_tool_turn",
|
|
1250
|
+
SESSION,
|
|
1251
|
+
TOOL_CREATED_MS,
|
|
1252
|
+
TOOL_CREATED_MS,
|
|
1253
|
+
&twin,
|
|
1254
|
+
);
|
|
1255
|
+
|
|
1256
|
+
let ev = poll_all(&db);
|
|
1257
|
+
let calls = of_type(&ev, "tool_use");
|
|
1258
|
+
assert_eq!(calls.len(), 2);
|
|
1259
|
+
assert_eq!(calls[0]["opencode_part_id"], "prt_twin_a");
|
|
1260
|
+
assert_eq!(calls[1]["opencode_part_id"], "prt_twin_b");
|
|
1261
|
+
assert_ne!(
|
|
1262
|
+
serde_json::to_string(calls[0]).unwrap(),
|
|
1263
|
+
serde_json::to_string(calls[1]).unwrap(),
|
|
1264
|
+
"otherwise-identical events must not hash the same"
|
|
1265
|
+
);
|
|
1266
|
+
// Even the same callID on both must not merge them.
|
|
1267
|
+
assert_eq!(calls[0]["tool_call_id"], calls[1]["tool_call_id"]);
|
|
1268
|
+
|
|
1269
|
+
fs::remove_dir_all(&dir).ok();
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
#[test]
|
|
1273
|
+
fn a_tool_part_with_no_call_id_falls_back_to_the_part_id() {
|
|
1274
|
+
let dir = tmpdir("nocallid");
|
|
1275
|
+
let (db, conn) = make_db(&dir);
|
|
1276
|
+
seed_turn(&conn);
|
|
1277
|
+
assistant_message(
|
|
1278
|
+
&conn,
|
|
1279
|
+
"msg_tool_turn",
|
|
1280
|
+
SESSION,
|
|
1281
|
+
TOOL_CREATED_MS,
|
|
1282
|
+
Some("tool-calls"),
|
|
1283
|
+
);
|
|
1284
|
+
insert_part(
|
|
1285
|
+
&conn,
|
|
1286
|
+
TOOL_PART,
|
|
1287
|
+
"msg_tool_turn",
|
|
1288
|
+
SESSION,
|
|
1289
|
+
TOOL_CREATED_MS,
|
|
1290
|
+
TOOL_CREATED_MS,
|
|
1291
|
+
&json!({"type":"tool","tool":"bash","state":{"status":"completed","input":{},"output":"x",
|
|
1292
|
+
"time":{"start":TOOL_CREATED_MS,"end":TOOL_CREATED_MS}}}),
|
|
1293
|
+
);
|
|
1294
|
+
|
|
1295
|
+
let ev = poll_all(&db);
|
|
1296
|
+
assert_eq!(of_type(&ev, "tool_use")[0]["tool_call_id"], TOOL_PART);
|
|
1297
|
+
assert_eq!(of_type(&ev, "tool_result")[0]["tool_call_id"], TOOL_PART);
|
|
1298
|
+
|
|
1299
|
+
fs::remove_dir_all(&dir).ok();
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
#[test]
|
|
1303
|
+
fn re_reading_the_same_rows_produces_identical_bytes() {
|
|
1304
|
+
// The whole re-read design rests on this: the server collapses a re-read
|
|
1305
|
+
// only when it hashes the same, and this source re-reads constantly.
|
|
1306
|
+
let dir = tmpdir("determinism");
|
|
1307
|
+
let (db, conn) = make_db(&dir);
|
|
1308
|
+
seed_turn(&conn);
|
|
1309
|
+
assistant_message(
|
|
1310
|
+
&conn,
|
|
1311
|
+
"msg_tool_turn",
|
|
1312
|
+
SESSION,
|
|
1313
|
+
TOOL_CREATED_MS,
|
|
1314
|
+
Some("tool-calls"),
|
|
1315
|
+
);
|
|
1316
|
+
insert_part(
|
|
1317
|
+
&conn,
|
|
1318
|
+
TOOL_PART,
|
|
1319
|
+
"msg_tool_turn",
|
|
1320
|
+
SESSION,
|
|
1321
|
+
TOOL_CREATED_MS,
|
|
1322
|
+
TOOL_DONE_MS,
|
|
1323
|
+
&completed_tool_part(),
|
|
1324
|
+
);
|
|
1325
|
+
insert_part(
|
|
1326
|
+
&conn,
|
|
1327
|
+
"prt_extra",
|
|
1328
|
+
ASSISTANT_MSG,
|
|
1329
|
+
SESSION,
|
|
1330
|
+
PROMPT_MS + 20,
|
|
1331
|
+
PROMPT_MS + 20,
|
|
1332
|
+
&streamed_text_part("more", PROMPT_MS + 20, Some(PROMPT_MS + 20)),
|
|
1333
|
+
);
|
|
1334
|
+
|
|
1335
|
+
let a = serde_json::to_string(&poll_all(&db)).unwrap();
|
|
1336
|
+
for _ in 0..5 {
|
|
1337
|
+
assert_eq!(a, serde_json::to_string(&poll_all(&db)).unwrap());
|
|
1338
|
+
}
|
|
1339
|
+
// …and paging through it produces the same events as one big poll.
|
|
1340
|
+
let mut paged: Vec<Value> = Vec::new();
|
|
1341
|
+
let mut wm = 0;
|
|
1342
|
+
loop {
|
|
1343
|
+
let out = poll_at(&db, wm, 1);
|
|
1344
|
+
paged.extend(out.events);
|
|
1345
|
+
if out.watermark == wm {
|
|
1346
|
+
break;
|
|
1347
|
+
}
|
|
1348
|
+
wm = out.watermark;
|
|
1349
|
+
}
|
|
1350
|
+
assert_eq!(a, serde_json::to_string(&paged).unwrap());
|
|
1351
|
+
|
|
1352
|
+
fs::remove_dir_all(&dir).ok();
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
#[test]
|
|
1356
|
+
fn a_malformed_data_blob_costs_that_row_not_the_whole_poll() {
|
|
1357
|
+
let dir = tmpdir("malformed");
|
|
1358
|
+
let (db, conn) = make_db(&dir);
|
|
1359
|
+
seed_turn(&conn);
|
|
1360
|
+
conn.execute(
|
|
1361
|
+
"INSERT INTO part VALUES ('prt_bad',?1,?2,?3,?3,'{not json')",
|
|
1362
|
+
rusqlite::params![ASSISTANT_MSG, SESSION, PROMPT_MS + 20],
|
|
1363
|
+
)
|
|
1364
|
+
.unwrap();
|
|
1365
|
+
|
|
1366
|
+
let out = poll_at(&db, 0, 1000);
|
|
1367
|
+
assert_eq!(out.rows_seen, 4);
|
|
1368
|
+
assert_eq!(of_type(&out.events, "model_response").len(), 1);
|
|
1369
|
+
assert_eq!(out.watermark, PROMPT_MS + 20, "the poll still advances");
|
|
1370
|
+
|
|
1371
|
+
fs::remove_dir_all(&dir).ok();
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// ── timestamps, format table, discovery ──────────────────────────────────
|
|
1375
|
+
|
|
1376
|
+
#[test]
|
|
1377
|
+
fn epoch_milliseconds_become_rfc3339_with_six_subsecond_digits() {
|
|
1378
|
+
// opencode stores INTEGER milliseconds. The index offset is what keeps
|
|
1379
|
+
// several events derived from ONE row in order.
|
|
1380
|
+
assert_eq!(
|
|
1381
|
+
transform::to_rfc3339_micros(TOOL_DONE_MS, 0).unwrap(),
|
|
1382
|
+
"2026-08-03T08:09:11.135000Z"
|
|
1383
|
+
);
|
|
1384
|
+
assert_eq!(
|
|
1385
|
+
transform::to_rfc3339_micros(TOOL_DONE_MS, 1).unwrap(),
|
|
1386
|
+
"2026-08-03T08:09:11.135001Z"
|
|
1387
|
+
);
|
|
1388
|
+
assert_eq!(
|
|
1389
|
+
transform::to_rfc3339_micros(TOOL_DONE_MS, 999).unwrap(),
|
|
1390
|
+
"2026-08-03T08:09:11.135999Z"
|
|
1391
|
+
);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
#[test]
|
|
1395
|
+
fn every_event_carries_the_full_envelope() {
|
|
1396
|
+
let dir = tmpdir("envelope");
|
|
1397
|
+
let (db, conn) = make_db(&dir);
|
|
1398
|
+
seed_turn(&conn);
|
|
1399
|
+
assistant_message(
|
|
1400
|
+
&conn,
|
|
1401
|
+
"msg_tool_turn",
|
|
1402
|
+
SESSION,
|
|
1403
|
+
TOOL_CREATED_MS,
|
|
1404
|
+
Some("tool-calls"),
|
|
1405
|
+
);
|
|
1406
|
+
insert_part(
|
|
1407
|
+
&conn,
|
|
1408
|
+
TOOL_PART,
|
|
1409
|
+
"msg_tool_turn",
|
|
1410
|
+
SESSION,
|
|
1411
|
+
TOOL_CREATED_MS,
|
|
1412
|
+
TOOL_DONE_MS,
|
|
1413
|
+
&completed_tool_part(),
|
|
1414
|
+
);
|
|
1415
|
+
|
|
1416
|
+
let ev = poll_all(&db);
|
|
1417
|
+
let kinds: Vec<&str> = ev.iter().map(|e| e["type"].as_str().unwrap()).collect();
|
|
1418
|
+
for kind in [
|
|
1419
|
+
"agent_start",
|
|
1420
|
+
"model_request",
|
|
1421
|
+
"model_response",
|
|
1422
|
+
"tool_use",
|
|
1423
|
+
"tool_result",
|
|
1424
|
+
] {
|
|
1425
|
+
assert!(kinds.contains(&kind), "missing {kind}");
|
|
1426
|
+
}
|
|
1427
|
+
for e in &ev {
|
|
1428
|
+
assert_eq!(e["session_id"], SESSION);
|
|
1429
|
+
assert_eq!(e["agent_id"], AGENT);
|
|
1430
|
+
assert_eq!(e["environment"], "local");
|
|
1431
|
+
assert!(e["opencode_part_id"].is_string(), "dedup discriminator");
|
|
1432
|
+
let ts = e["timestamp"].as_str().unwrap();
|
|
1433
|
+
assert!(ts.ends_with('Z') && ts.len() == 27, "RFC3339 micros: {ts}");
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
fs::remove_dir_all(&dir).ok();
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
#[test]
|
|
1440
|
+
fn the_dead_session_message_table_is_never_read() {
|
|
1441
|
+
// It is named exactly like the table this source wants, it is permanently
|
|
1442
|
+
// empty on a live install, and four shipped migrations DELETE FROM it. A
|
|
1443
|
+
// poller that reached for it would report success and ship nothing.
|
|
1444
|
+
let dir = tmpdir("deadtable");
|
|
1445
|
+
let (db, conn) = make_db(&dir);
|
|
1446
|
+
seed_turn(&conn);
|
|
1447
|
+
conn.execute(
|
|
1448
|
+
"INSERT INTO session_message VALUES ('sm_1',?1,'text',0,?2,?2,'{\"text\":\"LEGACY\"}')",
|
|
1449
|
+
rusqlite::params![SESSION, PROMPT_MS + 40],
|
|
1450
|
+
)
|
|
1451
|
+
.unwrap();
|
|
1452
|
+
|
|
1453
|
+
let out = poll_at(&db, 0, 1000);
|
|
1454
|
+
assert_eq!(out.rows_seen, 3, "session + two parts, and nothing else");
|
|
1455
|
+
assert!(
|
|
1456
|
+
!serde_json::to_string(&out.events)
|
|
1457
|
+
.unwrap()
|
|
1458
|
+
.contains("LEGACY"),
|
|
1459
|
+
"the legacy table must not reach the event stream"
|
|
1460
|
+
);
|
|
1461
|
+
|
|
1462
|
+
fs::remove_dir_all(&dir).ok();
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
#[test]
|
|
1466
|
+
fn opencode_declares_an_updated_at_watermark() {
|
|
1467
|
+
// If this ever flips to RowId, every tool call in the product ships with
|
|
1468
|
+
// an empty result and nothing errors. It is the single most
|
|
1469
|
+
// consequential line in this source.
|
|
1470
|
+
assert_eq!(opencode::FORMAT.watermark, Watermark::UpdatedAt);
|
|
1471
|
+
assert_eq!(opencode::FORMAT.kind, "opencode");
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
#[test]
|
|
1475
|
+
fn the_default_database_is_the_xdg_data_one() {
|
|
1476
|
+
let path = opencode::default_db_path();
|
|
1477
|
+
assert!(path.ends_with("opencode/opencode.db"), "{}", path.display());
|
|
1478
|
+
}
|