failproofai 1.0.0-beta.2 → 1.0.0-beta.3

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.
Files changed (196) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +6 -6
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  15. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
  17. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  18. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  22. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  23. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  24. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  25. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  26. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  27. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  37. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  38. package/.next/standalone/.next/server/app/audit/page.js +1 -1
  39. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/index.html +1 -1
  42. package/.next/standalone/.next/server/app/index.rsc +15 -15
  43. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  44. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  46. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  47. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  48. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  49. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  50. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  51. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  52. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  53. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
  54. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  55. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  58. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  59. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  66. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  67. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  68. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  70. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  75. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
  79. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
  81. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
  83. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__10s_z4l._.js} +2 -2
  84. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
  85. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__1ky3im9._.js} +2 -2
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
  87. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  90. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0oy_-36.js → node_modules_html-to-image_es_index_0jfts82.js} +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
  93. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  94. package/.next/standalone/.next/server/pages/404.html +1 -1
  95. package/.next/standalone/.next/server/pages/500.html +1 -1
  96. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  97. package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
  98. package/.next/standalone/.next/static/chunks/{3s7myk_co0gbh.js → 01h6lfjz1_oie.js} +1 -1
  99. package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
  100. package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 0a7k_x4rp_97z.js} +1 -1
  101. package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 0cvd6bcdwchc_.js} +1 -1
  102. package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 13-obgukzg_ke.js} +1 -1
  103. package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
  104. package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 1h0_yaoq5f3yx.js} +1 -1
  105. package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 29g8ykjk6sx_8.js} +1 -1
  106. package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 2rqm-klet_gbl.js} +1 -1
  107. package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 2uvyh8y0naofj.js} +1 -1
  108. package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
  109. package/.next/standalone/.next/static/chunks/3p-n_j80zlzi3.js +6 -0
  110. package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
  111. package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
  112. package/.next/standalone/Cargo.lock +1992 -198
  113. package/.next/standalone/Cargo.toml +1 -1
  114. package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
  115. package/.next/standalone/app/components/pause-notices.tsx +111 -0
  116. package/.next/standalone/app/policies/hooks-client.tsx +81 -6
  117. package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
  118. package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
  119. package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
  120. package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
  121. package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
  122. package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
  123. package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
  124. package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
  125. package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
  126. package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
  127. package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
  128. package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
  129. package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
  130. package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
  131. package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
  132. package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
  133. package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
  134. package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
  135. package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
  136. package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
  137. package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
  138. package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
  139. package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
  140. package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
  141. package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
  142. package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
  143. package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
  144. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
  145. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
  146. package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
  147. package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
  148. package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
  149. package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
  150. package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
  151. package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
  152. package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
  153. package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
  154. package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
  155. package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
  156. package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
  157. package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
  158. package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
  159. package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
  160. package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
  161. package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
  162. package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
  163. package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
  164. package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
  165. package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
  166. package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
  167. package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
  168. package/.next/standalone/package.json +2 -2
  169. package/.next/standalone/server.js +1 -1
  170. package/bin/failproofai.mjs +129 -0
  171. package/dist/cli.mjs +2195 -770
  172. package/dist/worker.mjs +365 -52
  173. package/package.json +2 -2
  174. package/src/hooks/builtin-policies.ts +139 -0
  175. package/src/hooks/cloud-connection.ts +205 -0
  176. package/src/hooks/cloud-enrollment-cli.ts +227 -0
  177. package/src/hooks/cloud-enrollment.ts +173 -0
  178. package/src/hooks/cloud-managed-policies.ts +138 -0
  179. package/src/hooks/collector-config.ts +226 -0
  180. package/src/hooks/configure-wizard.ts +221 -5
  181. package/src/hooks/custom-hooks-loader.ts +69 -25
  182. package/src/hooks/daemon-client.ts +18 -0
  183. package/src/hooks/handler.ts +144 -6
  184. package/src/hooks/hook-activity-store.ts +50 -0
  185. package/src/hooks/loader-utils.ts +6 -4
  186. package/src/hooks/policy-types.ts +35 -0
  187. package/src/hooks/session-pause-cli.ts +157 -0
  188. package/src/hooks/session-pause.ts +236 -0
  189. package/src/hooks/tui.ts +115 -0
  190. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  191. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  192. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  193. package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
  194. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
  195. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
  196. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_ssgManifest.js +0 -0
@@ -0,0 +1,1008 @@
1
+ //! goose source: transform correctness and engine behaviour.
2
+ //!
3
+ //! Every test builds a REAL SQLite database in WAL mode using the DDL goose
4
+ //! itself ships (verified against goose v1.43.0, `schema_version` 15) and reads
5
+ //! it back through the same read-only connection the daemon uses. Record shapes
6
+ //! are verbatim from a live `~/.local/share/goose/sessions/sessions.db`.
7
+
8
+ use std::fs;
9
+ use std::path::{Path, PathBuf};
10
+ use std::sync::Arc;
11
+ use std::sync::atomic::AtomicBool;
12
+ use std::time::{Duration, SystemTime, UNIX_EPOCH};
13
+
14
+ use fpai_collect::sources::goose;
15
+ use fpai_collect::sqlitepoll::{self, Params, PollOutcome, PollRequest, Spec, Watermark};
16
+ use fpai_collect::supervisor::Shutdown;
17
+ use rusqlite::{Connection, params};
18
+ use serde_json::{Value, json};
19
+
20
+ /// goose's own DDL, copied from a live database. Kept verbatim — a paraphrased
21
+ /// schema is exactly how a source stops matching the product it reads.
22
+ const SCHEMA: &str = r#"
23
+ CREATE TABLE sessions (
24
+ id TEXT PRIMARY KEY,
25
+ name TEXT NOT NULL DEFAULT '',
26
+ description TEXT NOT NULL DEFAULT '',
27
+ user_set_name BOOLEAN DEFAULT FALSE,
28
+ session_type TEXT NOT NULL DEFAULT 'user',
29
+ working_dir TEXT NOT NULL,
30
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
31
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
32
+ extension_data TEXT DEFAULT '{}',
33
+ total_tokens INTEGER,
34
+ input_tokens INTEGER,
35
+ output_tokens INTEGER,
36
+ cache_read_tokens INTEGER,
37
+ cache_write_tokens INTEGER,
38
+ accumulated_total_tokens INTEGER,
39
+ accumulated_input_tokens INTEGER,
40
+ accumulated_output_tokens INTEGER,
41
+ accumulated_cache_read_tokens INTEGER,
42
+ accumulated_cache_write_tokens INTEGER,
43
+ accumulated_cost REAL,
44
+ schedule_id TEXT,
45
+ recipe_json TEXT,
46
+ user_recipe_values_json TEXT,
47
+ provider_name TEXT,
48
+ model_config_json TEXT,
49
+ goose_mode TEXT NOT NULL DEFAULT 'auto',
50
+ archived_at TIMESTAMP,
51
+ project_id TEXT,
52
+ parent_session_id TEXT
53
+ );
54
+ CREATE TABLE messages (
55
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
56
+ message_id TEXT,
57
+ session_id TEXT NOT NULL REFERENCES sessions(id),
58
+ role TEXT NOT NULL,
59
+ content_json TEXT NOT NULL,
60
+ created_timestamp INTEGER NOT NULL,
61
+ timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
62
+ tokens INTEGER,
63
+ metadata_json TEXT
64
+ );
65
+ CREATE INDEX idx_messages_session ON messages(session_id);
66
+ CREATE INDEX idx_messages_timestamp ON messages(timestamp);
67
+ CREATE INDEX idx_sessions_type ON sessions(session_type);
68
+ "#;
69
+
70
+ const MODEL_CONFIG: &str = r#"{"model_name":"claude-sonnet-4-6","context_limit":1000000}"#;
71
+
72
+ /// A second inside the live corpus, so the rendered timestamps in these tests
73
+ /// are the ones a real session produced.
74
+ const T0: i64 = 1_785_743_817;
75
+
76
+ fn tmpdir(name: &str) -> PathBuf {
77
+ let d = std::env::temp_dir().join(format!(
78
+ "fpai-gs-{}-{}-{}",
79
+ name,
80
+ std::process::id(),
81
+ SystemTime::now()
82
+ .duration_since(UNIX_EPOCH)
83
+ .unwrap()
84
+ .as_nanos()
85
+ ));
86
+ fs::create_dir_all(&d).unwrap();
87
+ d
88
+ }
89
+
90
+ /// A writable goose database, in WAL mode like the real one.
91
+ struct Db {
92
+ dir: PathBuf,
93
+ path: PathBuf,
94
+ conn: Connection,
95
+ }
96
+
97
+ impl Db {
98
+ fn new(name: &str) -> Self {
99
+ let dir = tmpdir(name);
100
+ let path = dir.join("sessions.db");
101
+ let conn = Connection::open(&path).unwrap();
102
+ conn.pragma_update(None, "journal_mode", "WAL").unwrap();
103
+ conn.execute_batch(SCHEMA).unwrap();
104
+ Self { dir, path, conn }
105
+ }
106
+
107
+ fn session(&self, id: &str, working_dir: &str, session_type: &str) -> &Self {
108
+ self.conn
109
+ .execute(
110
+ "INSERT INTO sessions(id, name, session_type, working_dir, provider_name, model_config_json)
111
+ VALUES (?1, ?2, ?3, ?4, 'openai', ?5)",
112
+ params![id, "probe", session_type, working_dir, MODEL_CONFIG],
113
+ )
114
+ .unwrap();
115
+ self
116
+ }
117
+
118
+ /// Insert one `messages` row and return its rowid.
119
+ fn message(&self, session_id: &str, role: &str, content: &Value, created: i64) -> i64 {
120
+ self.message_with_meta(session_id, role, content, created, None)
121
+ }
122
+
123
+ fn message_with_meta(
124
+ &self,
125
+ session_id: &str,
126
+ role: &str,
127
+ content: &Value,
128
+ created: i64,
129
+ metadata: Option<&str>,
130
+ ) -> i64 {
131
+ self.conn
132
+ .execute(
133
+ "INSERT INTO messages(message_id, session_id, role, content_json,
134
+ created_timestamp, tokens, metadata_json)
135
+ VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6)",
136
+ params![
137
+ format!("msg_{created}"),
138
+ session_id,
139
+ role,
140
+ content.to_string(),
141
+ created,
142
+ metadata
143
+ ],
144
+ )
145
+ .unwrap();
146
+ self.conn.last_insert_rowid()
147
+ }
148
+
149
+ fn poll(&self, watermark: i64) -> PollOutcome {
150
+ self.poll_limited(watermark, 1000)
151
+ }
152
+
153
+ fn poll_limited(&self, watermark: i64, max_rows: u64) -> PollOutcome {
154
+ let conn = sqlitepoll::open_readonly(&self.path).unwrap();
155
+ (goose::FORMAT.poll)(
156
+ &conn,
157
+ &PollRequest {
158
+ watermark,
159
+ max_rows,
160
+ environment: "local".into(),
161
+ agent_id: goose::DEFAULT_AGENT_ID.into(),
162
+ },
163
+ )
164
+ .unwrap()
165
+ }
166
+ }
167
+
168
+ impl Drop for Db {
169
+ fn drop(&mut self) {
170
+ fs::remove_dir_all(&self.dir).ok();
171
+ }
172
+ }
173
+
174
+ // ── the block shapes, verbatim from a live database ──────────────────────
175
+
176
+ fn text(t: &str) -> Value {
177
+ json!([{ "type": "text", "text": t }])
178
+ }
179
+
180
+ fn thinking(t: &str) -> Value {
181
+ json!([{ "type": "thinking", "thinking": t, "signature": "sig" }])
182
+ }
183
+
184
+ fn tool_request(id: &str, name: &str, command: &str) -> Value {
185
+ json!([{
186
+ "type": "toolRequest",
187
+ "id": id,
188
+ "toolCall": {"status": "success", "value": {"name": name, "arguments": {"command": command}}},
189
+ "_meta": {"goose_extension": "developer"}
190
+ }])
191
+ }
192
+
193
+ fn tool_response(id: &str, out: &str) -> Value {
194
+ json!([{
195
+ "type": "toolResponse",
196
+ "id": id,
197
+ "toolResult": {"status": "success", "value": {
198
+ "content": [{"type": "text", "text": out, "annotations": {"priority": 0.0}}],
199
+ "structuredContent": {"stdout": out, "stderr": "", "exit_code": 0},
200
+ "isError": false
201
+ }}
202
+ }])
203
+ }
204
+
205
+ fn failed_tool_response(id: &str, out: &str) -> Value {
206
+ json!([{
207
+ "type": "toolResponse",
208
+ "id": id,
209
+ "toolResult": {"status": "success", "value": {
210
+ "content": [{"type": "text", "text": out}],
211
+ "isError": true
212
+ }}
213
+ }])
214
+ }
215
+
216
+ // ── assertions helpers ───────────────────────────────────────────────────
217
+
218
+ fn types(events: &[Value]) -> Vec<&str> {
219
+ events.iter().filter_map(|e| e["type"].as_str()).collect()
220
+ }
221
+
222
+ fn of_type<'a>(events: &'a [Value], kind: &str) -> Vec<&'a Value> {
223
+ events.iter().filter(|e| e["type"] == kind).collect()
224
+ }
225
+
226
+ fn one<'a>(events: &'a [Value], kind: &str) -> &'a Value {
227
+ let found = of_type(events, kind);
228
+ assert_eq!(found.len(), 1, "expected one {kind} in {:?}", types(events));
229
+ found[0]
230
+ }
231
+
232
+ // ── content-block shapes ─────────────────────────────────────────────────
233
+
234
+ #[test]
235
+ fn a_user_text_row_becomes_a_model_request_and_an_assistant_text_row_a_model_response() {
236
+ // The two halves of a plain conversational turn. A `model_request` with no
237
+ // `messages` array renders as an empty row, which is the row a session is
238
+ // most likely to be judged by.
239
+ let db = Db::new("text");
240
+ db.session("20260803_1", "/home/u/repo", "user");
241
+ db.message("20260803_1", "user", &text("list the files"), T0);
242
+ db.message("20260803_1", "assistant", &text("Here they are."), T0 + 1);
243
+
244
+ let ev = db.poll(0).events;
245
+ let req = one(&ev, "model_request");
246
+ assert_eq!(req["messages"][0]["role"], "user");
247
+ assert_eq!(req["messages"][0]["content"], "list the files");
248
+ // Read off the session row: `messages` carries no per-message model, and
249
+ // the server builds this row's summary from the model alone.
250
+ assert_eq!(req["model"], "claude-sonnet-4-6");
251
+
252
+ let resp = one(&ev, "model_response");
253
+ assert_eq!(resp["role"], "assistant");
254
+ assert_eq!(resp["content"], "Here they are.");
255
+ assert_eq!(resp["model"], "claude-sonnet-4-6");
256
+ }
257
+
258
+ #[test]
259
+ fn a_tool_request_becomes_a_tool_use_carrying_its_arguments() {
260
+ let db = Db::new("toolreq");
261
+ db.session("20260803_1", "/home/u/repo", "user");
262
+ db.message(
263
+ "20260803_1",
264
+ "assistant",
265
+ &tool_request("tooluse_zEEEAN7v7StUeLHzdGykp6", "shell", "ls -la"),
266
+ T0,
267
+ );
268
+
269
+ let ev = db.poll(0).events;
270
+ let use_ = one(&ev, "tool_use");
271
+ assert_eq!(use_["tool_name"], "shell");
272
+ assert_eq!(use_["tool_call_id"], "tooluse_zEEEAN7v7StUeLHzdGykp6");
273
+ // `arguments` is nested two levels deep inside a serialized Result; reading
274
+ // `toolCall` directly would ship an empty command for every call.
275
+ assert_eq!(use_["input"]["command"], "ls -la");
276
+ }
277
+
278
+ #[test]
279
+ fn a_tool_response_arrives_in_a_user_role_row_and_is_not_read_as_a_prompt() {
280
+ // The single most important branch in this source. goose files tool results
281
+ // under `role:'user'`, so a role-first branch turns every result into a
282
+ // user prompt and the timeline reads as the model interviewing itself.
283
+ let db = Db::new("respinuser");
284
+ db.session("20260803_1", "/home/u/repo", "user");
285
+ db.message(
286
+ "20260803_1",
287
+ "assistant",
288
+ &tool_request("t1", "shell", "ls"),
289
+ T0,
290
+ );
291
+ db.message(
292
+ "20260803_1",
293
+ "user",
294
+ &tool_response("t1", "total 4"),
295
+ T0 + 1,
296
+ );
297
+
298
+ let ev = db.poll(0).events;
299
+ assert!(
300
+ of_type(&ev, "model_request").is_empty(),
301
+ "a tool result must not become a prompt: {:?}",
302
+ types(&ev)
303
+ );
304
+ let result = one(&ev, "tool_result");
305
+ assert_eq!(result["output"], "total 4");
306
+ assert_eq!(result["tool_call_id"], "t1");
307
+ }
308
+
309
+ #[test]
310
+ fn a_tool_result_is_named_from_the_request_it_answers() {
311
+ // The name appears on NO response block. Without carrying it, every result
312
+ // renders as a blank row in the product.
313
+ let db = Db::new("named");
314
+ db.session("20260803_1", "/home/u/repo", "user");
315
+ db.message(
316
+ "20260803_1",
317
+ "assistant",
318
+ &tool_request("t1", "shell", "ls"),
319
+ T0,
320
+ );
321
+ db.message(
322
+ "20260803_1",
323
+ "user",
324
+ &tool_response("t1", "total 4"),
325
+ T0 + 1,
326
+ );
327
+
328
+ let ev = db.poll(0).events;
329
+ assert_eq!(one(&ev, "tool_result")["tool_name"], "shell");
330
+ }
331
+
332
+ #[test]
333
+ fn a_result_whose_call_landed_in_an_earlier_poll_is_still_named() {
334
+ // The call and its result are separate ROWS, so a poll boundary can fall
335
+ // between them — unlike Claude, where in-memory state always has the name.
336
+ let db = Db::new("crossbatch");
337
+ db.session("20260803_1", "/home/u/repo", "user");
338
+ let call = db.message(
339
+ "20260803_1",
340
+ "assistant",
341
+ &tool_request("t1", "shell", "ls"),
342
+ T0,
343
+ );
344
+ db.message(
345
+ "20260803_1",
346
+ "user",
347
+ &tool_response("t1", "total 4"),
348
+ T0 + 1,
349
+ );
350
+
351
+ // Resume as if the previous poll had stopped right after the call row.
352
+ let ev = db.poll(call).events;
353
+ assert_eq!(
354
+ one(&ev, "tool_result")["tool_name"],
355
+ "shell",
356
+ "the name must be recovered from the database"
357
+ );
358
+ }
359
+
360
+ #[test]
361
+ fn a_thinking_block_becomes_a_marked_model_response() {
362
+ // Not seen in the live corpus but present in goose's block enum; an
363
+ // unhandled variant drops the turn silently rather than loudly.
364
+ let db = Db::new("thinking");
365
+ db.session("20260803_1", "/home/u/repo", "user");
366
+ db.message("20260803_1", "assistant", &thinking("weighing options"), T0);
367
+
368
+ let ev = db.poll(0).events;
369
+ let resp = one(&ev, "model_response");
370
+ assert_eq!(resp["content"], "weighing options");
371
+ assert_eq!(resp["goose_thinking"], true);
372
+ }
373
+
374
+ #[test]
375
+ fn a_failed_tool_result_carries_a_non_empty_error() {
376
+ let db = Db::new("toolerr");
377
+ db.session("20260803_1", "/home/u/repo", "user");
378
+ db.message(
379
+ "20260803_1",
380
+ "assistant",
381
+ &tool_request("t1", "shell", "false"),
382
+ T0,
383
+ );
384
+ db.message(
385
+ "20260803_1",
386
+ "user",
387
+ &failed_tool_response("t1", "exit code 1"),
388
+ T0 + 1,
389
+ );
390
+
391
+ let ev = db.poll(0).events;
392
+ let result = one(&ev, "tool_result");
393
+ assert_eq!(result["error_type"], "goose_tool_error");
394
+ assert!(!result["error"].as_str().unwrap().is_empty());
395
+ }
396
+
397
+ #[test]
398
+ fn a_tool_call_that_never_reached_its_tool_is_reported_as_an_error() {
399
+ // `toolCall` is a serialized Result: on the error arm there is no `value`,
400
+ // so a blind read of `/toolCall/value/name` would ship a nameless call with
401
+ // no indication anything went wrong.
402
+ let db = Db::new("callerr");
403
+ db.session("20260803_1", "/home/u/repo", "user");
404
+ db.message(
405
+ "20260803_1",
406
+ "assistant",
407
+ &json!([{"type":"toolRequest","id":"t1",
408
+ "toolCall":{"status":"error","error":"extension not available"}}]),
409
+ T0,
410
+ );
411
+
412
+ let ev = db.poll(0).events;
413
+ let use_ = one(&ev, "tool_use");
414
+ assert_eq!(use_["goose_tool_call_status"], "error");
415
+ assert_eq!(use_["error"], "extension not available");
416
+ assert_eq!(use_["error_type"], "goose_tool_error");
417
+ }
418
+
419
+ // ── rows are not turn-aligned ────────────────────────────────────────────
420
+
421
+ #[test]
422
+ fn parallel_tool_calls_spread_over_separate_rows_keep_their_own_names_and_order() {
423
+ // A live turn emitting four parallel `shell` calls produced FIVE rows — a
424
+ // preamble text row plus one row per call — with the results interleaved as
425
+ // four more rows, every one of them stamped with the SAME second. Anything
426
+ // that assumes one row is one turn, or that a shared second is a tie,
427
+ // renders this turn wrong.
428
+ let db = Db::new("parallel");
429
+ db.session("20260803_2", "/home/u/repo", "user");
430
+ db.message("20260803_2", "assistant", &text("Running all four."), T0);
431
+ for (id, cmd) in [("a", "head"), ("b", "sed1"), ("c", "sed2"), ("d", "cat")] {
432
+ db.message(
433
+ "20260803_2",
434
+ "assistant",
435
+ &tool_request(id, "shell", cmd),
436
+ T0,
437
+ );
438
+ db.message("20260803_2", "user", &tool_response(id, cmd), T0);
439
+ }
440
+
441
+ let ev = db.poll(0).events;
442
+ assert_eq!(of_type(&ev, "tool_use").len(), 4);
443
+ assert_eq!(of_type(&ev, "tool_result").len(), 4);
444
+ assert_eq!(of_type(&ev, "model_response").len(), 1, "the preamble row");
445
+
446
+ // Each result must pair with its own call, not with whichever call the
447
+ // server happens to sort next to it.
448
+ for (id, cmd) in [("a", "head"), ("b", "sed1"), ("c", "sed2"), ("d", "cat")] {
449
+ let result = ev
450
+ .iter()
451
+ .find(|e| e["type"] == "tool_result" && e["tool_call_id"] == id)
452
+ .unwrap_or_else(|| panic!("no result for {id}"));
453
+ assert_eq!(result["tool_name"], "shell");
454
+ assert_eq!(result["output"], cmd);
455
+ }
456
+
457
+ // Every event shares one second, so ordering rests entirely on the
458
+ // sub-second slots: each call must sort before its own result.
459
+ for (id, _) in [("a", ""), ("b", ""), ("c", ""), ("d", "")] {
460
+ let call = ev
461
+ .iter()
462
+ .find(|e| e["type"] == "tool_use" && e["tool_call_id"] == id)
463
+ .unwrap();
464
+ let result = ev
465
+ .iter()
466
+ .find(|e| e["type"] == "tool_result" && e["tool_call_id"] == id)
467
+ .unwrap();
468
+ assert!(
469
+ call["timestamp"].as_str() < result["timestamp"].as_str(),
470
+ "{id}: call {} must sort before result {}",
471
+ call["timestamp"],
472
+ result["timestamp"]
473
+ );
474
+ }
475
+ }
476
+
477
+ #[test]
478
+ fn several_blocks_in_one_row_are_distinguishable_and_ordered() {
479
+ // Every live row held exactly one block, but the column is an array and
480
+ // goose's own writer can emit more; two identical blocks in one row must
481
+ // not hash to the same event.
482
+ let db = Db::new("multiblock");
483
+ db.session("20260803_1", "/home/u/repo", "user");
484
+ db.message(
485
+ "20260803_1",
486
+ "assistant",
487
+ &json!([{"type":"text","text":"same"},{"type":"text","text":"same"}]),
488
+ T0,
489
+ );
490
+
491
+ let ev = db.poll(0).events;
492
+ let responses = of_type(&ev, "model_response");
493
+ assert_eq!(responses.len(), 2);
494
+ assert_ne!(responses[0]["timestamp"], responses[1]["timestamp"]);
495
+ assert_eq!(responses[1]["goose_block_index"], 1);
496
+ }
497
+
498
+ // ── session filtering and identity ───────────────────────────────────────
499
+
500
+ #[test]
501
+ fn a_hidden_session_is_filtered_out_but_still_advances_the_watermark() {
502
+ // `session_type='hidden'` rows are `goose run --no-session` scratch runs.
503
+ // Filtering them without advancing past them would re-read them on every
504
+ // poll for the life of the machine.
505
+ let db = Db::new("hidden");
506
+ db.session("20260803_1", "/home/u/repo", "hidden");
507
+ db.session("20260803_2", "/home/u/repo", "user");
508
+ let scratch = db.message("20260803_1", "user", &text("scratch run"), T0);
509
+ db.message("20260803_2", "user", &text("real run"), T0 + 1);
510
+
511
+ let out = db.poll(0);
512
+ assert!(
513
+ !out.events.iter().any(|e| e["session_id"] == "20260803_1"),
514
+ "a hidden session must not reach the product"
515
+ );
516
+ assert!(out.events.iter().any(|e| e["session_id"] == "20260803_2"));
517
+ assert!(out.watermark > scratch, "the scratch row must be passed");
518
+ assert_eq!(out.rows_seen, 2, "both rows were examined");
519
+ }
520
+
521
+ #[test]
522
+ fn a_session_type_the_next_goose_release_adds_is_kept_rather_than_dropped() {
523
+ // goose already ships `subagent`, `scheduled`, `terminal` and `acp`. Only
524
+ // `hidden` is a scratch run; an allowlist would silently drop the rest.
525
+ let db = Db::new("subagent");
526
+ db.session("20260803_1", "/home/u/repo", "subagent");
527
+ db.message("20260803_1", "user", &text("delegated work"), T0);
528
+
529
+ let ev = db.poll(0).events;
530
+ assert!(types(&ev).contains(&"model_request"), "{:?}", types(&ev));
531
+ }
532
+
533
+ #[test]
534
+ fn the_agent_id_comes_from_the_sessions_working_dir() {
535
+ // `working_dir` is NOT NULL and populated for every live session, so goose
536
+ // sessions group by project like Claude's. The spelling has to match what
537
+ // the hook source derives for the same run, or one run's hook events and
538
+ // session events file under two agents that look unrelated.
539
+ let db = Db::new("agentid");
540
+ db.session("20260803_1", "/home/u/src/openclaw-local", "user");
541
+ db.message("20260803_1", "user", &text("hi"), T0);
542
+
543
+ let ev = db.poll(0).events;
544
+ assert!(!ev.is_empty());
545
+ for e in &ev {
546
+ assert_eq!(e["agent_id"], "goose-openclaw-local");
547
+ assert_eq!(e["environment"], "local");
548
+ assert_eq!(e["session_id"], "20260803_1");
549
+ }
550
+ }
551
+
552
+ #[test]
553
+ fn a_message_whose_session_row_is_missing_still_ships() {
554
+ // The foreign key is declared but SQLite does not enforce it unless
555
+ // `PRAGMA foreign_keys` is on. An inner join would make such a row vanish
556
+ // silently instead of shipping under the configured fallback agent.
557
+ let db = Db::new("orphan");
558
+ // rusqlite turns the pragma ON for its own connections, which is exactly
559
+ // why this state is reachable in the wild: goose's writer decides, not us.
560
+ db.conn.pragma_update(None, "foreign_keys", false).unwrap();
561
+ db.conn
562
+ .execute(
563
+ "INSERT INTO messages(session_id, role, content_json, created_timestamp)
564
+ VALUES ('ghost', 'user', ?1, ?2)",
565
+ params![text("orphaned").to_string(), T0],
566
+ )
567
+ .unwrap();
568
+
569
+ let ev = db.poll(0).events;
570
+ assert_eq!(one(&ev, "model_request")["agent_id"], "goose");
571
+ }
572
+
573
+ // ── session boundaries ───────────────────────────────────────────────────
574
+
575
+ #[test]
576
+ fn a_session_starts_on_its_first_row_and_ends_when_another_session_writes() {
577
+ // goose writes no start or end marker, so both are derived from the row
578
+ // stream: the start rides `MIN(id)`, and the end rides the row after the
579
+ // session's last one.
580
+ let db = Db::new("bounds");
581
+ db.session("20260803_1", "/home/u/repo", "user");
582
+ db.session("20260803_2", "/home/u/repo", "user");
583
+ db.message("20260803_1", "user", &text("first session goal"), T0);
584
+ db.message("20260803_1", "assistant", &text("done"), T0 + 1);
585
+ db.message("20260803_2", "user", &text("second session"), T0 + 60);
586
+
587
+ let ev = db.poll(0).events;
588
+ let starts = of_type(&ev, "agent_start");
589
+ assert_eq!(starts.len(), 2, "one start per session");
590
+ assert_eq!(starts[0]["goal"], "first session goal");
591
+ assert_eq!(starts[0]["goose_working_dir"], "/home/u/repo");
592
+
593
+ let ends = of_type(&ev, "agent_end");
594
+ assert_eq!(ends.len(), 1, "only the finished session ends");
595
+ assert_eq!(ends[0]["session_id"], "20260803_1");
596
+ // The end is stamped at the session's own last row, not at the row that
597
+ // revealed it had finished — otherwise a session appears to run until the
598
+ // next one starts, however long the gap.
599
+ assert!(
600
+ ends[0]["timestamp"]
601
+ .as_str()
602
+ .unwrap()
603
+ .starts_with("2026-08-03T07:56:58.")
604
+ );
605
+
606
+ // And it sorts after that row's own content.
607
+ let last_content = ev
608
+ .iter()
609
+ .rfind(|e| e["session_id"] == "20260803_1" && e["type"] == "model_response")
610
+ .unwrap();
611
+ assert!(last_content["timestamp"].as_str() < ends[0]["timestamp"].as_str());
612
+ }
613
+
614
+ #[test]
615
+ fn the_newest_session_stays_open_until_something_writes_after_it() {
616
+ // The documented gap. Closing it would need either a wall clock (which
617
+ // makes the poll impure and un-dedupable) or an `agent_end` recomputed for
618
+ // every session on every poll, forever, on an idle machine.
619
+ let db = Db::new("openend");
620
+ db.session("20260803_1", "/home/u/repo", "user");
621
+ db.message("20260803_1", "user", &text("still going"), T0);
622
+
623
+ assert!(of_type(&db.poll(0).events, "agent_end").is_empty());
624
+ }
625
+
626
+ #[test]
627
+ fn a_session_is_closed_even_when_its_last_row_ended_the_previous_poll() {
628
+ // The predecessor of a batch's first row lives in the database, not in the
629
+ // batch. Without reading it back, a session whose last row happened to end
630
+ // a poll would never be closed at all.
631
+ let db = Db::new("acrosspoll");
632
+ db.session("20260803_1", "/home/u/repo", "user");
633
+ db.session("20260803_2", "/home/u/repo", "user");
634
+ let last_of_first = db.message("20260803_1", "user", &text("one"), T0);
635
+ db.message("20260803_2", "user", &text("two"), T0 + 60);
636
+
637
+ let ev = db.poll(last_of_first).events;
638
+ let ends = of_type(&ev, "agent_end");
639
+ assert_eq!(ends.len(), 1, "got {:?}", types(&ev));
640
+ assert_eq!(ends[0]["session_id"], "20260803_1");
641
+ }
642
+
643
+ #[test]
644
+ fn a_hidden_session_never_produces_a_start_or_an_end() {
645
+ let db = Db::new("hiddenbounds");
646
+ db.session("20260803_1", "/home/u/repo", "hidden");
647
+ db.session("20260803_2", "/home/u/repo", "user");
648
+ db.message("20260803_1", "user", &text("scratch"), T0);
649
+ db.message("20260803_2", "user", &text("real"), T0 + 1);
650
+
651
+ let ev = db.poll(0).events;
652
+ assert!(
653
+ !ev.iter().any(|e| e["session_id"] == "20260803_1"),
654
+ "{:?}",
655
+ ev
656
+ );
657
+ assert_eq!(of_type(&ev, "agent_start").len(), 1);
658
+ }
659
+
660
+ // ── watermark, limits, determinism ───────────────────────────────────────
661
+
662
+ #[test]
663
+ fn the_watermark_advances_and_a_second_poll_ships_nothing() {
664
+ let db = Db::new("watermark");
665
+ db.session("20260803_1", "/home/u/repo", "user");
666
+ db.message("20260803_1", "user", &text("hi"), T0);
667
+ let last = db.message("20260803_1", "assistant", &text("hello"), T0 + 1);
668
+
669
+ let first = db.poll(0);
670
+ assert!(!first.events.is_empty());
671
+ assert_eq!(first.watermark, last);
672
+
673
+ let second = db.poll(first.watermark);
674
+ assert!(
675
+ second.events.is_empty(),
676
+ "a resumed poll must ship nothing: {:?}",
677
+ types(&second.events)
678
+ );
679
+ assert_eq!(second.rows_seen, 0);
680
+ assert!(!second.more);
681
+ assert_eq!(second.watermark, first.watermark);
682
+ }
683
+
684
+ #[test]
685
+ fn hitting_the_row_limit_asks_the_engine_to_poll_again_immediately() {
686
+ // A backlog should drain rather than trickle out one batch per interval.
687
+ let db = Db::new("limit");
688
+ db.session("20260803_1", "/home/u/repo", "user");
689
+ for i in 0..5 {
690
+ db.message("20260803_1", "user", &text(&format!("m{i}")), T0 + i);
691
+ }
692
+
693
+ let first = db.poll_limited(0, 2);
694
+ assert_eq!(first.rows_seen, 2);
695
+ assert!(first.more, "the limit was hit, so more rows are waiting");
696
+
697
+ let mut watermark = first.watermark;
698
+ let mut seen = first.rows_seen;
699
+ let mut more = first.more;
700
+ while more {
701
+ let out = db.poll_limited(watermark, 2);
702
+ watermark = out.watermark;
703
+ seen += out.rows_seen;
704
+ more = out.more;
705
+ }
706
+ assert_eq!(seen, 5, "the drain must reach the end exactly once");
707
+ }
708
+
709
+ #[test]
710
+ fn a_poll_that_reads_rows_and_emits_nothing_is_distinguishable_from_an_idle_one() {
711
+ // `rows_seen` is what tells "goose is not running" apart from "goose is
712
+ // running and we are dropping everything it writes".
713
+ let db = Db::new("rowsseen");
714
+ db.session("20260803_1", "/home/u/repo", "hidden");
715
+ db.message("20260803_1", "user", &text("scratch"), T0);
716
+
717
+ let out = db.poll(0);
718
+ assert!(out.events.is_empty());
719
+ assert_eq!(out.rows_seen, 1);
720
+ }
721
+
722
+ #[test]
723
+ fn re_reading_the_same_rows_produces_byte_identical_events() {
724
+ // The engine's contract, and the whole basis of the server's content-hash
725
+ // dedup: a crash between spooling and advancing the cursor must cost a
726
+ // re-ship that collapses, not a duplicated session.
727
+ let db = Db::new("determinism");
728
+ db.session("20260803_1", "/home/u/repo", "user");
729
+ db.session("20260803_2", "/home/u/repo", "user");
730
+ db.message("20260803_1", "user", &text("hi"), T0);
731
+ db.message(
732
+ "20260803_1",
733
+ "assistant",
734
+ &tool_request("t1", "shell", "ls"),
735
+ T0,
736
+ );
737
+ db.message("20260803_1", "user", &tool_response("t1", "total 4"), T0);
738
+ db.message("20260803_2", "user", &text("next"), T0 + 60);
739
+
740
+ let a = serde_json::to_string(&db.poll(0).events).unwrap();
741
+ let b = serde_json::to_string(&db.poll(0).events).unwrap();
742
+ assert_eq!(a, b);
743
+ }
744
+
745
+ #[test]
746
+ fn two_identical_rows_produce_different_events() {
747
+ // The row id is the dedup discriminator. Without it the server collapses a
748
+ // genuinely repeated turn — the same command run twice — into one row.
749
+ let db = Db::new("dedup");
750
+ db.session("20260803_1", "/home/u/repo", "user");
751
+ let first = db.message("20260803_1", "user", &text("again"), T0);
752
+ let second = db.message("20260803_1", "user", &text("again"), T0);
753
+
754
+ let ev = db.poll(0).events;
755
+ let requests = of_type(&ev, "model_request");
756
+ assert_eq!(requests.len(), 2);
757
+ assert_eq!(requests[0]["goose_row_id"], first);
758
+ assert_eq!(requests[1]["goose_row_id"], second);
759
+ assert_ne!(requests[0]["timestamp"], requests[1]["timestamp"]);
760
+ assert_ne!(
761
+ serde_json::to_string(requests[0]).unwrap(),
762
+ serde_json::to_string(requests[1]).unwrap()
763
+ );
764
+ }
765
+
766
+ // ── timestamps and usage ─────────────────────────────────────────────────
767
+
768
+ #[test]
769
+ fn whole_second_timestamps_render_with_six_sub_second_digits() {
770
+ // Ingest parses one shape. goose stores seconds, the hook source emits
771
+ // microseconds, and the two share a session timeline — a variable-precision
772
+ // string sorts wrongly against the other stream.
773
+ let db = Db::new("tsformat");
774
+ db.session("20260803_1", "/home/u/repo", "user");
775
+ db.message("20260803_1", "user", &text("hi"), T0);
776
+
777
+ let ev = db.poll(0).events;
778
+ let ts = one(&ev, "model_request")["timestamp"].as_str().unwrap();
779
+ assert!(ts.starts_with("2026-08-03T07:56:57."), "got {ts}");
780
+ assert_eq!(ts.len(), "2026-08-03T07:56:57.000000Z".len(), "got {ts}");
781
+ assert!(ts.ends_with('Z'));
782
+ }
783
+
784
+ #[test]
785
+ fn a_millisecond_timestamp_lands_on_the_same_wall_clock_as_a_second_one() {
786
+ // The four SQLite agents disagree on units, so the engine infers them from
787
+ // magnitude. A goose release that switched `created_timestamp` to
788
+ // milliseconds would otherwise date every session to 1970.
789
+ let db = Db::new("tsunits");
790
+ db.session("20260803_1", "/home/u/repo", "user");
791
+ db.message("20260803_1", "user", &text("seconds"), T0);
792
+ db.message("20260803_1", "user", &text("millis"), T0 * 1000);
793
+
794
+ let ev = db.poll(0).events;
795
+ let requests = of_type(&ev, "model_request");
796
+ assert_eq!(requests.len(), 2);
797
+ let day_and_second = |v: &Value| v["timestamp"].as_str().unwrap()[..19].to_string();
798
+ assert_eq!(day_and_second(requests[0]), day_and_second(requests[1]));
799
+ assert_eq!(day_and_second(requests[0]), "2026-08-03T07:56:57");
800
+ }
801
+
802
+ #[test]
803
+ fn an_unusable_timestamp_drops_the_row_rather_than_dating_it_to_1970() {
804
+ // A 1970 event parks at the head of every timeline it appears on, which is
805
+ // far more visible than one missing row — and the row still advances the
806
+ // watermark, so the poller does not stall on it.
807
+ let db = Db::new("badts");
808
+ db.session("20260803_1", "/home/u/repo", "user");
809
+ let bad = db.message("20260803_1", "user", &text("no time"), 0);
810
+
811
+ let out = db.poll(0);
812
+ assert!(out.events.is_empty(), "{:?}", types(&out.events));
813
+ assert_eq!(out.watermark, bad);
814
+ }
815
+
816
+ #[test]
817
+ fn token_usage_comes_from_metadata_json_not_the_null_tokens_column() {
818
+ // `messages.tokens` was NULL for every row of the live corpus; reading it
819
+ // would report zero tokens for every session in the product.
820
+ let db = Db::new("usage");
821
+ db.session("20260803_1", "/home/u/repo", "user");
822
+ db.message_with_meta(
823
+ "20260803_1",
824
+ "assistant",
825
+ &text("done"),
826
+ T0,
827
+ Some(
828
+ r#"{"userVisible":true,"agentVisible":true,"usage":{"inputTokens":10165,
829
+ "outputTokens":61,"totalTokens":10226,"cost":0.03141}}"#,
830
+ ),
831
+ );
832
+
833
+ let ev = db.poll(0).events;
834
+ let resp = one(&ev, "model_response");
835
+ assert_eq!(resp["input_tokens"], 10165);
836
+ assert_eq!(resp["output_tokens"], 61);
837
+ assert_eq!(resp["goose_usage"]["totalTokens"], 10226);
838
+ }
839
+
840
+ #[test]
841
+ fn usage_is_billed_once_per_row_rather_than_once_per_block() {
842
+ // The usage object belongs to the row, not to each block in it. Attaching
843
+ // it per block multiplies a session's token total by its block count.
844
+ let db = Db::new("usageonce");
845
+ db.session("20260803_1", "/home/u/repo", "user");
846
+ db.message_with_meta(
847
+ "20260803_1",
848
+ "assistant",
849
+ &json!([{"type":"text","text":"one"},{"type":"text","text":"two"}]),
850
+ T0,
851
+ Some(r#"{"usage":{"inputTokens":10,"outputTokens":2}}"#),
852
+ );
853
+
854
+ let ev = db.poll(0).events;
855
+ let billed = ev
856
+ .iter()
857
+ .filter(|e| e.get("input_tokens").is_some())
858
+ .count();
859
+ assert_eq!(billed, 1, "usage must be billed to exactly one event");
860
+ }
861
+
862
+ // ── engine ───────────────────────────────────────────────────────────────
863
+
864
+ fn spec(db: &Path, spool: PathBuf, state: PathBuf) -> Spec {
865
+ Spec {
866
+ format: goose::FORMAT,
867
+ db_path: db.to_path_buf(),
868
+ spool_dir: spool,
869
+ state_dir: state,
870
+ poll_interval: Duration::from_millis(200),
871
+ params: Params {
872
+ agent_id: goose::DEFAULT_AGENT_ID.into(),
873
+ environment: "local".into(),
874
+ max_rows_per_poll: 500,
875
+ max_batch_bytes: 8 * 1024 * 1024,
876
+ max_drain_passes: 8,
877
+ },
878
+ }
879
+ }
880
+
881
+ fn spooled(dir: &Path) -> Vec<Value> {
882
+ let mut out = Vec::new();
883
+ let Ok(rd) = fs::read_dir(dir) else {
884
+ return out;
885
+ };
886
+ for e in rd.filter_map(|e| e.ok()) {
887
+ let p = e.path();
888
+ if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
889
+ continue;
890
+ }
891
+ for l in fs::read_to_string(&p).unwrap().lines() {
892
+ if !l.trim().is_empty() {
893
+ out.push(serde_json::from_str(l).unwrap());
894
+ }
895
+ }
896
+ }
897
+ out
898
+ }
899
+
900
+ async fn run_briefly(s: Spec, ms: u64) {
901
+ let sd = Shutdown::for_test(Arc::new(AtomicBool::new(false)));
902
+ let _ = tokio::time::timeout(Duration::from_millis(ms), sqlitepoll::run(s, sd)).await;
903
+ }
904
+
905
+ #[tokio::test(flavor = "multi_thread")]
906
+ async fn the_engine_spools_a_session_and_a_resumed_run_ships_nothing() {
907
+ let db = Db::new("engine");
908
+ db.session("20260803_1", "/home/u/repo", "user");
909
+ db.session("20260803_2", "/home/u/repo", "user");
910
+ db.message("20260803_1", "user", &text("list the files"), T0);
911
+ db.message(
912
+ "20260803_1",
913
+ "assistant",
914
+ &tool_request("t1", "shell", "ls"),
915
+ T0 + 1,
916
+ );
917
+ db.message(
918
+ "20260803_1",
919
+ "user",
920
+ &tool_response("t1", "total 4"),
921
+ T0 + 2,
922
+ );
923
+ db.message("20260803_2", "user", &text("next session"), T0 + 60);
924
+
925
+ let spool = tmpdir("engine-spool");
926
+ let state = tmpdir("engine-state");
927
+
928
+ run_briefly(spec(&db.path, spool.clone(), state.clone()), 1200).await;
929
+ let ev = spooled(&spool);
930
+ let kinds = types(&ev);
931
+ for expected in [
932
+ "agent_start",
933
+ "model_request",
934
+ "tool_use",
935
+ "tool_result",
936
+ "agent_end",
937
+ ] {
938
+ assert!(kinds.contains(&expected), "missing {expected} in {kinds:?}");
939
+ }
940
+
941
+ for e in fs::read_dir(&spool).unwrap().filter_map(|e| e.ok()) {
942
+ fs::remove_file(e.path()).ok();
943
+ }
944
+ // The cursor is durable, so a second run must not re-ship the history.
945
+ run_briefly(spec(&db.path, spool.clone(), state.clone()), 800).await;
946
+ assert!(
947
+ spooled(&spool).is_empty(),
948
+ "a resumed run must ship nothing: {:?}",
949
+ types(&spooled(&spool))
950
+ );
951
+
952
+ fs::remove_dir_all(&spool).ok();
953
+ fs::remove_dir_all(&state).ok();
954
+ }
955
+
956
+ #[tokio::test(flavor = "multi_thread")]
957
+ async fn rows_appended_after_a_run_are_picked_up_without_re_shipping_the_history() {
958
+ let db = Db::new("engine-append");
959
+ db.session("20260803_1", "/home/u/repo", "user");
960
+ db.message("20260803_1", "user", &text("first"), T0);
961
+
962
+ let spool = tmpdir("append-spool");
963
+ let state = tmpdir("append-state");
964
+ run_briefly(spec(&db.path, spool.clone(), state.clone()), 1200).await;
965
+ assert!(!spooled(&spool).is_empty());
966
+ for e in fs::read_dir(&spool).unwrap().filter_map(|e| e.ok()) {
967
+ fs::remove_file(e.path()).ok();
968
+ }
969
+
970
+ db.message("20260803_1", "user", &text("second"), T0 + 30);
971
+ run_briefly(spec(&db.path, spool.clone(), state.clone()), 1200).await;
972
+
973
+ let ev = spooled(&spool);
974
+ assert!(
975
+ ev.iter().any(|e| e["messages"][0]["content"] == "second"),
976
+ "the appended row must ship"
977
+ );
978
+ assert!(
979
+ !ev.iter().any(|e| e["messages"][0]["content"] == "first"),
980
+ "the earlier row must not be re-shipped"
981
+ );
982
+
983
+ fs::remove_dir_all(&spool).ok();
984
+ fs::remove_dir_all(&state).ok();
985
+ }
986
+
987
+ #[tokio::test(flavor = "multi_thread")]
988
+ async fn a_missing_database_is_treated_as_goose_not_being_installed() {
989
+ // The overwhelmingly common case on any given machine. It must not log an
990
+ // error, retry storms, or take the supervisor's task down.
991
+ let dir = tmpdir("absent");
992
+ let spool = tmpdir("absent-spool");
993
+ let state = tmpdir("absent-state");
994
+ let missing = dir.join("sessions").join("sessions.db");
995
+
996
+ run_briefly(spec(&missing, spool.clone(), state.clone()), 700).await;
997
+ assert!(spooled(&spool).is_empty());
998
+
999
+ fs::remove_dir_all(&dir).ok();
1000
+ fs::remove_dir_all(&spool).ok();
1001
+ fs::remove_dir_all(&state).ok();
1002
+ }
1003
+
1004
+ #[test]
1005
+ fn the_format_reads_rows_by_id_and_is_registered_as_goose() {
1006
+ assert_eq!(goose::FORMAT.kind, "goose");
1007
+ assert_eq!(goose::FORMAT.watermark, Watermark::RowId);
1008
+ }