Compare commits

..
Author SHA1 Message Date
KRATOS 1663cc25f7 session local-c9: claude-opus-5 — published session 2026-08-24 20:53:18 +05:30
KRATOS 5434d6b1b6 session local-c9: claude-opus-5 — published session 2026-08-24 20:42:17 +05:30
KRATOS f803e8473a session local-c9: claude-opus-5 — published session 2026-08-24 20:05:49 +05:30
KRATOS 0cb8a34075 session local-c9: claude-opus-5 — published session 2026-08-24 18:19:07 +05:30
KRATOS 8378c39e1d session local-c9: claude-opus-5 — published session 2026-08-24 14:28:49 +05:30
KRATOS 1340fa5ae1 session local-c9: claude-opus-5 — published session 2026-08-24 14:28:06 +05:30
KRATOS efaeabdb55 session local-c9: claude-opus-5 — published session 2026-08-24 14:28:01 +05:30
KRATOS 22e79d6fbe session local-c9: claude-opus-5 — published session 2026-08-24 14:27:56 +05:30
KRATOS b65d2f1177 session local-c9: claude-opus-5 — published session 2026-08-24 14:27:51 +05:30
KRATOS eaa1617425 session local-c9: claude-opus-5 — published session 2026-08-24 14:27:45 +05:30
KRATOS 362253b58c session local-c9: claude-opus-5 — published session 2026-08-24 14:27:40 +05:30
KRATOS e74ed3eaf9 session local-c9: claude-opus-5 — published session 2026-08-24 14:27:35 +05:30
KRATOS cff3245568 session local-c9: claude-opus-5 — published session 2026-08-24 14:27:33 +05:30
KRATOS 973c8ca97a session local-c9: claude-opus-5 — published session 2026-08-24 14:27:27 +05:30
KRATOS 652690c1e5 session local-c9: claude-opus-5 — published session 2026-08-24 14:27:23 +05:30
KRATOS ce2e5c79d0 session local-c9: claude-opus-5 — published session 2026-08-24 14:19:56 +05:30
KRATOS 738ab547cb session local-c9: claude-opus-5 — published session 2026-08-24 14:19:24 +05:30
KRATOS bf76da2683 session local-c9: claude-opus-5 — published session 2026-08-24 14:19:19 +05:30
KRATOS f0144b45fc session local-c9: claude-opus-5 — published session 2026-08-24 14:19:16 +05:30
KRATOS 9c5379adf5 session local-c9: claude-opus-5 — published session 2026-08-24 14:19:11 +05:30
KRATOS d466e7a072 session local-c9: claude-opus-5 — published session 2026-08-24 14:19:05 +05:30
KRATOS 159d2fb2a9 session local-c9: claude-opus-5 — published session 2026-08-24 14:18:59 +05:30
KRATOS f7f1f05b0e session local-c9: claude-opus-5 — published session 2026-08-24 14:18:57 +05:30
KRATOS cb32fda724 session local-c9: claude-opus-5 — published session 2026-08-24 14:18:53 +05:30
KRATOS b33b5f2aa8 session local-c9: claude-opus-5 — published session 2026-08-24 14:18:48 +05:30
KRATOS 6ce59e681e session local-c9: claude-opus-5 — published session 2026-08-24 14:18:47 +05:30
KRATOS 0c059f5048 session local-c9: claude-opus-5 — published session 2026-08-24 14:18:42 +05:30
KRATOS f0ba49c66e session local-c9: claude-opus-5 — published session 2026-08-24 14:18:33 +05:30
KRATOS 2a0fa271f2 session local-c9: claude-opus-5 — published session 2026-08-24 14:18:29 +05:30
KRATOS 03079e7f95 session local-c9: claude-opus-5 — published session 2026-08-24 14:14:18 +05:30
KRATOS ad63ce2de3 session local-c9: claude-opus-5 — published session 2026-08-24 14:13:52 +05:30
KRATOS 0f2f6955b1 session local-c9: claude-opus-5 — published session 2026-08-24 14:13:46 +05:30
KRATOS 122f98a07b session local-c9: claude-opus-5 — published session 2026-08-24 14:13:42 +05:30
KRATOS 797e3a26bc session local-c9: claude-opus-5 — published session 2026-08-24 14:13:39 +05:30
KRATOS 2840462c61 session local-c9: claude-opus-5 — published session 2026-08-24 14:13:33 +05:30
KRATOS ff99a5f286 session local-c9: claude-opus-5 — published session 2026-08-24 14:13:29 +05:30
KRATOS 1b6e6a7856 session local-c9: claude-opus-5 — published session 2026-08-24 14:13:25 +05:30
KRATOS 089dbd2e26 session local-c9: claude-opus-5 — published session 2026-08-24 14:13:18 +05:30
KRATOS 13de9d7ce7 session local-c9: claude-opus-5 — published session 2026-08-24 14:13:14 +05:30
KRATOS 838d11d8c1 session local-c9: claude-opus-5 — published session 2026-08-24 14:13:11 +05:30
KRATOS 89483d3fff session local-c9: claude-opus-5 — published session 2026-08-24 14:13:06 +05:30
KRATOS df1b7e007f session local-c9: claude-opus-5 — published session 2026-08-24 14:13:02 +05:30
KRATOS fa72b59619 session local-c9: claude-opus-5 — published session 2026-08-24 14:12:56 +05:30
KRATOS 7b6c9510e3 session local-c9: claude-opus-5 — published session 2026-08-24 14:12:52 +05:30
KRATOS c198560854 session local-c9: claude-opus-5 — published session 2026-08-24 14:09:18 +05:30
KRATOS 467992e17e session local-c9: claude-opus-5 — published session 2026-08-24 13:31:28 +05:30
KRATOS 2a211e9b67 session local-c9: claude-opus-5 — published session 2026-08-24 13:30:58 +05:30
KRATOS 98af8807c0 session local-c9: claude-opus-5 — published session 2026-08-24 13:30:51 +05:30
KRATOS 53a4eb2efc session local-c9: claude-opus-5 — published session 2026-08-24 13:30:47 +05:30
KRATOS d22d485da8 session local-c9: claude-opus-5 — published session 2026-08-24 13:30:43 +05:30
KRATOS 3f96681782 session local-c9: claude-opus-5 — published session 2026-08-24 13:30:37 +05:30
KRATOS 26a07f8632 session local-c9: claude-opus-5 — published session 2026-08-24 13:30:32 +05:30
KRATOS d00e3c87e1 session local-c9: claude-opus-5 — published session 2026-08-24 13:30:29 +05:30
KRATOS 68010f16ec session local-c9: claude-opus-5 — published session 2026-08-24 13:30:23 +05:30
KRATOS 1c74c9b316 session local-c9: claude-opus-5 — published session 2026-08-24 13:30:19 +05:30
KRATOS e3ed3e95a7 session local-c9: claude-opus-5 — published session 2026-08-24 13:29:35 +05:30
KRATOS d96c5a83b0 session local-c9: claude-opus-5 — published session 2026-08-24 13:29:06 +05:30
KRATOS 594aa56405 session local-c9: claude-opus-5 — published session 2026-08-24 13:28:59 +05:30
KRATOS 933c0060b0 session local-c9: claude-opus-5 — published session 2026-08-24 13:28:56 +05:30
KRATOS 21026086c3 session local-c9: claude-opus-5 — published session 2026-08-24 13:28:51 +05:30
KRATOS df466c5f6a session local-c9: claude-opus-5 — published session 2026-08-24 13:28:45 +05:30
KRATOS 85b3c86d5b session local-c9: claude-opus-5 — published session 2026-08-24 13:28:41 +05:30
KRATOS 0e1f25baf4 session local-c9: claude-opus-5 — published session 2026-08-24 11:17:51 +05:30
KRATOS d0f43ab309 session local-c9: claude-opus-5 — published session 2026-08-24 11:17:46 +05:30
KRATOS 93236dc0cb session local-a4: claude-opus-5 — published session 2026-08-21 21:23:45 +05:30
KRATOS 79dbdeed66 session local-a4: claude-opus-5 — published session 2026-08-21 21:18:39 +05:30
KRATOS 857e206443 session local-a4: claude-opus-5 — published session 2026-08-21 21:03:08 +05:30
KRATOS ff077b9c20 session local-13: claude-opus-5 — published session 2026-08-21 13:27:33 +05:30
KRATOS 5ea719f9a8 session local-13: claude-opus-5 — published session 2026-08-21 11:45:31 +05:30
KRATOS 926cd7b4b2 session local-13: claude-opus-5 — published session 2026-08-21 11:45:28 +05:30
KRATOS 957c71f480 session local-13: claude-opus-5 — published session 2026-08-21 11:45:24 +05:30
KRATOS 6dcccf5425 session local-13: claude-opus-5 — published session 2026-08-21 11:45:20 +05:30
KRATOS 8a3a5bc803 session local-13: claude-opus-5 — published session 2026-08-21 11:45:17 +05:30
KRATOS 27e685d885 session local-13: claude-opus-5 — published session 2026-08-21 11:45:12 +05:30
KRATOS fd520cee66 session local-13: claude-opus-5 — published session 2026-08-21 11:45:07 +05:30
KRATOS 7464a23b7a session local-13: claude-opus-5 — published session 2026-08-21 11:45:04 +05:30
KRATOS 9809a062cf session local-13: claude-opus-5 — published session 2026-08-21 11:44:59 +05:30
KRATOS 1a39474738 session local-13: claude-opus-5 — published session 2026-08-21 11:44:53 +05:30
KRATOS 5855e326b2 session local-13: claude-opus-5 — published session 2026-08-21 11:44:52 +05:30
KRATOS ff20f914a1 session local-13: claude-opus-5 — published session 2026-08-21 11:44:48 +05:30
KRATOS 299b012222 session local-13: claude-opus-5 — published session 2026-08-21 11:44:42 +05:30
KRATOS 1237c5669a session local-13: claude-opus-5 — published session 2026-08-21 11:44:37 +05:30
KRATOS 8e18f9f48d session local-13: claude-opus-5 — published session 2026-08-21 11:44:36 +05:30
KRATOS 3c6056e81e session local-13: claude-opus-5 — published session 2026-08-21 11:44:31 +05:30
KRATOS 83a9208edb session local-13: claude-opus-5 — published session 2026-08-21 11:44:25 +05:30
KRATOS 6f230b3431 session local-13: claude-opus-5 — published session 2026-08-21 11:44:24 +05:30
KRATOS 0987f5272a session local-13: claude-opus-5 — published session 2026-08-21 11:44:20 +05:30
KRATOS 321ee665c1 session local-13: claude-opus-5 — published session 2026-08-21 11:44:14 +05:30
KRATOS 446aca17f4 session local-13: claude-opus-5 — published session 2026-08-21 11:44:09 +05:30
KRATOS 0f1b605300 session local-13: claude-opus-5 — published session 2026-08-21 11:44:07 +05:30
KRATOS dd2b6ba03f session local-13: claude-opus-5 — published session 2026-08-21 11:44:01 +05:30
KRATOS faba98ea7e session local-13: claude-opus-5 — published session 2026-08-21 11:43:58 +05:30
KRATOS 0da0388d2f session local-13: claude-opus-5 — published session 2026-08-21 11:43:53 +05:30
KRATOS 5f298c68f2 session local-13: claude-opus-5 — published session 2026-08-21 11:43:49 +05:30
KRATOS c08ba3cef7 session local-13: claude-opus-5 — published session 2026-08-21 11:43:44 +05:30
KRATOS 19b52802c7 session local-13: claude-opus-5 — published session 2026-08-21 11:43:42 +05:30
KRATOS 80a695b596 session local-13: claude-opus-5 — published session 2026-08-21 11:43:38 +05:30
KRATOS aa206fa5a4 session local-13: claude-opus-5 — published session 2026-08-21 11:43:33 +05:30
KRATOS 0cbb45636f session local-13: claude-opus-5 — published session 2026-08-21 11:43:28 +05:30
KRATOS 6ea8e420a4 session local-13: claude-opus-5 — published session 2026-08-21 11:43:26 +05:30
KRATOS a8609b12b9 session local-13: claude-opus-5 — published session 2026-08-21 11:43:19 +05:30
KRATOS b77e820225 session local-13: claude-opus-5 — published session 2026-08-21 11:43:16 +05:30
KRATOS e0e7c2396b session local-13: claude-opus-5 — published session 2026-08-21 11:43:12 +05:30
KRATOS 969e959c5a session local-13: claude-opus-5 — published session 2026-08-21 11:43:06 +05:30
KRATOS f8ee037073 session local-13: claude-opus-5 — published session 2026-08-21 11:43:02 +05:30
KRATOS 2ef7eb3cda session local-13: claude-opus-5 — published session 2026-08-21 11:43:00 +05:30
KRATOS 5660f40de0 session local-13: claude-opus-5 — published session 2026-08-21 11:42:53 +05:30
KRATOS 458f71fd1d session local-13: claude-opus-5 — published session 2026-08-21 11:42:50 +05:30
KRATOS 679e7f8df4 session local-13: claude-opus-5 — published session 2026-08-21 11:42:47 +05:30
KRATOS 4d324265a4 session local-13: claude-opus-5 — published session 2026-08-21 11:42:39 +05:30
KRATOS e01a594d5e session local-13: claude-opus-5 — published session 2026-08-21 11:42:38 +05:30
KRATOS 2e8eaf7112 session local-13: claude-opus-5 — published session 2026-08-21 11:42:34 +05:30
KRATOS be0ef63b20 session local-13: claude-opus-5 — published session 2026-08-21 11:42:28 +05:30
KRATOS c968be278a session local-13: claude-opus-5 — published session 2026-08-21 11:42:24 +05:30
KRATOS ad1249a0e3 session local-13: claude-opus-5 — published session 2026-08-21 11:42:22 +05:30
KRATOS 05ad939eab session local-13: claude-opus-5 — published session 2026-08-21 11:42:16 +05:30
KRATOS 4166cfc03b session local-13: claude-opus-5 — published session 2026-08-21 11:42:12 +05:30
KRATOS 97c3d357c6 session local-13: claude-opus-5 — published session 2026-08-21 11:42:09 +05:30
KRATOS 798423ca95 session local-13: claude-opus-5 — published session 2026-08-21 11:42:05 +05:30
KRATOS 7711eb96d5 session local-13: claude-opus-5 — published session 2026-08-21 11:42:00 +05:30
KRATOS a510789411 session local-13: claude-opus-5 — published session 2026-08-21 11:41:55 +05:30
KRATOS cf56ece043 session local-13: claude-opus-5 — published session 2026-08-21 11:41:53 +05:30
KRATOS 56f2f3d4a5 session local-13: claude-opus-5 — published session 2026-08-21 11:41:44 +05:30
KRATOS 9ef702c184 session local-13: claude-opus-5 — published session 2026-08-21 11:41:44 +05:30
KRATOS c8c94c69ea session local-13: claude-opus-5 — published session 2026-08-21 11:41:44 +05:30
KRATOS 1ffbea6a6a session local-13: claude-opus-5 — published session 2026-08-21 11:41:43 +05:30
KRATOS e48f04a07d session local-13: claude-opus-5 — published session 2026-08-21 11:41:43 +05:30
KRATOS 0c5b71e2e0 session local-13: claude-opus-5 — published session 2026-08-21 11:41:43 +05:30
KRATOS c6e48056e0 session local-13: claude-opus-5 — published session 2026-08-21 11:41:43 +05:30
KRATOS fd42b8b5b6 session local-13: claude-opus-5 — published session 2026-08-21 11:41:42 +05:30
KRATOS e80dabe34b session local-13: claude-opus-5 — published session 2026-08-21 11:41:40 +05:30
KRATOS 4aac44961d session local-13: claude-opus-5 — published session 2026-08-21 11:38:53 +05:30
KRATOS e54d707013 session local-13: claude-opus-5 — published session 2026-08-21 09:37:53 +05:30
KRATOS e541690e7d session local-13: claude-opus-5 — published session 2026-08-21 09:29:23 +05:30
KRATOS 3c97437f9b session local-13: claude-opus-5 — published session 2026-08-21 09:29:20 +05:30
KRATOS abc3d7a848 session local-13: claude-opus-5 — published session 2026-08-21 09:29:14 +05:30
KRATOS 98ee70f7f4 session local-13: claude-opus-5 — published session 2026-08-21 09:29:11 +05:30
KRATOS 94dad89b7c session local-13: claude-opus-5 — published session 2026-08-21 09:29:07 +05:30
KRATOS 355eae789d session local-13: claude-opus-5 — published session 2026-08-21 09:29:01 +05:30
KRATOS 7298d7d3b0 session local-13: claude-opus-5 — published session 2026-08-21 09:28:56 +05:30
KRATOS 81dede85db session local-13: claude-opus-5 — published session 2026-08-21 09:28:51 +05:30
KRATOS ee30c1be2a session local-13: claude-opus-5 — published session 2026-08-21 09:12:15 +05:30
KRATOS 03c06bf8a5 session local-13: claude-opus-5 — published session 2026-08-21 09:12:11 +05:30
KRATOS 82ee2da78f session local-13: claude-opus-5 — published session 2026-08-21 09:12:06 +05:30
KRATOS 295c543f5b session local-13: claude-opus-5 — published session 2026-08-21 09:12:05 +05:30
KRATOS 0ff7f66c05 session local-13: claude-opus-5 — published session 2026-08-21 09:11:59 +05:30
KRATOS d5e1f3bb80 session local-13: claude-opus-5 — published session 2026-08-21 09:11:55 +05:30
KRATOS 47bd0b5e5b session local-8c: claude-opus-5 — published session 2026-08-21 00:20:11 +05:30
KRATOS 8b598eb79f session local-8c: claude-opus-5 — published session 2026-08-21 00:11:09 +05:30
KRATOS 7ce0e9fafc session local-8c: claude-opus-5 — published session 2026-08-20 21:09:31 +05:30
KRATOS 27097c1bf5 session local-8c: claude-opus-5 — published session 2026-08-20 21:06:54 +05:30
KRATOS 5e8d94c068 session local-8c: claude-opus-5 — published session 2026-08-20 20:58:45 +05:30
KRATOS 2d790aaa4f session local-8c: claude-opus-5 — published session 2026-08-20 20:53:40 +05:30
KRATOS 8a6799967f session local-8c: claude-opus-5 — published session 2026-08-20 18:29:07 +05:30
KRATOS ae62e4d7ca session local-8c: claude-opus-5 — published session 2026-08-20 18:28:56 +05:30
KRATOS 4bfa5b0984 session local-8c: claude-opus-5 — published session 2026-08-20 17:17:13 +05:30
KRATOS a1d7ed18f9 session local-76: claude-opus-5 — published session 2026-08-19 18:40:30 +05:30
KRATOS 1d2960cfd3 session local-76: claude-opus-5 — published session 2026-08-19 18:37:28 +05:30
KRATOS dceb83b438 session local-ce: claude-opus-5 — published session 2026-08-19 10:26:14 +05:30
KRATOS 4a4cc6c27e session local-ce: claude-opus-5 — published session 2026-08-19 08:11:49 +05:30
KRATOS 2d4a911086 session local-79: claude-opus-5 — published session 2026-08-18 00:37:15 +05:30
KRATOS aab95fec03 session local-79: claude-opus-5 — published session 2026-08-18 00:29:59 +05:30
KRATOS a0e3f90591 session local-79: claude-opus-5 — published session 2026-08-18 00:22:28 +05:30
KRATOS 46afafc218 session local-ae: claude-opus-5 — published session 2026-08-17 12:03:08 +05:30
KRATOS c59260680c session local-ae: claude-opus-5 — published session 2026-08-17 11:57:18 +05:30
KRATOS 0489541c78 session local-ae: claude-opus-5 — published session 2026-08-17 11:50:54 +05:30
KRATOS 0be8bfdadd session local-ab: claude-opus-5 — published session 2026-08-16 23:04:27 +05:30
KRATOS fcd776c608 session local-ab: claude-opus-5 — published session 2026-08-16 23:03:54 +05:30
KRATOS a9d7056f29 session local-ab: claude-opus-5 — published session 2026-08-16 22:52:05 +05:30
KRATOS 0c9c5b9bd1 session local-ab: claude-opus-5 — published session 2026-08-16 22:49:40 +05:30
KRATOS 1b18f88290 session local-ab: claude-opus-5 — published session 2026-08-16 22:47:48 +05:30
KRATOS d7dd4aeb2d session local-ab: claude-opus-5 — published session 2026-08-16 22:45:01 +05:30
KRATOS d8d11b4884 session local-ab: claude-opus-5 — published session 2026-08-16 20:39:05 +05:30
KRATOS 6a4058324f session local-ab: claude-opus-5 — published session 2026-08-16 20:36:58 +05:30
KRATOS 8f7c20b5d6 session local-ab: claude-opus-5 — published session 2026-08-16 20:22:40 +05:30
KRATOS 75ca43777f session local-ab: claude-opus-5 — published session 2026-08-16 20:20:43 +05:30
KRATOS 6c1aa0b46a session local-ab: claude-opus-5 — published session 2026-08-16 20:04:56 +05:30
KRATOS c2b5c3b238 session local-ab: claude-opus-5 — published session 2026-08-16 20:02:37 +05:30
KRATOS e7e2385522 session local-ab: claude-opus-5 — published session 2026-08-16 20:02:11 +05:30
KRATOS ac4cd4c513 session local-ab: claude-opus-5 — published session 2026-08-16 19:57:02 +05:30
KRATOS 7d7ec95b47 session local-ab: claude-opus-5 — published session 2026-08-16 16:41:36 +05:30
KRATOS a6698d837a session local-ab: claude-opus-5 — published session 2026-08-16 16:38:17 +05:30
KRATOS 02729f4161 session local-ab: claude-opus-5 — published session 2026-08-16 16:36:15 +05:30
KRATOS 976bac14be session local-ab: claude-opus-5 — published session 2026-08-16 16:30:33 +05:30
KRATOS aaad061104 session local-ab: claude-opus-5 — published session 2026-08-16 16:27:28 +05:30
KRATOS a98ef8786c session local-ab: claude-opus-5 — published session 2026-08-16 16:18:22 +05:30
KRATOS 8d3ccbc20a session local-ab: claude-opus-5 — published session 2026-08-16 16:00:02 +05:30
KRATOS f3581f53dc session local-ab: claude-opus-5 — published session 2026-08-16 15:57:33 +05:30
KRATOS 5cfea35cdf session local-ab: claude-opus-5 — published session 2026-08-16 15:47:04 +05:30
KRATOS 482c8da43a session local-ab: claude-opus-5 — published session 2026-08-16 15:08:04 +05:30
KRATOS 8d6340bd8e session local-ab: claude-opus-5 — published session 2026-08-16 15:05:53 +05:30
KRATOS e8b0fefad1 session local-ab: claude-opus-5 — published session 2026-08-16 15:05:43 +05:30
KRATOS 515b41a065 session local-ab: claude-opus-5 — published session 2026-08-16 15:01:33 +05:30
KRATOS c65486c0d5 session local-ab: claude-opus-5 — published session 2026-08-16 14:58:35 +05:30
KRATOS aa2ab465f3 session local-ab: claude-opus-5 — published session 2026-08-16 14:52:13 +05:30
KRATOS 255c3c4fe1 session local-ab: claude-opus-5 — published session 2026-08-16 14:48:03 +05:30
KRATOS d590abc5c7 session local-04: claude-opus-5 — published session 2026-08-16 10:55:21 +05:30
KRATOS dbb050fb1d session local-04: claude-opus-5 — published session 2026-08-16 10:53:27 +05:30
KRATOS ed7d9653b7 session local-de: claude-opus-5 — published session 2026-08-15 21:06:31 +05:30
KRATOS 64fe4ec6c7 session local-de: claude-opus-5 — published session 2026-08-15 21:01:56 +05:30
KRATOS d8933013bb session local-de: claude-opus-5 — published session 2026-08-15 20:58:06 +05:30
KRATOS 676c9a18c0 session local-de: claude-opus-5 — published session 2026-08-15 16:17:04 +05:30
KRATOS 4124870d70 session local-de: claude-opus-5 — published session 2026-08-15 16:16:32 +05:30
KRATOS 3599bf95a3 session local-de: claude-opus-5 — published session 2026-08-15 16:07:04 +05:30
KRATOS d0634cc95c session local-de: claude-opus-5 — published session 2026-08-15 15:58:49 +05:30
KRATOS 0564d9ecea session local-de: claude-opus-5 — published session 2026-08-15 15:58:49 +05:30
KRATOS 1d8d5e8a4e session local-de: claude-opus-5 — published session 2026-08-15 15:58:49 +05:30
KRATOS 2a9e121adf session local-de: claude-opus-5 — published session 2026-08-15 15:58:48 +05:30
KRATOS bc70710caa session local-de: claude-opus-5 — published session 2026-08-15 15:58:45 +05:30
KRATOS 016e5f2c70 session local-de: claude-opus-5 — published session 2026-08-15 15:58:44 +05:30
KRATOS bd8b9dfbfe session local-de: claude-opus-5 — published session 2026-08-15 15:58:43 +05:30
KRATOS 1d78b430cb session local-de: claude-opus-5 — published session 2026-08-15 15:58:43 +05:30
KRATOS f35e957627 session local-de: claude-opus-5 — published session 2026-08-15 15:58:42 +05:30
KRATOS e4eddf0097 session local-de: claude-opus-5 — published session 2026-08-15 15:58:41 +05:30
KRATOS d478ffe5e7 session local-de: claude-opus-5 — published session 2026-08-15 15:58:41 +05:30
KRATOS d14c0df801 session local-de: claude-opus-5 — published session 2026-08-15 15:58:40 +05:30
KRATOS 2bbac5b7c6 session local-de: claude-opus-5 — published session 2026-08-15 15:58:39 +05:30
KRATOS c8828822e4 session local-de: claude-opus-5 — published session 2026-08-15 15:58:38 +05:30
KRATOS 07ceda46b3 session local-de: claude-opus-5 — published session 2026-08-15 15:58:37 +05:30
KRATOS 7e5e0ff5f9 session local-de: claude-opus-5 — published session 2026-08-15 15:58:36 +05:30
KRATOS 4004ce09f3 session local-de: claude-opus-5 — published session 2026-08-15 15:58:33 +05:30
KRATOS ab986b8251 session local-de: claude-opus-5 — published session 2026-08-15 15:58:33 +05:30
KRATOS 8319dfffe5 session local-de: claude-opus-5 — published session 2026-08-15 15:58:31 +05:30
KRATOS f41826b3fe session local-de: claude-opus-5 — published session 2026-08-15 15:58:30 +05:30
KRATOS 5fab7bc01e session local-de: claude-opus-5 — published session 2026-08-15 15:58:30 +05:30
KRATOS bcb05c7aea session local-de: claude-opus-5 — published session 2026-08-15 15:58:30 +05:30
KRATOS cf1fb1a399 session local-de: claude-opus-5 — published session 2026-08-15 15:58:26 +05:30
KRATOS d2dea772ea session local-de: claude-opus-5 — published session 2026-08-15 15:58:24 +05:30
KRATOS ebbab956fa session local-de: claude-opus-5 — published session 2026-08-15 15:58:17 +05:30
KRATOS b3c00588c1 session local-de: claude-opus-5 — published session 2026-08-15 15:58:15 +05:30
KRATOS 20d2c420ff session local-de: claude-opus-5 — published session 2026-08-15 15:58:14 +05:30
KRATOS 4fa9e906dd session local-de: claude-opus-5 — published session 2026-08-15 15:58:13 +05:30
KRATOS 44346dfbce session local-de: claude-opus-5 — published session 2026-08-15 15:58:06 +05:30
KRATOS 6ec5276807 session local-de: claude-opus-5 — published session 2026-08-15 15:58:05 +05:30
KRATOS 4ccdc3ac5f session local-de: claude-opus-5 — published session 2026-08-15 15:58:05 +05:30
KRATOS 34d3c90a7e session local-de: claude-opus-5 — published session 2026-08-15 15:58:02 +05:30
KRATOS 14ce7fe9e5 session local-de: claude-opus-5 — published session 2026-08-15 15:57:55 +05:30
KRATOS 8d741c30c8 session local-de: claude-opus-5 — published session 2026-08-15 15:57:54 +05:30
KRATOS 6e19bb8197 session local-de: claude-opus-5 — published session 2026-08-15 15:57:54 +05:30
KRATOS b42086c13a session local-de: claude-opus-5 — published session 2026-08-15 15:57:53 +05:30
KRATOS 99c7c71590 session local-de: claude-opus-5 — published session 2026-08-15 15:57:49 +05:30
KRATOS 431326de86 session local-de: claude-opus-5 — published session 2026-08-15 15:57:47 +05:30
KRATOS 74519a5195 session local-de: claude-opus-5 — published session 2026-08-15 15:57:43 +05:30
KRATOS b8ef3202e6 session local-de: claude-opus-5 — published session 2026-08-15 15:57:39 +05:30
KRATOS e673da2399 session local-de: claude-opus-5 — published session 2026-08-15 15:57:38 +05:30
KRATOS dcf3fb103f session local-de: claude-opus-5 — published session 2026-08-15 15:57:37 +05:30
KRATOS 4b291b649e session local-de: claude-opus-5 — published session 2026-08-15 15:57:34 +05:30
KRATOS 7748d912e0 session local-de: claude-opus-5 — published session 2026-08-15 15:57:27 +05:30
KRATOS 656fc27f3f session local-de: claude-opus-5 — published session 2026-08-15 15:57:25 +05:30
KRATOS f6054c2626 session local-de: claude-opus-5 — published session 2026-08-15 15:57:24 +05:30
KRATOS 5d122996a2 session local-de: claude-opus-5 — published session 2026-08-15 15:57:24 +05:30
KRATOS 452cfaa3e0 session local-de: claude-opus-5 — published session 2026-08-15 15:57:23 +05:30
KRATOS 8540cb58f3 session local-de: claude-opus-5 — published session 2026-08-15 15:57:22 +05:30
KRATOS 528a6cacc2 session local-de: claude-opus-5 — published session 2026-08-15 15:57:20 +05:30
KRATOS 5a7aff38e3 session local-de: claude-opus-5 — published session 2026-08-15 15:57:13 +05:30
KRATOS 26c3b74d9a session local-de: claude-opus-5 — published session 2026-08-15 15:57:12 +05:30
KRATOS 9d2aa78984 session local-de: claude-opus-5 — published session 2026-08-15 15:57:09 +05:30
KRATOS ca2e1f8f73 session local-de: claude-opus-5 — published session 2026-08-15 15:57:04 +05:30
KRATOS 027b9ed70a session local-de: claude-opus-5 — published session 2026-08-15 15:57:03 +05:30
KRATOS f17b2f35f3 session local-de: claude-opus-5 — published session 2026-08-15 15:57:03 +05:30
KRATOS 30f589ed44 session local-de: claude-opus-5 — published session 2026-08-15 15:56:57 +05:30
KRATOS 869ce1f84d session local-de: claude-opus-5 — published session 2026-08-15 15:56:53 +05:30
KRATOS eda0f569e5 session local-de: claude-opus-5 — published session 2026-08-15 15:56:52 +05:30
KRATOS ee053e4fa6 session local-de: claude-opus-5 — published session 2026-08-15 15:56:48 +05:30
KRATOS 4a235fe241 session local-de: claude-opus-5 — published session 2026-08-15 15:56:45 +05:30
KRATOS eef969b30d session local-de: claude-opus-5 — published session 2026-08-15 15:56:42 +05:30
KRATOS f72bd5d773 session local-de: claude-opus-5 — published session 2026-08-15 15:56:42 +05:30
KRATOS 498fc58a52 session local-de: claude-opus-5 — published session 2026-08-15 15:56:40 +05:30
KRATOS 62dfcd0cbf session local-de: claude-opus-5 — published session 2026-08-15 15:56:38 +05:30
KRATOS 343607d0b8 session local-de: claude-opus-5 — published session 2026-08-15 15:56:36 +05:30
KRATOS 5828a71b1e session local-de: claude-opus-5 — published session 2026-08-15 15:56:35 +05:30
KRATOS 5781f232d7 session local-de: claude-opus-5 — published session 2026-08-15 15:56:34 +05:30
KRATOS 1ba9d47f78 session local-de: claude-opus-5 — published session 2026-08-15 15:56:33 +05:30
KRATOS 346a5bc9cb session local-de: claude-opus-5 — published session 2026-08-15 15:56:31 +05:30
KRATOS b5513522c7 session local-de: claude-opus-5 — published session 2026-08-15 15:56:27 +05:30
KRATOS a666d8f3a0 session local-de: claude-opus-5 — published session 2026-08-15 15:56:26 +05:30
KRATOS 56c5104df9 session local-de: claude-opus-5 — published session 2026-08-15 15:56:22 +05:30
KRATOS dc36ae0be7 session local-de: claude-opus-5 — published session 2026-08-15 15:56:17 +05:30
KRATOS be626a96ee session local-de: claude-opus-5 — published session 2026-08-15 15:56:16 +05:30
KRATOS 5cba0e200a session local-de: claude-opus-5 — published session 2026-08-15 15:56:15 +05:30
KRATOS f575231d2c session local-de: claude-opus-5 — published session 2026-08-15 15:56:14 +05:30
KRATOS f6c032614a session local-de: claude-opus-5 — published session 2026-08-15 15:56:13 +05:30
KRATOS d53903b7c7 session local-de: claude-opus-5 — published session 2026-08-15 15:56:13 +05:30
KRATOS 0dfecaf0b3 session local-de: claude-opus-5 — published session 2026-08-15 15:56:11 +05:30
KRATOS 10f8adb735 session local-de: claude-opus-5 — published session 2026-08-15 15:56:11 +05:30
KRATOS 7292d24738 session local-de: claude-opus-5 — published session 2026-08-15 15:56:06 +05:30
KRATOS 6a58992570 session local-de: claude-opus-5 — published session 2026-08-15 15:56:04 +05:30
KRATOS 9c8d1073ed session local-de: claude-opus-5 — published session 2026-08-15 15:56:02 +05:30
KRATOS ef15310379 session local-de: claude-opus-5 — published session 2026-08-15 15:55:58 +05:30
KRATOS 19a38a06fb session local-de: claude-opus-5 — published session 2026-08-15 15:55:57 +05:30
KRATOS f09f1b69af session local-de: claude-opus-5 — published session 2026-08-15 15:55:54 +05:30
KRATOS a5bc6229bd session local-de: claude-opus-5 — published session 2026-08-15 15:55:53 +05:30
KRATOS 700b8c3fc6 session local-de: claude-opus-5 — published session 2026-08-15 15:55:50 +05:30
KRATOS 39bd7a27d8 session local-de: claude-opus-5 — published session 2026-08-15 15:55:48 +05:30
KRATOS 7a7144cf62 session local-de: claude-opus-5 — published session 2026-08-15 15:55:41 +05:30
KRATOS 3e154dab26 session local-de: claude-opus-5 — published session 2026-08-15 15:55:37 +05:30
KRATOS 51db9fcc99 session local-de: claude-opus-5 — published session 2026-08-15 15:55:34 +05:30
KRATOS ce05474725 session local-de: claude-opus-5 — published session 2026-08-15 15:55:29 +05:30
KRATOS fb0fd62b74 session local-de: claude-opus-5 — published session 2026-08-15 15:55:20 +05:30
KRATOS 798802b9e5 session local-de: claude-opus-5 — published session 2026-08-15 15:55:15 +05:30
KRATOS 0178f4df8e session local-de: claude-opus-5 — published session 2026-08-15 15:55:11 +05:30
KRATOS ac4cf93772 session local-de: claude-opus-5 — published session 2026-08-15 15:55:08 +05:30
KRATOS 1f51067e34 session local-de: claude-opus-5 — published session 2026-08-15 15:54:59 +05:30
KRATOS 6699ea0987 session local-de: claude-opus-5 — published session 2026-08-15 15:54:51 +05:30
KRATOS d799d546db session local-de: claude-opus-5 — published session 2026-08-15 15:54:50 +05:30
KRATOS cc0b3934d2 session local-de: claude-opus-5 — published session 2026-08-15 15:54:49 +05:30
KRATOS 116f064ee5 session local-de: claude-opus-5 — published session 2026-08-15 15:54:48 +05:30
KRATOS d4f13d3547 session local-de: claude-opus-5 — published session 2026-08-15 15:54:46 +05:30
KRATOS 22670503db session local-de: claude-opus-5 — published session 2026-08-15 15:54:37 +05:30
KRATOS cf66e5f471 session local-de: claude-opus-5 — published session 2026-08-15 15:54:36 +05:30
KRATOS 77e41f958e session local-de: claude-opus-5 — published session 2026-08-15 15:54:31 +05:30
KRATOS 299955d98d session local-de: claude-opus-5 — published session 2026-08-15 15:54:30 +05:30
KRATOS 55728e1335 session local-de: claude-opus-5 — published session 2026-08-15 15:54:26 +05:30
KRATOS 2c0d49c5cc session local-de: claude-opus-5 — published session 2026-08-15 15:54:24 +05:30
KRATOS ee3913ce0d session local-de: claude-opus-5 — published session 2026-08-15 15:54:13 +05:30
KRATOS 84b60292f1 session local-de: claude-opus-5 — published session 2026-08-15 15:54:10 +05:30
KRATOS dba56ebe4f session local-de: claude-opus-5 — published session 2026-08-15 15:53:48 +05:30
KRATOS bad458ced9 session local-de: claude-opus-5 — published session 2026-08-15 15:53:42 +05:30
KRATOS d3140c986c session local-de: claude-opus-5 — published session 2026-08-15 15:53:40 +05:30
KRATOS b893d00cd2 session local-de: claude-opus-5 — published session 2026-08-15 15:53:39 +05:30
KRATOS b68b423565 session local-de: claude-opus-5 — published session 2026-08-15 15:53:33 +05:30
KRATOS bd158253a2 session local-de: claude-opus-5 — published session 2026-08-15 15:53:31 +05:30
KRATOS feb9b1c56b session local-de: claude-opus-5 — published session 2026-08-15 15:53:24 +05:30
KRATOS f81ee9b2af session local-de: claude-opus-5 — published session 2026-08-15 15:53:23 +05:30
KRATOS 4390205b7f session local-de: claude-opus-5 — published session 2026-08-15 15:53:22 +05:30
KRATOS 93e1b026b2 session local-de: claude-opus-5 — published session 2026-08-15 15:53:21 +05:30
KRATOS c6e8e5bd8a session local-de: claude-opus-5 — published session 2026-08-15 15:53:20 +05:30
KRATOS 22485db130 session local-de: claude-opus-5 — published session 2026-08-15 15:53:18 +05:30
KRATOS e2b74afcdb session local-de: claude-opus-5 — published session 2026-08-15 15:53:15 +05:30
KRATOS d43c368830 session local-de: claude-opus-5 — published session 2026-08-15 15:53:12 +05:30
KRATOS 4fc8aca8e0 session local-de: claude-opus-5 — published session 2026-08-15 15:53:10 +05:30
KRATOS cb0391bbeb session local-de: claude-opus-5 — published session 2026-08-15 15:53:09 +05:30
KRATOS d11714e1db session local-de: claude-opus-5 — published session 2026-08-15 15:52:59 +05:30
KRATOS a8f08f421a session local-de: claude-opus-5 — published session 2026-08-15 15:52:58 +05:30
KRATOS 177c029a88 session local-de: claude-opus-5 — published session 2026-08-15 15:52:57 +05:30
KRATOS f04d3c0cd8 session local-de: claude-opus-5 — published session 2026-08-15 15:52:49 +05:30
KRATOS f5da40fdb5 session local-de: claude-opus-5 — published session 2026-08-15 15:51:34 +05:30
KRATOS 790bac5eb2 session local-de: claude-opus-5 — published session 2026-08-15 15:51:15 +05:30
KRATOS 9625b3439c session local-de: claude-opus-5 — published session 2026-08-15 15:51:10 +05:30
KRATOS 861458e0d4 session local-de: claude-opus-5 — published session 2026-08-15 15:50:27 +05:30
KRATOS 5733e3c530 session local-de: claude-opus-5 — published session 2026-08-15 15:40:27 +05:30
KRATOS b2d8fd1bee session local-de: claude-opus-5 — published session 2026-08-15 15:36:57 +05:30
KRATOS 7eb8c16b98 session local-de: claude-opus-5 — published session 2026-08-15 15:36:48 +05:30
KRATOS a784253c19 session local-de: claude-opus-5 — published session 2026-08-15 15:36:44 +05:30
KRATOS efeb385ceb session local-de: claude-opus-5 — published session 2026-08-15 15:36:36 +05:30
KRATOS fecb51f069 session local-de: claude-opus-5 — published session 2026-08-15 15:36:25 +05:30
KRATOS 0a3c35e686 session local-de: claude-opus-5 — published session 2026-08-15 15:36:17 +05:30
KRATOS 59e6cea925 session local-de: claude-opus-5 — published session 2026-08-15 15:36:10 +05:30
KRATOS 7c7d6b2d5b session local-de: claude-opus-5 — published session 2026-08-15 15:35:58 +05:30
KRATOS 8ae00c0d78 session local-de: claude-opus-5 — published session 2026-08-15 15:35:49 +05:30
KRATOS d3e7517e19 session local-de: claude-opus-5 — published session 2026-08-15 15:35:42 +05:30
KRATOS 13a1996b01 session local-de: claude-opus-5 — published session 2026-08-15 15:35:34 +05:30
KRATOS 9aef72da30 session local-de: claude-opus-5 — published session 2026-08-15 15:35:21 +05:30
KRATOS c3fb330be8 session local-de: claude-opus-5 — published session 2026-08-15 15:35:14 +05:30
KRATOS 7fe058d3a0 session local-de: claude-opus-5 — published session 2026-08-15 15:35:07 +05:30
KRATOS c359c4040d session local-de: claude-opus-5 — published session 2026-08-15 15:34:57 +05:30
KRATOS 9f60c961db session local-de: claude-opus-5 — published session 2026-08-15 15:34:45 +05:30
KRATOS 70c84b91fd session local-de: claude-opus-5 — published session 2026-08-15 15:34:38 +05:30
KRATOS db0de38120 session local-de: claude-opus-5 — published session 2026-08-15 15:34:30 +05:30
KRATOS af0705ff77 session local-de: claude-opus-5 — published session 2026-08-15 15:34:21 +05:30
KRATOS 3a4856155d session local-de: claude-opus-5 — published session 2026-08-15 15:34:12 +05:30
KRATOS b403dd9215 session local-de: claude-opus-5 — published session 2026-08-15 15:34:02 +05:30
KRATOS 4408a95654 session local-de: claude-opus-5 — published session 2026-08-15 15:33:56 +05:30
KRATOS 5065046072 session local-de: claude-opus-5 — published session 2026-08-15 15:33:45 +05:30
KRATOS f4af0e9a7c session local-de: claude-opus-5 — published session 2026-08-15 15:33:38 +05:30
KRATOS 211fc2d741 session local-de: claude-opus-5 — published session 2026-08-15 15:33:26 +05:30
KRATOS 444fe4c1be session local-de: claude-opus-5 — published session 2026-08-15 15:33:14 +05:30
KRATOS 97457467ee session local-de: claude-opus-5 — published session 2026-08-15 15:32:59 +05:30
KRATOS 64076d935e session local-de: claude-opus-5 — published session 2026-08-15 15:32:45 +05:30
KRATOS a1aeaba4d4 session local-de: claude-opus-5 — published session 2026-08-15 15:32:23 +05:30
KRATOS 435804ec80 session local-de: claude-opus-5 — published session 2026-08-15 15:32:12 +05:30
KRATOS dbc88b08c7 session local-de: claude-opus-5 — published session 2026-08-15 15:31:58 +05:30
KRATOS f3e3502877 session local-de: claude-opus-5 — published session 2026-08-15 15:31:51 +05:30
KRATOS fbdf800830 session local-de: claude-opus-5 — published session 2026-08-15 15:31:51 +05:30
KRATOS 18a67c8435 session local-de: claude-opus-5 — published session 2026-08-15 15:31:38 +05:30
KRATOS 2876a0a98f session local-de: claude-opus-5 — published session 2026-08-15 15:31:30 +05:30
KRATOS 3b63dbc0cb session local-de: claude-opus-5 — published session 2026-08-15 15:31:15 +05:30
KRATOS 5635cde896 session local-de: claude-opus-5 — published session 2026-08-15 15:31:08 +05:30
KRATOS 2a52fb745b session local-de: claude-opus-5 — published session 2026-08-15 15:30:57 +05:30
KRATOS 26e0b018fa session local-de: claude-opus-5 — published session 2026-08-15 15:30:50 +05:30
KRATOS 7961ddcebb session local-de: claude-opus-5 — published session 2026-08-15 15:30:41 +05:30
KRATOS 1c328c4346 session local-de: claude-opus-5 — published session 2026-08-15 15:30:36 +05:30
KRATOS a4e4c51684 session local-de: claude-opus-5 — published session 2026-08-15 15:30:30 +05:30
KRATOS 1918a9c6af session local-de: claude-opus-5 — published session 2026-08-15 15:30:24 +05:30
KRATOS 8e4652dc76 session local-de: claude-opus-5 — published session 2026-08-15 15:30:18 +05:30
KRATOS dfe2976483 session local-de: claude-opus-5 — published session 2026-08-15 15:30:09 +05:30
KRATOS c0c8787e3c session local-de: claude-opus-5 — published session 2026-08-15 15:30:06 +05:30
KRATOS 59c2ba7c62 session local-de: claude-opus-5 — published session 2026-08-15 15:29:53 +05:30
KRATOS a410311b6c session local-de: claude-opus-5 — published session 2026-08-15 15:29:44 +05:30
KRATOS 6471947588 session local-de: claude-opus-5 — published session 2026-08-15 15:29:39 +05:30
KRATOS 7680479db4 session local-de: claude-opus-5 — published session 2026-08-15 15:29:29 +05:30
KRATOS 9dbb46c230 session local-de: claude-opus-5 — published session 2026-08-15 15:29:20 +05:30
KRATOS 411c255f0d session local-de: claude-opus-5 — published session 2026-08-15 15:29:10 +05:30
KRATOS 920410dcab session local-de: claude-opus-5 — published session 2026-08-15 15:28:57 +05:30
KRATOS 4ae71af794 session local-de: claude-opus-5 — published session 2026-08-15 15:28:50 +05:30
KRATOS 0036fc6384 session local-de: claude-opus-5 — published session 2026-08-15 15:28:41 +05:30
KRATOS a637568d79 session local-de: claude-opus-5 — published session 2026-08-15 15:28:33 +05:30
KRATOS d3bd1c3317 session local-de: claude-opus-5 — published session 2026-08-15 15:28:26 +05:30
KRATOS 5a48380d04 session local-de: claude-opus-5 — published session 2026-08-15 15:28:18 +05:30
KRATOS 67a765141c session local-de: claude-opus-5 — published session 2026-08-15 15:28:13 +05:30
KRATOS 927f4c1093 session local-de: claude-opus-5 — published session 2026-08-15 15:28:05 +05:30
KRATOS 14c8f54a6e session local-de: claude-opus-5 — published session 2026-08-15 15:27:59 +05:30
KRATOS ac17900017 session local-de: claude-opus-5 — published session 2026-08-15 15:27:54 +05:30
KRATOS e3625e6183 session local-de: claude-opus-5 — published session 2026-08-15 15:27:46 +05:30
KRATOS 83c9c838c7 session local-de: claude-opus-5 — published session 2026-08-15 15:27:40 +05:30
KRATOS 125790404c session local-de: claude-opus-5 — published session 2026-08-15 15:27:33 +05:30
KRATOS 9e66c3c976 session local-de: claude-opus-5 — published session 2026-08-15 15:27:26 +05:30
KRATOS 5b3bcbd15f session local-de: claude-opus-5 — published session 2026-08-15 15:27:15 +05:30
KRATOS e54478cf73 session local-de: claude-opus-5 — published session 2026-08-15 15:27:05 +05:30
KRATOS 50db365c2f session local-de: claude-opus-5 — published session 2026-08-15 15:26:47 +05:30
KRATOS 82d7e4a57b session local-de: claude-opus-5 — published session 2026-08-15 15:26:36 +05:30
KRATOS 7bcbffe8e3 session local-de: claude-opus-5 — published session 2026-08-15 15:26:24 +05:30
KRATOS eb91ea0ec1 session local-de: claude-opus-5 — published session 2026-08-15 15:26:12 +05:30
KRATOS 9c9ace9314 session local-de: claude-opus-5 — published session 2026-08-15 15:26:01 +05:30
KRATOS 348f5dc131 session local-de: claude-opus-5 — published session 2026-08-15 15:25:49 +05:30
KRATOS 0b7d5f02b1 session local-de: claude-opus-5 — published session 2026-08-15 15:25:40 +05:30
KRATOS 31a68f296c session local-de: claude-opus-5 — published session 2026-08-15 15:25:24 +05:30
KRATOS b22895e4b0 session local-de: claude-opus-5 — published session 2026-08-15 15:25:14 +05:30
KRATOS e95097f6a6 session local-de: claude-opus-5 — published session 2026-08-15 15:24:54 +05:30
KRATOS f52eaebf34 session local-de: claude-opus-5 — published session 2026-08-15 15:24:45 +05:30
KRATOS ae0d6eab31 session local-de: claude-opus-5 — published session 2026-08-15 15:24:35 +05:30
KRATOS 598c3dabb4 session local-de: claude-opus-5 — published session 2026-08-15 15:24:15 +05:30
KRATOS 59559e8f3c session local-de: claude-opus-5 — published session 2026-08-15 15:24:01 +05:30
KRATOS a60563edf7 session local-de: claude-opus-5 — published session 2026-08-15 15:23:46 +05:30
KRATOS 54f58ed7f5 session local-de: claude-opus-5 — published session 2026-08-15 15:23:30 +05:30
KRATOS 974e215d62 session local-de: claude-opus-5 — published session 2026-08-15 15:23:21 +05:30
KRATOS 4f5ad96c7d session local-de: claude-opus-5 — published session 2026-08-15 15:23:07 +05:30
KRATOS 914bce1c40 session local-de: claude-opus-5 — published session 2026-08-15 15:23:01 +05:30
KRATOS b0a90944ea session local-de: claude-opus-5 — published session 2026-08-15 15:22:54 +05:30
KRATOS 2d243908ed session local-de: claude-opus-5 — published session 2026-08-15 15:22:47 +05:30
KRATOS 093eadc229 session local-de: claude-opus-5 — published session 2026-08-15 15:22:40 +05:30
KRATOS ded539d4b9 session local-de: claude-opus-5 — published session 2026-08-15 15:22:32 +05:30
KRATOS ffbb63676e session local-de: claude-opus-5 — published session 2026-08-15 15:22:25 +05:30
KRATOS 62d6dc001d session local-de: claude-opus-5 — published session 2026-08-15 15:22:18 +05:30
KRATOS 426355f4d7 session local-de: claude-opus-5 — published session 2026-08-15 15:22:10 +05:30
KRATOS 30d98ff488 session local-de: claude-opus-5 — published session 2026-08-15 15:22:03 +05:30
KRATOS b57625c535 session local-de: claude-opus-5 — published session 2026-08-15 15:21:56 +05:30
KRATOS 4ec13bb043 session local-de: claude-opus-5 — published session 2026-08-15 15:21:49 +05:30
KRATOS 2e4458a9a0 session local-de: claude-opus-5 — published session 2026-08-15 15:21:41 +05:30
KRATOS cc5eddab8a session local-de: claude-opus-5 — published session 2026-08-15 15:21:36 +05:30
KRATOS b97e533735 session local-de: claude-opus-5 — published session 2026-08-15 15:21:30 +05:30
KRATOS d5aeec742e session local-de: claude-opus-5 — published session 2026-08-15 15:21:24 +05:30
KRATOS 47dac1af84 session local-de: claude-opus-5 — published session 2026-08-15 15:21:18 +05:30
KRATOS 0ab01dc034 session local-de: claude-opus-5 — published session 2026-08-15 15:21:13 +05:30
KRATOS 4e41294bd6 session local-de: claude-opus-5 — published session 2026-08-15 15:21:07 +05:30
KRATOS b8a11a190d session local-de: claude-opus-5 — published session 2026-08-15 15:21:00 +05:30
KRATOS d18846f7b9 session local-de: claude-opus-5 — published session 2026-08-15 15:20:55 +05:30
KRATOS 8f0762eb42 session local-de: claude-opus-5 — published session 2026-08-15 15:20:50 +05:30
KRATOS 29fcdfe9b9 session local-de: claude-opus-5 — published session 2026-08-15 15:20:43 +05:30
KRATOS 1822034212 session local-de: claude-opus-5 — published session 2026-08-15 15:20:38 +05:30
KRATOS e8fa03109c session local-de: claude-opus-5 — published session 2026-08-15 15:20:32 +05:30
KRATOS 33e15f37c2 session local-de: claude-opus-5 — published session 2026-08-15 15:20:26 +05:30
KRATOS 24f4bd5e21 session local-de: claude-opus-5 — published session 2026-08-15 15:20:19 +05:30
KRATOS 417da3d5f3 session local-de: claude-opus-5 — published session 2026-08-15 15:20:13 +05:30
KRATOS ce64f1ac44 session local-de: claude-opus-5 — published session 2026-08-15 15:20:07 +05:30
KRATOS 9cc32f11d9 session local-de: claude-opus-5 — published session 2026-08-15 15:19:59 +05:30
KRATOS 1e7b3f0a68 session local-de: claude-opus-5 — published session 2026-08-15 15:19:54 +05:30
KRATOS 5e63e6c8fe session local-de: claude-opus-5 — published session 2026-08-15 15:19:46 +05:30
KRATOS 8d124c6a03 session local-de: claude-opus-5 — published session 2026-08-15 15:19:39 +05:30
KRATOS 7d44025331 session local-de: claude-opus-5 — published session 2026-08-15 15:19:33 +05:30
KRATOS 8986a874ee session local-de: claude-opus-5 — published session 2026-08-15 15:19:27 +05:30
KRATOS 492cb3e185 session local-de: claude-opus-5 — published session 2026-08-15 15:19:21 +05:30
KRATOS fbee46279a session local-de: claude-opus-5 — published session 2026-08-15 15:19:15 +05:30
KRATOS bccd4d6875 session local-de: claude-opus-5 — published session 2026-08-15 15:19:08 +05:30
KRATOS 79003e9de2 session local-de: claude-opus-5 — published session 2026-08-15 15:19:04 +05:30
KRATOS 82bd37476d session local-de: claude-opus-5 — published session 2026-08-15 15:18:56 +05:30
KRATOS 2b062b8986 session local-de: claude-opus-5 — published session 2026-08-15 15:18:49 +05:30
KRATOS 3de1ca66a3 session local-de: claude-opus-5 — published session 2026-08-15 15:18:41 +05:30
KRATOS e6e25e97e0 session local-de: claude-opus-5 — published session 2026-08-15 15:18:33 +05:30
KRATOS 086d77ff62 session local-de: claude-opus-5 — published session 2026-08-15 15:18:25 +05:30
KRATOS cfabbdba1e session local-de: claude-opus-5 — published session 2026-08-15 15:18:00 +05:30
KRATOS 2a4c92ce1d session local-de: claude-opus-5 — published session 2026-08-15 15:17:48 +05:30
KRATOS b7b5f5b4fa session local-7a: claude-opus-5 — published session 2026-08-15 00:24:13 +05:30
KRATOS 69bd85b376 session local-7a: claude-opus-5 — published session 2026-08-14 21:32:44 +05:30
KRATOS f869315e82 session local-7a: claude-opus-5 — published session 2026-08-14 21:20:11 +05:30
KRATOS cb71ad1c5d session local-7a: claude-opus-5 — published session 2026-08-14 21:20:07 +05:30
305 changed files with 61234 additions and 42758 deletions

No files matched your search

-53
View File
@@ -1,53 +0,0 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
language: "en-US"
tone_instructions: "Be direct and prioritize actionable correctness, safety, CLI UX, and test coverage issues over style nits."
reviews:
profile: "chill"
# Keep CodeRabbit as a review signal without letting stale bot reviews block merges.
request_changes_workflow: true
high_level_summary: true
poem: false
# Avoid noisy "review skipped" status comments when CodeRabbit decides not to run.
review_status: false
collapse_walkthrough: false
path_filters:
- "!dist/**"
- "!out/**"
- "!coverage/**"
- "!*.tgz"
- "!/zero"
- "!/zero.exe"
path_instructions:
- path: "src/**/*.ts"
instructions: |
Review changed code as a Bun-first TypeScript terminal coding agent. Prioritize tool safety, permission metadata, provider/config correctness, session/stream event integrity, and avoiding raw execution paths that bypass ToolBase-style controls.
- path: "src/tui/**/*.tsx"
instructions: |
Review Ink terminal UI for responsive layout, keyboard flow, approval prompts, startup cleanliness, and small-terminal behavior. Flag browser UI assumptions, hardcoded terminal dimensions, and clutter that weakens the CLI agent experience.
- path: "tests/**/*.ts"
instructions: |
Review tests for Bun test idioms, deterministic behavior, and meaningful coverage of safety decisions, provider/runtime flows, session events, and TUI command behavior.
tools:
github-checks:
timeout_ms: 300000
auto_review:
enabled: true
drafts: true
auto_incremental_review: true
auto_pause_after_reviewed_commits: 10
ignore_title_keywords:
- "WIP"
- "DO NOT MERGE"
- "[skip review]"
labels:
- "!do-not-review"
ignore_usernames:
- "dependabot[bot]"
- "renovate[bot]"
- "github-actions[bot]"
# CodeRabbit reviews default-branch PRs by default. This extends automatic
# reviews to PRs targeting any branch, including release and stacked branches.
base_branches:
- ".*"
-94
View File
@@ -1,94 +0,0 @@
name: CI
on:
pull_request:
push:
branches:
- main
jobs:
smoke:
name: Smoke (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- macos-latest
- windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
with:
go-version-file: go.mod
cache: true
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Go test
run: bun run test:go
- name: Go build
run: bun run build:go
- name: Go smoke
run: bun run smoke:go
- name: Test
run: bun run test
- name: Typecheck
run: bun run typecheck
- name: Build binary
run: bun run build
- name: Smoke binary
run: bun run smoke:build
performance:
name: Performance Smoke
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
with:
go-version-file: go.mod
cache: true
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build binary
run: bun run build
- name: Performance smoke
run: bun run scripts/perf-bench.ts --ci --output dist/perf/perf-bench.json
- name: Upload performance report
if: always()
uses: actions/upload-artifact@v4
with:
name: zero-performance-smoke
path: dist/perf/perf-bench.json
if-no-files-found: warn
-183
View File
@@ -1,183 +0,0 @@
name: PR Auto Review
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
permissions:
contents: read
issues: write
pull-requests: write
jobs:
review:
name: Zero Review
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Diff hygiene
id: diffcheck
continue-on-error: true
run: git diff --check origin/${{ github.base_ref }}...HEAD
- name: Typecheck
id: typecheck
continue-on-error: true
run: bun run typecheck
- name: Test
id: test
continue-on-error: true
run: bun run test
- name: Build
id: build
continue-on-error: true
run: bun run build
- name: Smoke build
id: smoke
continue-on-error: true
run: bun run smoke:build
- name: Collect changed files
id: changed-files
if: always()
shell: bash
run: |
{
echo 'files<<EOF'
git diff --name-only "origin/${{ github.base_ref }}...HEAD" | sort
echo EOF
} >> "$GITHUB_OUTPUT"
- name: Post review summary
if: always()
uses: actions/github-script@v7
env:
ZERO_REVIEW_DIFF_CHECK: ${{ steps.diffcheck.outcome }}
ZERO_REVIEW_TYPECHECK: ${{ steps.typecheck.outcome }}
ZERO_REVIEW_TEST: ${{ steps.test.outcome }}
ZERO_REVIEW_BUILD: ${{ steps.build.outcome }}
ZERO_REVIEW_SMOKE: ${{ steps.smoke.outcome }}
ZERO_CHANGED_FILES: ${{ steps.changed-files.outputs.files }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const marker = '<!-- zero-auto-review -->';
const checks = [
{ label: 'Diff hygiene', command: 'git diff --check', outcome: process.env.ZERO_REVIEW_DIFF_CHECK },
{ label: 'Typecheck', command: 'bun run typecheck', outcome: process.env.ZERO_REVIEW_TYPECHECK },
{ label: 'Tests', command: 'bun run test', outcome: process.env.ZERO_REVIEW_TEST },
{ label: 'Build', command: 'bun run build', outcome: process.env.ZERO_REVIEW_BUILD },
{ label: 'Smoke build', command: 'bun run smoke:build', outcome: process.env.ZERO_REVIEW_SMOKE },
].map((check) => ({
...check,
outcome: normalizeOutcome(check.outcome),
}));
const blocking = new Set(['failure', 'cancelled', 'timed_out', 'action_required', 'unknown']);
const blockers = checks.filter((check) => blocking.has(check.outcome));
const files = (process.env.ZERO_CHANGED_FILES || '')
.split(/\r?\n/)
.map((file) => file.trim())
.filter(Boolean)
.sort();
const visibleFiles = files.slice(0, 12);
const fileSuffix = files.length > visibleFiles.length
? `, and ${files.length - visibleFiles.length} more`
: '';
const fileLine = files.length > 0
? `Changed files (${files.length}): ${visibleFiles.map((file) => `\`${file}\``).join(', ')}${fileSuffix}`
: 'Changed files: unavailable in this run.';
const body = [
marker,
'## Zero automated PR review',
'',
`Verdict: **${blockers.length > 0 ? 'Changes requested' : 'No blockers found'}**`,
'',
'### Blockers',
'',
blockers.length > 0
? blockers.map((check) => `- \`${check.command}\` ended with \`${check.outcome}\`.`).join('\n')
: '- None found.',
'',
'### Validation',
'',
...checks.map((check) => `- ${formatOutcome(check.outcome)} ${check.label}: \`${check.command}\``),
'',
'### Scope',
'',
`Head: \`${context.payload.pull_request.head.sha.slice(0, 12)}\``,
fileLine,
'',
'This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.',
].join('\n');
try {
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
per_page: 100,
});
const existing = comments.find((comment) => comment.body?.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
} catch (error) {
core.warning(`Unable to post automated review comment: ${error.message}`);
await core.summary.addRaw(body, true).write();
}
function normalizeOutcome(value) {
const normalized = String(value || '').trim().toLowerCase().replace(/-/g, '_');
return [
'success',
'failure',
'cancelled',
'skipped',
'timed_out',
'action_required',
'neutral',
].includes(normalized) ? normalized : 'unknown';
}
function formatOutcome(outcome) {
if (outcome === 'success') return '[pass]';
if (outcome === 'skipped' || outcome === 'neutral') return '[info]';
return '[fail]';
}
- name: Fail on review blockers
if: ${{ steps.diffcheck.outcome != 'success' || steps.typecheck.outcome != 'success' || steps.test.outcome != 'success' || steps.build.outcome != 'success' || steps.smoke.outcome != 'success' }}
run: exit 1
-68
View File
@@ -1,68 +0,0 @@
name: Release Artifacts
on:
workflow_dispatch:
inputs:
ref:
description: Git ref to package. Defaults to the workflow branch.
required: false
type: string
push:
tags:
- 'v*'
jobs:
package:
name: Package (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- macos-latest
- windows-latest
steps:
- name: Checkout
if: ${{ github.event_name != 'workflow_dispatch' || inputs.ref == '' }}
uses: actions/checkout@v4
- name: Checkout requested ref
if: ${{ github.event_name == 'workflow_dispatch' && inputs.ref != '' }}
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
with:
go-version-file: go.mod
cache: true
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Test
run: bun run test
- name: Typecheck
run: bun run typecheck
- name: Package release artifact
run: bun run package:release
- name: Verify release checksums
run: bun run verify:release
- name: Upload package
uses: actions/upload-artifact@v4
with:
name: zero-${{ runner.os }}-${{ runner.arch }}
path: dist/release/*
if-no-files-found: error
-39
View File
@@ -1,39 +0,0 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
.cursor
/zero
/zero.exe
-111
View File
@@ -1,111 +0,0 @@
---
description: Use Bun instead of Node.js, npm, pnpm, or vite.
globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
alwaysApply: false
---
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
import { createRoot } from "react-dom/client";
// import .css files directly and it works
import './index.css';
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
-160
View File
@@ -1,160 +0,0 @@
# Contributing
Thanks for your interest in improving this project.
This project is currently in a controlled development phase. During this period,
most implementation work will be completed by the core team while the application
stabilizes. Community participation is still welcome, especially through bug
reports, feature requests, reproduction details, logs, and discussion on existing
issues.
Please read this policy before opening a pull request. Pull requests that do not
follow it may be closed without review.
## Current Contribution Policy
The project is not accepting unsolicited community pull requests by default while
the application is still reaching a stable baseline.
Community pull requests must be tied to an existing issue that has already been
reviewed and approved by the core team. Approval is shown by the `issue-approved`
label on the related issue.
If a pull request is opened before the related issue has the `issue-approved`
label, the pull request will be closed without review. Maintainers may ask the
author to continue the discussion on the issue instead.
Team members may open pull requests as part of the internal development cycle.
This does not indicate that the same issue is open for community implementation
unless the issue has been explicitly approved for outside contribution.
## How to Contribute
There are three primary ways to contribute:
1. Open a bug report with clear reproduction steps.
2. Open a feature request describing the problem, use case, and expected behavior.
3. Add useful context to an existing issue, such as logs, screenshots, examples,
affected versions, or reproduction cases.
Please search existing issues before opening a new one. Duplicate or low-signal
issues may be closed so maintainers can focus on actionable reports.
## Issue First Requirement
All community pull requests require an approved parent issue.
Before starting implementation work:
1. Open an issue using the appropriate issue template.
2. Describe the bug, feature request, or functional change clearly.
3. Wait for the core team to review the issue.
4. Do not open a pull request unless the issue has the `issue-approved` label.
An approved issue means only that the specific issue has been accepted for work.
It does not approve unrelated future work, future pull requests, or future issues
from the same author. Each issue is reviewed case by case.
## Pull Request Requirements
Before opening a community pull request, make sure all of the following are true:
- The pull request links to an existing issue.
- The linked issue already has the `issue-approved` label.
- The pull request is focused on only that approved issue.
- The pull request explains what changed and why.
- The pull request includes tests or verification notes when appropriate.
- UI changes include screenshots or video when possible.
- The pull request does not include fixes, features, refactors, formatting
changes, or other work outside the approved issue scope unless that expansion
was discussed with and approved by project team members.
Use `Fixes #123`, `Closes #123`, or another clear link in the pull request
description so maintainers can confirm the approved parent issue.
Pull requests that include unrelated changes, broad rewrites, formatting-only
changes, or unapproved feature work may be closed without review. Scope drift
will be rejected or called out for correction before a pull request can be
approved.
## Project Consistency
Pull requests should stay within the project's existing technical direction.
Changes that introduce a new implementation language, shift existing code away
from the core language used by the project, or add a new language/runtime to the
codebase are unlikely to be accepted unless they were discussed and approved by
maintainers before implementation.
The same applies to dependency changes. Pull requests that replace, rebase, or
significantly restructure the project's dependencies without prior discussion are
likely to be rejected. A preference-based explanation such as "this is better" is
not enough. Dependency changes need a clear project benefit, such as fixing a
specific bug, addressing a security issue, improving compatibility, or supporting
an approved feature.
## What We Prioritize
The core team will prioritize:
- Bug reports with clear reproduction steps.
- Functional defects that affect current users.
- Feature requests that align with the project roadmap.
- Requests that explain the user problem and expected behavior.
- Reports with logs, screenshots, test cases, or other concrete evidence.
Most approved implementation work will be completed by team members until the
project reaches a more stable status.
## What We Are Not Prioritizing
The project is not currently prioritizing:
- Unsolicited implementation pull requests.
- Pull requests without an approved parent issue.
- Typo-only changes.
- Formatting-only changes.
- Refactors without a clear functional benefit.
- Large rewrites that were not discussed with maintainers first.
- New features that have not been approved through an issue.
## Submitting a Bug Report
When filing a bug report, please include:
- The version, branch, or commit you are using.
- Your operating system and relevant environment details.
- Steps to reproduce the issue.
- Expected behavior.
- Actual behavior.
- Relevant logs, screenshots, or error messages.
- A minimal reproduction case when possible.
Clear bug reports are the most useful way to help the project during this phase.
## Submitting a Feature Request
When filing a feature request, please include:
- The problem you are trying to solve.
- The behavior or functionality you are requesting.
- Why the request belongs in this project.
- Any alternatives or workarounds you have considered.
- Examples, screenshots, or prior discussion if relevant.
Please do not begin implementation until the issue has been reviewed and marked
with the `issue-approved` label.
## Review Expectations
Maintainers may close issues or pull requests that do not follow these guidelines.
This is not a judgment on the contributor; it is part of keeping the development
cycle focused while the project stabilizes.
If an issue or pull request is closed, the best next step is usually to clarify
the issue, provide more evidence, or wait for the project to reach a more open
contribution phase.
## Getting Help
If you are unsure whether a change is appropriate, open an issue first and ask.
Please keep discussion focused, specific, and respectful of maintainer time.
+28 -205
View File
@@ -1,222 +1,45 @@
```
███████████ ██████████ ███████████ ███████
▒█▒▒▒▒▒▒███ ▒▒███▒▒▒▒▒█▒▒███▒▒▒▒▒███ ███▒▒▒▒▒███
▒ ███▒ ▒███ █ ▒ ▒███ ▒███ ███ ▒▒███
███ ▒██████ ▒██████████ ▒███ ▒███
███ ▒███▒▒█ ▒███▒▒▒▒▒███ ▒███ ▒███
████ █ ▒███ ▒ █ ▒███ ▒███ ▒▒███ ███
███████████ ██████████ █████ █████ ▒▒▒███████▒
▒▒▒▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒▒▒ ▒▒▒▒▒ ▒▒▒▒▒ ▒▒▒▒▒▒▒
```
# Origin session history
# Zero
This branch is written automatically by [Origin](https://getorigin.io). It is
**not part of your codebase** — nothing here is built, imported, or deployed. It
records which AI agent wrote the code on the other branches, and the prompt
behind each change.
**A clean, terminal-first AI coding agent you fully own — multi-provider, scriptable, and safe by default.**
You don't need Origin installed to read any of it. Plain `git` is enough.
![core](https://img.shields.io/badge/core-Go--native-00ADD8?logo=go&logoColor=white)
![distribution](https://img.shields.io/badge/distribution-npm%20wrapper-cb3837?logo=npm&logoColor=white)
![transition](https://img.shields.io/badge/TS%2FBun-transitional-14151a?logo=bun&logoColor=fbf0df)
![status](https://img.shields.io/badge/status-active%20development-67e8f9)
## Layout
Zero is a coding agent that lives in your terminal. It runs an agentic tool loop —
reading, editing, searching, and running commands in your repo — against **whatever
model you choose**. Zero is migrating to a **Go-native core** with an npm
distribution wrapper; the current TypeScript/Bun implementation remains a
transitional reference while the Go M0 foundation lands.
sessions/<session-id>/metadata.json model, cost, tokens, duration, commits
sessions/<session-id>/prompts.md the prompts, in order
sessions/<session-id>/changes.json per-prompt diffs and files touched
> Zero treats the **model as a swappable, per-task choice** — no single-vendor lock-in —
> and never mutates your system without a permission decision.
## Reading it
---
List the sessions:
## Highlights
git ls-tree --name-only origin/origin-sessions:sessions/
- 🔌 **Multi-provider** — OpenAI-compatible, Anthropic, and Gemini behind one interface, with a model registry (capabilities, context limits, cost). Bring your own key and endpoint.
- 🖥️ **Premium TUI** — Bubble Tea/Lip Gloss is the target TUI stack; the current Ink surface remains transitional during the Go migration.
- 🤖 **Headless & scriptable** — `zero exec` with clean `text` / `json` / `stream-json` I/O and meaningful exit codes for CI and automation.
- 🧰 **Real tools** — read / write / edit files, `apply_patch`, `grep`, `glob`, `bash`, directory listing, and a live plan/todo.
- 🛡️ **Safe by default** — mutating tools are permission-gated; `--skip-permissions-unsafe` is an explicit, clearly-labeled opt-out.
- 💾 **Durable sessions** — local, append-only session event store with full-text `search`.
- 🩺 **Operable** — built-in `doctor`, `config` inspection, secret redaction everywhere, and `update --check`.
Read the prompts behind one:
## Quick start
git show origin/origin-sessions:sessions/<session-id>/prompts.md
> Requires [Bun](https://bun.com) for the current transitional npm wrapper and
> [Go](https://go.dev/) for Go-native validation commands.
## Per-commit attribution (one extra step)
```bash
bun install --frozen-lockfile
bun run dev # launch the interactive TUI
```
Origin also writes a git note per commit, so `git log` can show you which agent
wrote it and why. Notes live in `refs/notes/origin`, and **`git clone` does not
fetch them** — that's a git default, not an Origin choice. Bring them down once:
Point Zero at a model — either set environment variables:
git fetch origin refs/notes/origin:refs/notes/origin
```bash
export OPENAI_API_KEY=sk-...
# optional: any OpenAI-compatible endpoint / model
export OPENAI_BASE_URL=https://api.openai.com/v1
export OPENAI_MODEL=gpt-4.1
```
Then, with plain git:
…or save reusable provider profiles in `~/.config/zero/config.json` and manage them with
`zero providers`. Run `zero doctor` anytime to verify your setup.
git log --show-notes=origin
## Usage
To keep them arriving on every ordinary `git pull`, add the refspec:
### Interactive (TUI)
git config --add remote.origin.fetch '+refs/notes/origin:refs/notes/origin-remote'
```bash
bun run dev # or: zero
```
Inside the TUI: type to chat and press **Enter** to send. `/` opens command suggestions
(**Tab** accepts the first). When the prompt is empty, the arrow keys, **PgUp/PgDn**, and
**Home/End** scroll the transcript. **Ctrl+C** exits.
### Headless (`exec`)
```bash
# one-shot
zero exec "explain src/agent/loop.ts and suggest one improvement"
# from a file, with a specific model, as JSON for scripts
zero exec --file task.md --model claude-sonnet-4.5 --output-format json
# multi-turn / programmatic over stdio
zero exec --input-format stream-json --output-format stream-json < turns.jsonl
```
`exec` flags: `-f, --file` · `-m, --model` · `-C, --cwd` · `-i, --input-format <text|stream-json>` ·
`-o, --output-format <text|json|stream-json>` · `--skip-permissions-unsafe`.
stdout carries **only** program output; logs go to stderr. See
[`docs/STREAM_JSON_PROTOCOL.md`](docs/STREAM_JSON_PROTOCOL.md).
### Other commands
```bash
zero providers list|switch <name>|current # manage provider profiles
zero search "<query>" [--json --session <id> --type <event>] # search local sessions
zero doctor [--connectivity] [--json] # health checks
zero config [--json] # inspect resolved configuration
zero update --check [--json] # check for a newer release
```
## Providers & models
Selectable per task and per session. The model registry knows each model's provider,
capabilities, context window, and cost.
| Provider | Example models |
|---|---|
| OpenAI-compatible | `gpt-4.1`, `gpt-4.1-mini`, `gpt-4o`, `gpt-4o-mini` |
| Anthropic | `claude-opus-4.1`, `claude-sonnet-4.5`, `claude-haiku-4.5` |
| Google Gemini | `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` |
Any OpenAI-compatible endpoint works with just a base URL, key, and model — so local
runtimes (Ollama, gateways, etc.) plug in the same way.
## Tools
| Tool | Purpose | Side effect |
|---|---|---|
| `read_file` · `list_directory` · `grep` · `glob` | explore & search | read |
| `update_plan` | maintain a live task plan | plan |
| `write_file` · `edit_file` · `apply_patch` | create & modify files | write (gated) |
| `bash` | run shell commands | shell (gated) |
Write/shell tools route through the permission policy before any side effect.
## Architecture
```
Terminal (Bubble Tea target) headless `exec` (future) editor ext
└───────────────────┬───────────────────────┘
Go-native core target (loop, events, tools)
┌──────────┬───────────┬──────────┬──────────┬───────────┬──────────┐
providers tools sessions usage redaction doctor / stream-json
+ registry registry + search + cost config
```
- **Go-native target**: the new `cmd/zero` entrypoint is the foundation for the production core; TS/Bun code remains in place during migration.
- **Surface-agnostic core**: the agent loop streams text + tool calls, executes tools, and emits a typed event stream consumed identically by every surface.
- **Edges are interfaces**: `Provider`, `Tool`, `SessionStore`, and the permission policy are swappable.
- **Model is data**: capabilities, cost, and routing live in the registry — never hard-coded.
## Project layout
```
cmd/zero # Go-native entrypoint
internal/cli # minimal Go CLI surface during migration
src/
agent/ # transitional TS agent loop + system prompts
cli/ # transitional TS headless exec + command surface
providers/ # openai · anthropic · gemini · base
tools/ # read/write/edit/bash/grep/glob/apply_patch/plan
tui/ # transitional Ink interface + startup/
config/ # layered configuration
zero-model-registry/ # models, capabilities, cost
zero-provider-runtime/ # provider resolution/routing
zero-sessions/ # append-only session event store
zero-search/ # session search
zero-usage/ # token usage tracking
zero-redaction/ # secret redaction
zero-doctor/ # health checks
zero-config-inspection/# config inspection
zero-stream-json/ # headless stream-json protocol
docs/ # PRD + protocol/install/perf docs
tests/ # bun test suite
```
## Development
```bash
bun test # run the test suite
bun run test:go # run Go tests
bun run typecheck # tsc --noEmit
bun run build # compile the release-facing Go binary
bun run build:go # same Go binary builder, kept as a stable validation command
bun run smoke:build # verify the release-facing Go binary
bun run smoke:go # verify the Go binary through the Go smoke path
bun run perf:bench # performance benchmarks (see docs/PERFORMANCE.md)
```
Cross-compile by passing Go targets to the builder:
```bash
bun run scripts/build.ts --goos linux --goarch amd64
bun run scripts/build.ts --goos windows --goarch amd64 --output dist/zero.exe
```
### Install from a release
```bash
# Linux / macOS
scripts/install.sh
# Windows
powershell -ExecutionPolicy Bypass -File scripts/install.ps1
```
See [`docs/INSTALL.md`](docs/INSTALL.md) for version, repository, and install-path overrides,
and [`docs/UPDATE.md`](docs/UPDATE.md) for the update flow.
## Documentation
- [Product Requirements (PRD)](docs/PRD.md) — vision, goals, full feature spec, roadmap
- [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md) — headless I/O contract
- [Headless exec PRD](docs/M1_HEADLESS_EXEC_PRD.md)
- [npm wrapper smoke checklist](docs/NPM_WRAPPER_SMOKE.md)
- [Performance](docs/PERFORMANCE.md) · [Install](docs/INSTALL.md) · [Update](docs/UPDATE.md)
## Contributing
Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Please run
`bun test` and `bun run typecheck` before opening a PR.
## License
License is being finalized; a `LICENSE` file will be added before a public release.
---
<sub>Targeting a Go-native core with an npm distribution wrapper. The current Bun · TypeScript · Ink implementation is transitional during migration.</sub>
(That stages them into `refs/notes/origin-remote`. Origin merges the staging ref
into `refs/notes/origin` so your own local notes are never overwritten — mapping
the remote straight onto `refs/notes/origin` with a forced refspec would silently
destroy any note you hadn't pushed yet.)
-9
View File
@@ -1,9 +0,0 @@
#!/usr/bin/env bun
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { runNpmWrapper } from '../src/npm-wrapper';
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
process.exitCode = await runNpmWrapper({ root: packageRoot });
-277
View File
@@ -1,277 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "zero",
"dependencies": {
"commander": "^15.0.0",
"execa": "^9.6.1",
"ink": "^7.0.4",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"openai": "^6.39.1",
"react": "^19.2.6",
"shiki": "^4.1.0",
"zod": "^4.4.3",
},
"devDependencies": {
"@types/bun": "latest",
"@types/react": "^19.2.15",
"react-devtools-core": "^7.0.1",
"typescript": "^5.9.3",
},
},
},
"packages": {
"@alcalzone/ansi-tokenize": ["@alcalzone/[email protected]", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="],
"@sec-ant/readable-stream": ["@sec-ant/[email protected]", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
"@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/primitive": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ=="],
"@shikijs/engine-javascript": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ=="],
"@shikijs/engine-oniguruma": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg=="],
"@shikijs/langs": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg=="],
"@shikijs/primitive": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw=="],
"@shikijs/themes": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw=="],
"@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA=="],
"@shikijs/vscode-textmate": ["@shikijs/[email protected]", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@sindresorhus/merge-streams": ["@sindresorhus/[email protected]", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/hast": ["@types/[email protected]", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/mdast": ["@types/[email protected]", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"@types/react": ["@types/[email protected]", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
"@types/unist": ["@types/[email protected]", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@ungap/structured-clone": ["@ungap/[email protected]", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="],
"ansi-escapes": ["[email protected]", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
"ansi-regex": ["[email protected]", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["[email protected]", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"auto-bind": ["[email protected]", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"ccount": ["[email protected]", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chalk": ["[email protected]", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"character-entities-html4": ["[email protected]", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["[email protected]", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"cli-boxes": ["[email protected]", "", {}, "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw=="],
"cli-cursor": ["[email protected]", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="],
"cli-spinners": ["[email protected]", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
"cli-truncate": ["[email protected]", "", { "dependencies": { "slice-ansi": "^9.0.0", "string-width": "^8.2.0" } }, "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA=="],
"code-excerpt": ["[email protected]", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="],
"comma-separated-tokens": ["[email protected]", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["[email protected]", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="],
"convert-to-spaces": ["[email protected]", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
"cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"dequal": ["[email protected]", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"devlop": ["[email protected]", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"environment": ["[email protected]", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
"es-toolkit": ["[email protected]", "", {}, "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw=="],
"escape-string-regexp": ["[email protected]", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
"execa": ["[email protected]", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
"figures": ["[email protected]", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
"get-east-asian-width": ["[email protected]", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
"get-stream": ["[email protected]", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
"hast-util-to-html": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-whitespace": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"html-void-elements": ["[email protected]", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"human-signals": ["[email protected]", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
"indent-string": ["[email protected]", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
"ink": ["[email protected]", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.3", "auto-bind": "^5.0.1", "chalk": "^5.6.2", "cli-boxes": "^4.0.1", "cli-cursor": "^4.0.0", "cli-truncate": "^6.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.45.1", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^9.0.0", "stack-utils": "^2.0.6", "string-width": "^8.2.0", "terminal-size": "^4.0.1", "type-fest": "^5.5.0", "widest-line": "^6.0.0", "wrap-ansi": "^10.0.0", "ws": "^8.20.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.2.0", "react": ">=19.2.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-4wsM/gMKOT2ZANNTJibI6I9IcwBfobqv/CgaDcwvOaCREZIQxo3iGQS7qPHa2hmA67NYltZWCMtBDELB/mcbJQ=="],
"ink-spinner": ["[email protected]", "", { "dependencies": { "cli-spinners": "^2.7.0" }, "peerDependencies": { "ink": ">=4.0.0", "react": ">=18.0.0" } }, "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA=="],
"ink-text-input": ["[email protected]", "", { "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" }, "peerDependencies": { "ink": ">=5", "react": ">=18" } }, "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw=="],
"is-fullwidth-code-point": ["[email protected]", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
"is-in-ci": ["[email protected]", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="],
"is-plain-obj": ["[email protected]", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-stream": ["[email protected]", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
"is-unicode-supported": ["[email protected]", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
"isexe": ["[email protected]", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"mdast-util-to-hast": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"micromark-util-character": ["[email protected]", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-encode": ["[email protected]", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-sanitize-uri": ["[email protected]", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-symbol": ["[email protected]", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["[email protected]", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"mimic-fn": ["[email protected]", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"npm-run-path": ["[email protected]", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
"onetime": ["[email protected]", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"oniguruma-parser": ["[email protected]", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
"oniguruma-to-es": ["[email protected]", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
"openai": ["[email protected]", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-z3dO9fEWOXBzlXynVb/xZ/tujzUjFWQWn3C0n0mw6Vo0zJTbEkaN4b2cLWjhJ6haJQx8LlREoafHRl+Gu/Hl+A=="],
"parse-ms": ["[email protected]", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
"patch-console": ["[email protected]", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="],
"path-key": ["[email protected]", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"pretty-ms": ["[email protected]", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
"property-information": ["[email protected]", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"react": ["[email protected]", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
"react-devtools-core": ["[email protected]", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="],
"react-reconciler": ["[email protected]", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
"regex": ["[email protected]", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
"regex-recursion": ["[email protected]", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
"regex-utilities": ["[email protected]", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"restore-cursor": ["[email protected]", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
"scheduler": ["[email protected]", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"shebang-command": ["[email protected]", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["[email protected]", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shell-quote": ["[email protected]", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="],
"shiki": ["[email protected]", "", { "dependencies": { "@shikijs/core": "4.1.0", "@shikijs/engine-javascript": "4.1.0", "@shikijs/engine-oniguruma": "4.1.0", "@shikijs/langs": "4.1.0", "@shikijs/themes": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q=="],
"signal-exit": ["[email protected]", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"slice-ansi": ["[email protected]", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA=="],
"space-separated-tokens": ["[email protected]", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"stack-utils": ["[email protected]", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
"string-width": ["[email protected]", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
"stringify-entities": ["[email protected]", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"strip-final-newline": ["[email protected]", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
"tagged-tag": ["[email protected]", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"terminal-size": ["[email protected]", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="],
"trim-lines": ["[email protected]", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"type-fest": ["[email protected]", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="],
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["[email protected]", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"unicorn-magic": ["[email protected]", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
"unist-util-is": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"vfile": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"widest-line": ["[email protected]", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="],
"wrap-ansi": ["[email protected]", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="],
"ws": ["[email protected]", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="],
"yoctocolors": ["[email protected]", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
"yoga-layout": ["[email protected]", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
"zod": ["[email protected]", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"zwitch": ["[email protected]", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"ink/signal-exit": ["[email protected]", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"ink/ws": ["[email protected]", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"ink-text-input/type-fest": ["[email protected]", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"npm-run-path/path-key": ["[email protected]", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"openai/ws": ["[email protected]", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"restore-cursor/signal-exit": ["[email protected]", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
}
}
-11
View File
@@ -1,11 +0,0 @@
package main
import (
"os"
"github.com/Gitlawb/zero/internal/cli"
)
func main() {
os.Exit(cli.Run(os.Args[1:], os.Stdout, os.Stderr))
}
-98
View File
@@ -1,98 +0,0 @@
# Zero Install Scripts
Zero release archives are published as:
- `zero-v<version>-linux-<arch>.tar.gz`
- `zero-v<version>-macos-<arch>.tar.gz`
- `zero-v<version>-windows-<arch>.zip`
Each archive must have a matching `.sha256` file. The install scripts download both files and verify the checksum before copying the binary.
Maintainers can verify the release directory before upload:
```bash
bun run package:release
bun run verify:release
```
`verify:release` requires every archive in `dist/release` to have a same-directory `.sha256` file whose contents are:
```text
<sha256> <archive-name>
```
## Linux And macOS
From a checkout:
```bash
scripts/install.sh
```
Install a specific version:
```bash
ZERO_VERSION=0.1.0 scripts/install.sh
```
Install to a custom directory:
```bash
ZERO_INSTALL_DIR="$HOME/bin" scripts/install.sh
```
Install from a fork or internal repository:
```bash
scripts/install.sh --repo owner/repo
```
Defaults:
- Repository: `Gitlawb/zero`
- Version: latest GitHub release
- Install path: `~/.local/bin/zero`
Requirements: `curl` or `wget`, `tar`, and `shasum` or `sha256sum`.
## Windows
From a checkout:
```powershell
powershell -ExecutionPolicy Bypass -File scripts/install.ps1
```
Install a specific version:
```powershell
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -Version 0.1.0
```
Install to a custom directory:
```powershell
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -InstallDir "$env:USERPROFILE\bin"
```
Install from a fork or internal repository:
```powershell
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -Repository owner/repo
```
Defaults:
- Repository: `Gitlawb/zero`
- Version: latest GitHub release
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
## Updating
Check whether a newer release exists:
```bash
zero update --check
```
For M2, updates are check-only. Re-run the installer to replace the local binary after reviewing the release.
-87
View File
@@ -1,87 +0,0 @@
# M1 Headless Exec PRD
Status: Draft
Owner: Vasanth
Milestone: M1
Scope: `zero exec` CLI surface
## Goal
Ship the first production-shaped headless command for Zero:
```sh
zero exec "inspect this repo and summarize the next fix"
```
This command should be useful for local scripts, CI smoke runs, and later VS Code integration without pulling in the full future protocol, session, MCP, sandbox, or plugin roadmap.
## Non-Goals
- No daemon mode.
- No JSON-RPC input protocol.
- No MCP, plugin, marketplace, or subagent support.
- No session resume/fork/search.
- No new provider adapters in this slice.
- No architecture rewrite of the agent loop.
## User Stories
1. As a CLI user, I can run one prompt headlessly and receive the assistant answer on stdout.
2. As a script author, I can choose `text` or JSONL output.
3. As a reviewer, I can see tool activity without corrupting text stdout.
4. As a model tester, I can override the configured model for one run.
5. As a cautious user, prompt-gated tools stay disabled unless I pass an explicit unsafe flag.
## CLI Contract
```sh
zero exec [prompt...] [options]
```
Options for M1:
- `-f, --file <path>`: read the prompt from a file.
- `-m, --model <model>`: override the configured model for this run.
- `-C, --cwd <path>`: run from another working directory.
- `-o, --output-format <text|json>`: choose stdout format.
- `--skip-permissions-unsafe`: grant prompt-gated tools for this run.
The existing `zero -p "prompt"` shortcut remains as a compatibility path and should call the same runner.
## Output Contract
Text mode:
- stdout contains assistant text only.
- stderr contains tool rows, warnings, and errors.
JSON mode:
- stdout is newline-delimited JSON events.
- Required event types for this slice: `run_start`, `text`, `tool_call`, `tool_result`, `final`, `warning`, `error`, `done`.
## Exit Codes
- `0`: success
- `1`: unexpected crash
- `2`: usage error
- `3`: provider/config/runtime error
- `4`: reserved for future tool failure policy
- `5`: reserved for future permission-denied policy
## Permission Behavior
Default headless mode advertises only tools marked `permission: "allow"`.
`--skip-permissions-unsafe` advertises tools that are not marked `deny` and grants prompt-gated tools through `ToolRegistry.run`. It must not bypass schema validation or direct tool execution safeguards.
## Acceptance Criteria
- `bun run typecheck` passes.
- `bun test ./tests --timeout 15000` passes.
- `bun run build` passes.
- `zero exec --help` documents the M1 flags.
- Usage errors do not initialize providers.
- Provider failures return exit code `3`.
- Normal mode does not advertise `bash`, `write_file`, `edit_file`, or `apply_patch`.
- Unsafe mode still uses the registry execution path.
-37
View File
@@ -1,37 +0,0 @@
# npm Wrapper Smoke Checklist
Run this checklist when a PR changes npm distribution files such as
`package.json`, `bun.lock`, `bin/zero.ts`, `src/npm-wrapper.ts`, build scripts,
package release scripts, or the npm `bin` wrapper.
## Required Checks
```bash
bun install --frozen-lockfile
bun run build
bun run smoke:build
```
`build` and `build:go` both compile the Go-native release binary and inject the
version from `package.json`. Keep the Bun install/test checks because the npm
wrapper and transitional TypeScript surface still ship from this package.
Also run the Go checks when the PR changes Go entrypoint, CLI, or release
artifact behavior:
```bash
bun run test:go
bun run build:go
bun run smoke:go
```
## Checklist
- `package.json` has the expected package name, version, and `bin.zero` entry.
- `bun install --frozen-lockfile` succeeds without lockfile changes.
- The wrapper binary resolves through the package `bin` entry and
`node_modules/.bin` in a package-install smoke test.
- The built binary exits 0 for `zero --version` or `zero --help`.
- `zero --version` reports `zero <package.json version>`.
- Release packaging still emits the expected archive and checksum names when
package release files change.
-62
View File
@@ -1,62 +0,0 @@
# Zero Performance Benchmarks
The M2 performance harness tracks three release-facing signals:
- Cold start: process startup time for `zero --version`.
- TTFT: time from starting the local agent loop to the first streamed text event from a deterministic provider.
- Memory: peak RSS for the benchmarked in-process agent path.
When a built Go binary exists at `./zero` or `./zero.exe`, cold start uses that artifact. Otherwise it falls back to `bun src/index.ts --version` so the benchmark can run before a local build.
## Run Locally
```bash
bun run perf:bench
```
Run against a freshly built binary:
```bash
bun run build
bun run perf:bench
```
Write the JSON report used by CI:
```bash
bun run perf:smoke
```
Default warning thresholds:
- Cold start p95: 300 ms
- TTFT p95: 500 ms
- Agent RSS peak: 256 MB
The default sample count is intentionally small for CI smoke coverage. `p95` uses nearest-rank percentile selection, so with the default 5 measured samples it is the slowest sample. Increase `--iterations` for local baseline investigations.
Override thresholds with CLI flags:
```bash
bun run scripts/perf-bench.ts --cold-start-warn-ms=350 --ttft-warn-ms=600 --rss-warn-mb=384
```
Or with environment variables:
```bash
ZERO_PERF_COLD_START_WARN_MS=350 bun run perf:bench
```
Supported environment variables:
- `ZERO_PERF_ITERATIONS`
- `ZERO_PERF_WARMUP_ITERATIONS`
- `ZERO_PERF_COLD_START_WARN_MS`
- `ZERO_PERF_TTFT_WARN_MS`
- `ZERO_PERF_RSS_WARN_MB`
## CI Behavior
The `Performance Smoke` job builds the binary, runs `bun run perf:smoke`, and uploads `dist/perf/perf-bench.json`.
Threshold drift is emitted as GitHub Actions warnings. The job fails only if the benchmark cannot run, the build fails, or `--fail-on-warning` is passed explicitly.
-602
View File
@@ -1,602 +0,0 @@
# Zero — Product Requirements Document (v1.0)
**Status:** DRAFT v1.0
**Target:** Compete with Claude Code + Cursor + OpenCode
**Timeline:** 6-12 months
**Stack:** Go native core + npm distribution wrapper
---
## 1. Vision
**Zero is a terminal-first, editor-aware, multi-provider AI coding platform.**
It combines the best of:
- **Claude Code** — terminal-native agentic coding
- **Cursor** — IDE integration + smart editing
- **OpenCode** — multi-provider + headless
**One Go-native agent core. Three surfaces. Zero lock-in.**
---
## 2. Core Surfaces
### 2.1 Terminal (TUI) — Primary
- Interactive coding agent in terminal
- Streaming, append-style (like Claude Code)
- Bubble Tea + Lip Gloss-based
- Full keyboard control
- Mouse support
### 2.2 Editor (VS Code Extension)
- Same agent core, different UI
- Inline suggestions + chat panel
- File editing with diff view
- Diagnostics integration
- LSP-aware
### 2.3 Headless (CLI/CI)
- `zero exec "prompt"` for scripts
- `zero serve` for daemon mode
- JSON-RPC over stdio/WebSocket
- MCP server support
---
## 3. Architecture
```
┌─────────────────────────────────────────────────┐
│ ZERO PLATFORM │
├─────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ TUI │ │ VSCode │ │ Headless │ │
│ │ Surface │ │ Surface │ │ Surface │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └─────────────┼─────────────┘ │
│ │ │
│ ┌──────▼──────┐ │
│ │ Agent Core │ │
│ │ (ReAct) │ │
│ └──────┬──────┘ │
│ │ │
│ ┌──────┬──────┬────┴────┬──────┬──────┐ │
│ │Tools │Perms │Sessions │Provi │Memory│ │
│ └──────┴──────┴─────────┴──────┴──────┘ │
│ │
└─────────────────────────────────────────────────┘
```
**Layering Rules:**
- Agent Core never imports a surface
- Surfaces are thin shells (I/O + rendering)
- All state persists to local files
- No cloud dependency
---
## 4. Functional Requirements (24 Features)
### F1 — Agent Core (P0)
- ReAct loop: think → tool → result → think
- Stream text + tool calls + thinking
- Parallel tool execution where safe
- Bound turns (max 100)
- Clean interrupt (Esc/Ctrl-C)
- Event stream: `text | tool_call | tool_result | thinking | usage | plan_update | error | turn_end`
### F2 — Tools (P0)
**Core (8):**
- `read_file` — read with line ranges, images
- `write_file` — create/overwrite
- `edit_file` — exact-string edit (uniqueness guard)
- `bash` — run shell command
- `grep` — ripgrep content search
- `glob` — file pattern matching
- `apply_patch` — multi-hunk patches
- `update_plan` — todo list
**Advanced (4):**
- `web_fetch` / `web_search` — web access
- `task` — spawn subagent
- `structured_output` — JSON-Schema validated
- `notebook_edit` — Jupyter notebooks
**Tool Requirements:**
- Go typed parameter schemas -> JSON Schema
- Side-effect class: `read | write | shell | network | out_of_workspace`
- Structured results: `{ status, output, truncated?, meta? }`
- Atomic execution
- Permission gating
### F3 — Providers & Model Registry (P0)
- OpenAI-compatible (works with OpenAI, OpenRouter, Ollama, etc.)
- Anthropic (Claude)
- Google Gemini
- Azure OpenAI
- AWS Bedrock
- xAI (Grok)
**Model Registry:**
```go
type ModelEntry struct {
ID string
Name string
Provider string
APIProviders []string
ContextLimits ContextLimits
ReasoningEffort ReasoningEffort
Capabilities ModelCapabilities
Cost ModelCost
Tier string
MatchPatterns []string
}
```
- Per-task model selection (`-m`)
- Provider rotation/failover
- Cost tracking
- "Spec model" for planning
- Fallback model on failure
### F4 — Terminal UI (P0)
- **Layout:** header (cwd · git · model) → tool rows → diff → response → input → footer
- **Streaming:** append-only transcript model with live tail updates
- **Markdown:** full CommonMark + syntax highlighting
- **Tool rows:** collapsible with one-line summaries
- **Input:** `/` commands, `@` files, `!` bash, `Esc` interrupt
- **Robustness:** resize, wide-char, ANSI, non-TTY fallback
- **Splash:** ZERO wordmark
- **Themes:** light/dark + custom
### F5 — Sessions (P0)
- Persist to `~/.local/share/zero/sessions/<id>/`
- `meta.json` + `events.jsonl` + `messages.jsonl` + `checkpoints/`
- `--resume [id]` (default: latest)
- `--fork <id>` (copy with lineage)
- Search: `zero search <query>` over messages/tools/results
- Atomic writes (write-temp-then-rename)
- Schema versioning
- Export to Markdown/JSON
### F6 — Configuration (P0)
- Layered: defaults → user → project → env → CLI
- `~/.config/zero/config.json` (user)
- `./.zero/config.json` (project)
- Runtime overlay (`--settings <path>`)
- Feature flags for staged rollout
- Validation on load
### F7 — MCP (Model Context Protocol) (P0)
- Connect to stdio/HTTP/SSE servers
- Surface MCP tools into registry
- `zero mcp add/remove/list`
- Per-server and per-tool permissions
- Persistent grants with revoke/clear
- MCP server mode (zero as MCP server)
### F8 — Headless / Programmatic (P0)
- `zero exec [prompt]` — one-shot
- `zero serve` — daemon mode
- `--input-format stream-json` — line-delimited events
- `--input-format stream-jsonrpc` — JSON-RPC protocol
- `-o text|json` output formats
- Exit codes: `0=success, 1=crash, 2=usage, 3=provider, 4=tool, 5=permission, 6=budget`
### F9 — Permissions & Autonomy (P0)
- **Side-effect classes:** read, write, shell, network, out_of_workspace
- **Autonomy levels:** low (ask), medium (smart), high (auto)
- `--skip-permissions-unsafe` (loud warning)
- TUI: inline prompts (allow/deny/always)
- Headless: default-deny unless granted
- Persistent grants with list/revoke/clear
- Audit log in `events.jsonl`
**Permission Matrix:**
| Tool Class | low | medium | high |
|------------|-----|--------|------|
| Read | allow | allow | allow |
| Workspace write | prompt | allow | allow |
| Shell | prompt | prompt | allow |
| Network | prompt | allow | allow |
| Out-of-workspace | deny | prompt | prompt |
### F10 — Observability (P0)
- Structured logging (stdout/stderr split)
- Secret redaction (API keys, tokens, passwords)
- No vendor telemetry (local only)
- `zero doctor` — health check command
- Crash reports (opt-in, local)
- Performance metrics (TTFT, tokens/sec)
### F11 — Subagents (P1)
- `task` tool spawns scoped subagent
- Built-in agents: code-review, security-review, refactor, test-gen
- Recursion depth limit
- Parent session linkage
- Shared/isolated budgets
### F12 — Plugins (P1)
- User/project-scoped plugins
- Commands, tools, prompts, hooks
- Plugin manifest: `plugin.json`
- `zero plugin install <path|url>`
- No marketplace in v1 (manual install)
### F13 — Distribution (P0)
- **Single binary** via Go cross-compilation
- npm package as a thin installer/launcher for the platform binary
- Cross-platform: Linux, macOS, Windows
- `zero update [--check]` self-update
- Checksum verification
- Rollback on failure
### F14 — VS Code Extension (P1)
- Same agent core via stream protocol
- Inline chat panel
- Diff view in editor
- Diagnostics integration
- LSP awareness
- File watching
### F15 — Memory & Context (P1)
- `AGENTS.md` hierarchical memory
- `@import` for modular rules
- Auto-generated from `init` command
- Project conventions learning
- Skills system (reusable instruction packs)
- Context budgeting (token tracking)
- Auto-compaction when full
### F16 — Hooks System (P1)
- Lifecycle hooks: `PreToolUse`, `PostToolUse`, `SessionStart`, `SessionEnd`
- User-defined shell commands
- JSON output for conditional flow
- `.zero/hooks/` directory
### F17 — Slash Commands (P0)
**Built-in (20):**
- Session: `/clear`, `/compact`, `/context`, `/rewind`, `/resume`, `/exit`
- Model: `/model`, `/effort`, `/style`, `/permissions`
- Project: `/init`, `/memory`, `/skills`
- Extensibility: `/mcp`, `/hooks`, `/agents`
- Meta: `/help`, `/config`, `/doctor`, `/feedback`
**Custom:** `./.zero/commands/*.md`
### F18 — Git Integration (P1)
- Worktree isolation per task
- Auto-commit with messages
- Branch management
- PR creation
- Diff review
- Conflict resolution
### F19 — Testing & Verification (P1)
- `run_tests` tool
- Self-verification loop
- Test runner integration
- Coverage reporting
- Diagnostics (typecheck, lint)
- LSP integration
### F20 — Web Access (P1)
- `web_fetch` — fetch URL content
- `web_search` — search engine
- Provider/permission gated
- Rate limiting
- Caching
### F21 — Sandbox (P2)
- Linux: bubblewrap
- macOS: sandbox-exec
- Windows: AppContainer
- Filesystem policies
- Network policies
- Unix socket controls
### F22 — Multi-modal (P2)
- Image input (screenshots, diagrams)
- PDF parsing
- Voice input (optional)
- File attachments
- Paste support
### F23 — Telemetry (P2)
- Local-only metrics
- Token usage tracking
- Cost reports
- Performance profiling
- Crash reports (opt-in)
- NO vendor telemetry
### F24 — Advanced Features (P2)
- Code review automation
- Auto-refactor
- Dependency updates
- Security scanning
- Documentation generation
---
## 5. Data Models
### 5.1 Headless Event Stream
```json
{ "type": "run_start", "runId": "r1", "sessionId": "s1", "cwd": "/repo", "model": "..." }
{ "type": "text", "delta": "..." }
{ "type": "tool_call", "id": "t1", "name": "bash", "args": {...}, "sideEffect": "shell" }
{ "type": "permission", "id": "t1", "decision": "needs_approval", "reason": "shell" }
{ "type": "tool_result", "id": "t1", "status": "ok", "output": "...", "truncated": false }
{ "type": "usage", "promptTokens": 1234, "completionTokens": 567, "costUsd": 0.012 }
{ "type": "plan_update", "plan": [{ "id": "1", "content": "...", "status": "in_progress" }] }
{ "type": "turn_end", "stopReason": "end_turn" }
{ "type": "error", "code": "rate_limit", "message": "...", "recoverable": true }
{ "type": "run_end", "status": "success", "exitCode": 0 }
```
### 5.2 Session On Disk
```
~/.local/share/zero/sessions/<id>/
meta.json # { schemaVersion, id, parentId?, title, cwd, model, createdAt, updatedAt, tokens, cost }
events.jsonl # append-only AgentEvent stream
messages.jsonl # provider-facing messages
checkpoints/ # snapshots for restore/fork/compact
```
### 5.3 Tool Contract
```go
type Tool interface {
Name() string
Description() string
Parameters() JSONSchema
SideEffect() SideEffect
Execute(ctx ToolContext, args map[string]any) ToolResult
}
type ToolResult struct {
Status string
Output string
Truncated bool
Meta map[string]any
}
```
### 5.4 Config Schema
```json
{
"model": "claude-sonnet-4-6",
"providers": {
"anthropic": { "apiKey": "env:AN..._KEY" },
"openai": { "apiKey": "env:OP..._KEY" }
},
"permissions": {
"autonomy": "medium",
"grants": [{ "tool": "bash", "pattern": "git status", "decision": "allow" }]
},
"ui": { "theme": "dark", "syntaxHighlighting": true }
}
```
---
## 6. Non-Functional Requirements
### Performance
- TTFT < 500ms after provider's first byte
- No full-transcript repaint per token
- Cold start < 300ms (warm cache)
- Tool execution < 100ms overhead
- Session resume < 1s
### Reliability
- Crash-free TUI under resize, wide-char, non-TTY
- Clean interrupt (no half-written state)
- Atomic session writes
- Recovery from malformed JSONL
- Provider failover
### Security
- No mutation without permission decision (hard invariant)
- API keys never logged
- Secret redaction everywhere
- Headless stdout free of logs
- Workspace boundary by default
- Sandboxing for untrusted code
### Portability
- Linux, macOS, Windows
- Any OpenAI-compatible endpoint
- x86_64, ARM64
- Single binary distribution
### Testability
- Mock provider for unit tests
- Fake MCP server/client
- Fake terminal/PTY
- Sandbox simulator
- Deterministic event replay
---
## 7. Milestones
### M0 — Foundation (Weeks 1-2)
- Go module setup
- Agent core skeleton
- 8 core tools (read, write, edit, bash, grep, glob, apply_patch, update_plan)
- OpenAI-compatible provider
- Basic Bubble Tea TUI
- Permission gate (read vs mutate)
- Session persistence
**Exit:** `zero` runs, can read/edit files, basic chat works.
### M1 — Multi-Provider (Weeks 3-4)
- Anthropic provider
- Gemini provider
- Model registry
- Per-task model selection
- Provider rotation
- Cost tracking
- Headless `exec` mode
**Exit:** Switch between Claude/GPT/Gemini, see cost, run headless.
### M2 — Polish (Weeks 5-8)
- Advanced TUI features
- Full markdown + syntax highlighting
- Tool result truncation/pagination
- Auto-compaction
- Context budgeting
- 20 slash commands
- Doctor command
- Search command
**Exit:** Production-ready TUI, smooth streaming, all commands work.
### M3 — Distribution (Weeks 9-10)
- Single binary build
- Self-update
- Cross-platform CI
- VS Code extension MVP
- Documentation site
**Exit:** `zero` ships as one binary, works on Win/Mac/Linux.
### M4 — Extensibility (Weeks 11-14)
- MCP client + permissions
- Hooks system
- Skills system
- Memory (AGENTS.md)
- Plugin loading
- Subagents (built-in: review, security)
**Exit:** Plugins work, MCP servers connect, memory persists.
### M5 — Advanced (Weeks 15-20)
- Web fetch/search
- Git worktrees
- Self-verification
- Diagnostics (LSP, typecheck)
- Testing integration
- Code review automation
**Exit:** Zero can review code, run tests, manage git.
### M6 — Sandbox & Security (Weeks 21-24)
- Linux sandbox (bubblewrap)
- macOS sandbox
- Windows AppContainer
- Network policies
- Filesystem policies
**Exit:** Untrusted code runs safely in sandbox.
### Beyond v1.0
- Multi-modal (images, PDFs)
- Voice input
- Cloud sync (optional)
- Team features
- Marketplace (if demand)
---
## 8. Risks & Mitigations
| # | Risk | Mitigation |
|---|------|------------|
| R1 | Scope creep | Stick to milestones, defer features |
| R2 | Provider API changes | Abstract via interface, test with mocks |
| R3 | TUI complexity | Use Bubble Tea/Lip Gloss, follow mature terminal agent UX patterns |
| R4 | Performance | Benchmark early, optimize streaming |
| R5 | Security vulnerabilities | Permission gate from day 1, security review |
| R6 | Cross-platform bugs | CI on Win/Mac/Linux from M0 |
| R7 | Breaking changes | Schema versioning, migration tools |
---
## 9. Success Metrics
### v1.0 (6 months)
- 5+ providers supported
- 12+ tools
- 100% test coverage on core
- < 300ms cold start
- Single binary < 50MB
- Works on Win/Mac/Linux
- 1000+ GitHub stars
- 50+ contributors
### v2.0 (12 months)
- VS Code extension published
- MCP server mode
- Plugin ecosystem (20+ plugins)
- 10K+ active users
- Compete with Claude Code on benchmarks
---
## 10. Out of Scope (v1.0)
- Cloud sync / hosted backend
- Relay / remote SSH execution
- Multi-agent mission mode
- Plugin marketplace
- Auto-generated wikis
- Git authorship notes
- Vendor telemetry
- Voice input
- Mobile support
---
## 11. Open Decisions
1. **TUI library:** Bubble Tea + Lip Gloss — **Decide: Go-native TUI** (single-binary friendly, mature terminal ecosystem)
2. **Storage:** JSONL files vs SQLite — **Decide: JSONL** (inspectable, simple)
3. **Package structure:** Single vs monorepo — **Decide: Single** until M4
4. **Provider abstraction:** Direct API vs wrapper — **Decide: Direct** (portability)
5. **Patch strategy:** apply_patch vs edit_file — **Decide: Both** (apply_patch for bulk, edit_file for small)
---
## 12. Team & Timeline
**Team (3 people):**
- You (lead + TUI + tools)
- Backend dev (providers + core)
- Designer (VS Code extension + docs)
**Timeline:**
- M0-M1: 4 weeks (foundation)
- M2-M3: 6 weeks (polish + distribution)
- M4-M5: 8 weeks (extensibility)
- M6: 4 weeks (sandbox)
- **Total v1.0: 6 months**
---
## 13. Why This Will Work
1. **Proven patterns** — Based on clean-room analysis of existing terminal coding-agent UX patterns
2. **Multi-provider** — Unique advantage vs Claude Code/Cursor
3. **Open source** — Community contributions
4. **Go** — Native binary, fast startup, strong concurrency, cross-platform CLI ergonomics
5. **npm distribution wrapper** — Keeps JavaScript ecosystem installation while the runtime stays native
6. **Clear milestones** — Ship early, iterate
---
**This is the real PRD. 13 sections, 24 features, 6 months to v1.0.**
-53
View File
@@ -1,53 +0,0 @@
# Zero Stream-JSON Protocol
Zero stream-json is a line-delimited protocol for headless clients such as
editor extensions and automation wrappers.
Every line is one JSON object. Empty input lines are ignored. Output events are
redacted before they are written to stdout.
## Version
Current schema version: `1`
Every input and output event must include:
```json
{ "schemaVersion": 1, "type": "..." }
```
Output events also include `runId`.
## Input Events
`zero exec --input-format stream-json` accepts these JSONL events from stdin or
`--file`.
```json
{ "schemaVersion": 1, "type": "message", "role": "user", "content": "Inspect this repo." }
{ "schemaVersion": 1, "type": "prompt", "content": "Return only blockers." }
```
Zero combines accepted input event content in order, separated by blank lines.
Unknown fields are rejected so protocol clients catch drift early.
## Output Events
`zero exec --output-format stream-json` emits schema-versioned JSONL events.
```json
{ "schemaVersion": 1, "type": "run_start", "runId": "run_20260603_abc123", "sessionId": "zero_20260603100000_abc123", "cwd": "/repo", "provider": "openai", "model": "gpt-4.1", "apiModel": "gpt-4.1" }
{ "schemaVersion": 1, "type": "text", "runId": "run_20260603_abc123", "delta": "..." }
{ "schemaVersion": 1, "type": "tool_call", "runId": "run_20260603_abc123", "id": "call_1", "name": "read_file", "args": { "path": "README.md" }, "sideEffect": "read" }
{ "schemaVersion": 1, "type": "tool_result", "runId": "run_20260603_abc123", "id": "call_1", "status": "ok", "output": "...", "truncated": false }
{ "schemaVersion": 1, "type": "usage", "runId": "run_20260603_abc123", "promptTokens": 12, "completionTokens": 8, "totalTokens": 20 }
{ "schemaVersion": 1, "type": "final", "runId": "run_20260603_abc123", "text": "..." }
{ "schemaVersion": 1, "type": "run_end", "runId": "run_20260603_abc123", "status": "success", "exitCode": 0 }
```
Errors are part of the protocol and are followed by `run_end`.
```json
{ "schemaVersion": 1, "type": "error", "runId": "run_20260603_abc123", "code": "provider_error", "message": "...", "recoverable": false }
{ "schemaVersion": 1, "type": "run_end", "runId": "run_20260603_abc123", "status": "error", "exitCode": 3 }
```
-21
View File
@@ -1,21 +0,0 @@
# Zero Update Flow
`zero update --check` checks the latest GitHub release for `Gitlawb/zero` and compares it with the local CLI version.
For M2 this command is intentionally check-only:
- It does not replace the running binary.
- It exits with code `0` when the check succeeds, even when an update is available.
- It exits with code `1` when the release check cannot be completed.
- `--json` prints the same result in a machine-readable format for scripts and CI.
- Release checks time out after 5 seconds by default.
The release endpoint resolves in this order:
- `options.endpoint` when calling `checkForUpdate` from code.
- `ZERO_UPDATE_RELEASE_URL` from the environment.
- `https://api.github.com/repos/Gitlawb/zero/releases/latest`.
`options.endpoint` and `ZERO_UPDATE_RELEASE_URL` may be a full URL or an `owner/repo` slug.
Installer scripts download the matching release asset for the local platform and verify its `.sha256` file. If Zero is already installed, use this command before re-running the installer.
-572
View File
@@ -1,572 +0,0 @@
# Zero WorkSplit PRD v2: Balanced And Conflict-Free Team Plan
Status: Draft v2.0
Date: 2026-06-02
Timeline: 6 months to v1.0
Team: Vasanth, Gnanam, Anandan
This v2 replaces the conflict points in the original WorkSplit PRD:
- UI/TUI surfaces are separated from backend capabilities.
- Headless `exec` is split into CLI surface, runtime protocol, and distribution responsibilities.
- Search is scheduled after a minimal session event store exists.
- Slash commands are split into command UI, backend APIs, and integration commands.
- Anandan's workload is narrowed to infra/distribution/integrations, with clear dependencies on Gnanam runtime contracts.
- Each milestone gives all 3 people similar effort and reviewable PR slices.
## Product Goal
Zero is a production coding agent CLI/TUI that can:
- Chat and stream responses in a polished terminal UI.
- Read, write, edit, search, and run tools safely.
- Use OpenAI-compatible, Anthropic, and Gemini providers.
- Track context and cost.
- Run headless for scripts, automation, CI, and VS Code.
- Persist, resume, fork, search, and export sessions.
- Connect to MCP servers and load local plugins.
- Run tests, manage git, self-verify changes, and operate inside sandbox policies.
## Ownership Rules
These rules prevent future conflicts:
1. Every feature has one Directly Responsible Owner.
2. UI/TUI commands and backend modules are separate deliverables.
3. Cross-owner features must land the backend contract before UI/integration work depends on it.
4. Provider-specific logic stays inside provider modules.
5. Session/event protocol is owned by Gnanam and consumed by TUI, VS Code, MCP, and automation.
6. Distribution work packages stable behavior; it does not define runtime semantics.
7. Permission prompts are Vasanth's UI, but grants/policy/sandbox enforcement are Gnanam's backend.
8. Windows platform implementation is Anandan's, but it must use the shared sandbox policy interface.
## Team Roles
| Person | Primary role | Owns | Does not own |
|---|---|---|---|
| Vasanth | Product lead, TUI, tools UX | Bubble Tea TUI, slash command UI, tool result rendering, local tool UX, permission prompts, themes, command palette, user-facing flows | Provider internals, session protocol, MCP transports, release pipeline |
| Gnanam | Runtime backend, providers, protocols | Model registry, provider factory, Anthropic/Gemini, usage/cost, session store, stream-json, config validation, doctor/search backend, MCP/plugin backend, permissions/grants, sandbox policy | TUI rendering, binary packaging, VS Code extension UI |
| Anandan | Infra, distribution, external integrations | CI, build, single binary, release packages, installers, self-update, performance benchmarks, VS Code extension, GitHub PR integration, Windows sandbox, release security | Provider internals, TUI command rendering, model registry |
## Shared Contracts
These contracts are required before dependent work starts:
| Contract | Owner | Consumers | Needed by |
|---|---|---|---|
| Provider stream event schema | Gnanam | Vasanth, Anandan | TUI streaming, headless output, VS Code |
| Model registry API | Gnanam | Vasanth | `/model`, `/effort`, cost, context budget |
| Session event schema | Gnanam | Vasanth, Anandan | `/resume`, `/rewind`, search, VS Code, audit |
| Command registry shape | Vasanth | Gnanam, Anandan | Slash command registration and help |
| Build/test scripts | Anandan | Vasanth, Gnanam | CI, DoD, release |
| Permission/grant policy | Gnanam | Vasanth, Anandan | prompts, MCP permissions, sandbox, Windows |
| Stream-json protocol | Gnanam | Anandan | VS Code extension and automation |
## Milestone M0: Foundation Baseline, Weeks 1-2
Goal: Zero runs locally, has basic tools, one OpenAI-compatible provider, basic TUI, tests, and project scripts.
Current status: the earlier TS/Bun M0 baseline is historical context only. The Go migration resets M0 around a new Go module, `cmd/zero` entrypoint, Bubble Tea TUI shell, core tools, tests, and validation commands. M1 work should start only after these Go foundation PRs are merged.
### Vasanth
- Tool interface with JSON Schema-compatible parameter validation.
- Core file tools: read, write, edit, grep, list directory, bash, plan.
- Basic Bubble Tea TUI and message/tool rendering.
- Basic agent loop integration.
- PR: `feat/go-m0-tools-tui`.
### Gnanam
- Define the first Go provider contract for the M0 OpenAI-compatible provider.
- Define first draft of normalized `Usage` type.
- Define first draft of model registry type.
- Review config loader and list missing provider/config fields.
- PR: `feat/go-m0-runtime-contracts`.
### Anandan
- Add stable project commands: `go test ./...`, `go build ./cmd/zero`, and the npm wrapper smoke checklist.
- Add initial CI smoke job.
- Verify Go toolchain version, module cache behavior, and npm wrapper lockfile behavior.
- Add build artifact smoke check.
- PR: `feat/m0-ci-scripts`.
### M0 Done
- `go test ./...` passes.
- `go build ./cmd/zero` passes.
- npm wrapper smoke checklist exists when npm packaging files are touched.
- `zero` can run, read a file, edit a file, and stream a response.
### npm Wrapper Smoke Checklist
When npm distribution files are changed, the smoke check must verify:
- `package.json` exists with the expected package name and version.
- `npm ci` succeeds from the wrapper package directory.
- The wrapper binary resolves through the package `bin` entry and `node_modules/.bin`.
- `zero --version` or `zero --help` exits 0 and reports the expected version or command surface.
- The designated npm smoke/build script exits 0.
## Milestone M1: Multi-Provider And Headless Foundation, Weeks 3-4
Goal: Zero can select models/providers, use Claude/Gemini/OpenAI-compatible providers, and run headless with stable output.
### Vasanth: TUI And CLI Surface
- Model selector UI and `/model` command shell.
- Reasoning effort UI and `/effort` command shell.
- Header/footer model display.
- Streaming output polish in TUI.
- Headless command surface: parse `zero exec`, flags, output mode selection.
- Exit code mapping display and CLI user messages.
- PRs:
- `feat/m1-model-selector-ui`
- `feat/m1-headless-cli-surface`
### Gnanam: Provider Runtime
- Model registry with 10+ models, cost, context limits, capabilities.
- Provider factory that resolves model/profile/provider.
- Anthropic provider with streaming text, tool calls, system prompt, usage.
- Gemini provider with streaming text, tool calls, usage, vision-ready input shape.
- Normalized provider event schema and usage type.
- PRs:
- `feat/m1-model-registry`
- `feat/m1-provider-factory`
- `feat/m1-anthropic-provider`
- `feat/m1-gemini-provider`
### Anandan: Build And CI
- Single binary build spike with Go cross-compilation.
- CI matrix for Linux/macOS/Windows test and build smoke.
- Release packaging formats: tar.gz for Unix, zip for Windows.
- Artifact upload on tags or manual workflow.
- PRs:
- `feat/m1-ci-matrix`
- `feat/m1-binary-build`
- `feat/m1-release-packaging`
### M1 Dependency Order
1. Gnanam lands model registry.
2. Gnanam lands provider factory.
3. Vasanth connects model selector to registry.
4. Gnanam lands Anthropic/Gemini.
5. Vasanth finishes provider-aware TUI/headless surface.
6. Anandan packages once scripts and entrypoints are stable.
### M1 Done
- `zero exec -m <model> "list files"` runs through provider factory.
- OpenAI-compatible provider still works.
- Anthropic and Gemini providers pass mocked stream tests.
- Model selector reads registry data.
- CI runs tests and build checks on PR.
- Binary build has at least one working platform artifact or documented blocker.
## Milestone M2: Core Commands, Cost, Observability, Install Flow, Weeks 5-8
Goal: Core slash commands work, cost and diagnostics are visible, sessions have a minimal event store, and installation/update flows begin.
### Vasanth: Core Command UX
- Slash command framework and help registry.
- Session commands UI: `/clear`, `/compact`, `/context`, `/resume`, `/exit`.
- Model commands UI: `/model`, `/effort`, `/style`.
- Meta commands UI: `/help`, `/config`, `/doctor`.
- Tool result truncation and pagination UI.
- Auto-compaction prompt UX.
- PRs:
- `feat/m2-slash-framework`
- `feat/m2-session-model-commands`
- `feat/m2-tool-truncation`
- `feat/m2-compaction-ui`
### Gnanam: Observability Backend
- Minimal session event store to support cost/search/resume later.
- Cost tracking from normalized usage and model registry.
- `zero doctor` backend checks: binary, config, provider, connectivity, model validity.
- `zero search` backend over local session events.
- Config inspection and validation API for `/config`.
- Secret redaction helper used by logs, provider errors, doctor, feedback.
- PRs:
- `feat/m2-minimal-session-events`
- `feat/m2-cost-tracking`
- `feat/m2-doctor`
- `feat/m2-search`
- `feat/m2-secret-redaction`
### Anandan: Install, Update, Performance
- Self-update design and `zero update --check`.
- Install scripts for Linux/macOS and PowerShell draft for Windows.
- Performance benchmark harness: cold start, TTFT, memory.
- CI performance smoke job with threshold warnings.
- Release checksum generation.
- PRs:
- `feat/m2-update-check`
- `feat/m2-install-scripts`
- `feat/m2-perf-bench`
- `feat/m2-release-checksums`
### M2 Command Scope
Fully working in M2:
- `/clear`
- `/compact`
- `/context`
- `/resume`
- `/exit`
- `/model`
- `/effort`
- `/style`
- `/help`
- `/config`
- `/doctor`
Registered but full backend lands later:
- `/rewind`: full in M3 with session event replay.
- `/permissions`: full in M4/M6 with permission/grant system.
- `/init`, `/memory`, `/skills`: full in M4 with memory/skills.
- `/mcp`, `/hooks`, `/agents`: full in M4 with MCP/hooks/subagents.
- `/feedback`: full in M3/M5 with integration/redaction.
### M2 Done
- TUI command framework is stable.
- Core 11 commands work.
- Cost shows in footer/session summary.
- `zero doctor` gives redacted pass/warn/fail checks.
- `zero search` can search persisted local events.
- Install scripts and update check have smoke tests.
## Milestone M3: Sessions, Stream Protocol, VS Code MVP, Weeks 9-10
Goal: Sessions are durable, stream-json is stable, and VS Code can drive Zero through the protocol.
### Vasanth: TUI Session UX
- Themes: light, dark, custom config.
- Mouse support for selectors and command palette where feasible.
- Collapsible tool rows with one-line summaries.
- `/rewind` interactive UI using Gnanam's session event API.
- Session picker polish for `/resume` and fork display.
- PRs:
- `feat/m3-themes`
- `feat/m3-tool-rows`
- `feat/m3-rewind-ui`
### Gnanam: Headless And Sessions
- Full session persistence under `~/.local/share/zero/sessions/<id>/`.
- Append-only `events.jsonl`.
- `--resume` and `--fork` support.
- Stream-json input/output protocol with schema tests.
- Config validation errors with source paths.
- Session search indexing improvements.
- PRs:
- `feat/m3-sessions`
- `feat/m3-resume-fork`
- `feat/m3-stream-json`
- `feat/m3-config-validation`
### Anandan: VS Code And Docs Distribution
- VS Code extension MVP using stream-json.
- Inline chat panel.
- Diff view integration.
- Homebrew tap or formula draft.
- Documentation site skeleton with install and CLI docs.
- PRs:
- `feat/m3-vscode-mvp`
- `feat/m3-homebrew`
- `feat/m3-docs-site`
### M3 Dependency Order
1. Gnanam freezes stream-json event schema.
2. Anandan builds VS Code against that schema.
3. Gnanam lands session resume/fork.
4. Vasanth wires `/rewind` and session picker.
5. Anandan documents protocol and install path.
### M3 Done
- Sessions survive process restart.
- `zero exec --resume <id>` works.
- `zero exec --fork <id>` creates a new session branch.
- Stream-json is line-delimited and schema-tested.
- VS Code MVP can send prompt and render streamed text/tool events.
## Milestone M4: Extensibility, MCP, Skills, Subagents, Weeks 11-14
Goal: Users can extend Zero with memory, skills, hooks, MCP servers, plugins, and subagents.
### Vasanth: Extensibility UX
- `/init` generates project memory scaffold.
- `/memory` inspect/reload UI.
- `/skills` manager UI.
- `/hooks` manager UI.
- `/mcp` manager UI shell.
- `/agents` selector UI.
- PRs:
- `feat/m4-memory-ui`
- `feat/m4-skills-ui`
- `feat/m4-extensibility-commands`
### Gnanam: MCP And Plugin Backend
- MCP client with stdio, HTTP, and SSE transports.
- Surface MCP tools in Zero tool registry.
- MCP permissions: per-server and per-tool grants, list/revoke/clear.
- Local plugin loader from `~/.config/zero/plugins/` and `.zero/plugins/`.
- Plugin manifest validation with `plugin.json`.
- MCP server mode: `zero serve --mcp`.
- Hook config storage and audit events.
- PRs:
- `feat/m4-mcp-client`
- `feat/m4-mcp-permissions`
- `feat/m4-plugin-loader`
- `feat/m4-mcp-server`
- `feat/m4-hook-backend`
### Anandan: Subagents And Integration Docs
- Subagent framework using Gnanam's parent/child session linkage.
- `task` tool implementation for scoped subagents.
- Built-in agents: code-review, security-review, refactor, test-gen.
- VS Code support for subagent session tree.
- Extensibility docs for MCP/plugins/agents.
- PRs:
- `feat/m4-subagent-framework`
- `feat/m4-builtin-agents`
- `feat/m4-vscode-agents`
- `feat/m4-extensibility-docs`
### M4 Dependency Order
1. Gnanam lands MCP tool registry integration.
2. Vasanth wires `/mcp` UI to backend.
3. Gnanam lands parent/child session APIs.
4. Anandan lands subagent framework.
5. Vasanth wires `/agents` UI.
### M4 Done
- User can add and call an MCP tool.
- User can list/revoke/clear MCP permissions.
- User can load a local plugin manifest.
- User can run `zero serve --mcp`.
- User can spawn a scoped subagent through `task`.
- Extensibility commands are fully functional.
## Milestone M5: Advanced Agent Operations, Weeks 15-20
Goal: Zero can verify work, manage git/test flows, and assist code review.
### Vasanth: Advanced Tool UX
- `web_fetch` and `web_search` tools with safe display and truncation.
- LSP diagnostics display in TUI.
- Typecheck/lint result rendering.
- Rich test failure rendering in TUI.
- Review summary UI for agent findings.
- PRs:
- `feat/m5-web-tools-ui`
- `feat/m5-diagnostics-ui`
- `feat/m5-verification-ui`
### Gnanam: Git And Verification Backend
- Git worktree isolation per task.
- Auto-commit backend: detect changes and generate commit messages.
- Test runner detection and execution.
- Parse test results into structured summaries.
- Self-verification loop: run checks after edits, retry within budget.
- PRs:
- `feat/m5-worktrees`
- `feat/m5-autocommit`
- `feat/m5-test-runner`
- `feat/m5-self-verify`
### Anandan: GitHub And Review Automation
- GitHub API integration for PR metadata and comments.
- Automated code-review workflow using built-in agent.
- Security-review workflow.
- Dependency update detection.
- CI integration for agent-generated reports.
- PRs:
- `feat/m5-github-integration`
- `feat/m5-code-review-agent`
- `feat/m5-security-review-agent`
- `feat/m5-dependency-updates`
### M5 Dependency Order
1. Gnanam lands git/test backend APIs.
2. Vasanth renders verification output.
3. Anandan builds GitHub PR review workflow on git/test/session APIs.
4. Anandan publishes CI report integration.
### M5 Done
- Zero can create/use a git worktree for a task.
- Zero can run project tests and parse failures.
- Zero can self-verify after edits.
- Zero can produce a code-review report.
- GitHub PR comment posting works when configured.
## Milestone M6: Sandbox And Security, Weeks 21-24
Goal: Zero can safely run higher-autonomy workflows with OS-aware sandboxing and durable permission grants.
### Vasanth: Security UX
- Permission prompts: allow, deny, always allow, always deny.
- Autonomy level UI: low, medium, high.
- Permission audit log view.
- Sandbox violation rendering.
- Security warnings in TUI and headless text output.
- PRs:
- `feat/m6-permission-prompts`
- `feat/m6-autonomy-ui`
- `feat/m6-audit-ui`
### Gnanam: Sandbox Backend
- Shared sandbox policy interface.
- Linux sandbox using bubblewrap.
- macOS sandbox using sandbox-exec or best available policy backend.
- Persistent grants: allow/deny list/revoke/clear.
- Network and filesystem policy enforcement.
- Structured sandbox violation errors.
- PRs:
- `feat/m6-sandbox-policy`
- `feat/m6-sandbox-linux`
- `feat/m6-sandbox-macos`
- `feat/m6-persistent-grants`
### Anandan: Windows And Release Security
- Windows sandbox using AppContainer or best supported Windows isolation.
- Release signing/checksum verification.
- Security CI hardening.
- Dependency vulnerability scanning.
- Security audit and penetration test coordination.
- PRs:
- `feat/m6-sandbox-windows`
- `feat/m6-release-signing`
- `feat/m6-security-ci`
- `feat/m6-security-audit`
### M6 Dependency Order
1. Gnanam lands shared sandbox policy interface.
2. Vasanth wires prompts to the shared policy.
3. Gnanam implements Linux/macOS policy adapters.
4. Anandan implements Windows adapter using same policy.
5. Anandan finalizes release security.
### M6 Done
- `zero --auto high` uses permission policy and sandbox backend.
- Persistent grants work across sessions.
- Linux/macOS sandboxing works or has documented OS-specific fallback.
- Windows sandbox adapter works or has documented fallback.
- Security audit findings are triaged and fixed or accepted.
## Slash Command Ownership Matrix
| Command | UI owner | Backend owner | Fully done by | Notes |
|---|---|---|---|---|
| `/clear` | Vasanth | Gnanam | M2 | New session/reset event. |
| `/compact` | Vasanth | Gnanam | M2 | Requires token/cost/context backend. |
| `/context` | Vasanth | Gnanam | M2 | Registry + usage + tool/MCP context. |
| `/rewind` | Vasanth | Gnanam | M3 | Requires durable event store. |
| `/resume` | Vasanth | Gnanam | M3 | M2 can list minimal sessions; M3 full resume/fork. |
| `/exit` | Vasanth | Gnanam | M2 | Must flush session-end event. |
| `/model` | Vasanth | Gnanam | M1/M2 | Registry lands M1, UI polished M2. |
| `/effort` | Vasanth | Gnanam | M1/M2 | Reasoning metadata from registry. |
| `/style` | Vasanth | Vasanth | M2 | UI/config preference only unless style affects prompts. |
| `/permissions` | Vasanth | Gnanam | M4/M6 | MCP permissions in M4, sandbox grants in M6. |
| `/init` | Vasanth | Vasanth | M4 | Project memory scaffold. |
| `/memory` | Vasanth | Vasanth | M4 | Memory inspect/reload. |
| `/skills` | Vasanth | Gnanam | M4 | UI by Vasanth; plugin-provided skills backend by Gnanam. |
| `/mcp` | Vasanth | Gnanam | M4 | MCP client/server/backend by Gnanam. |
| `/hooks` | Vasanth | Gnanam | M4 | UI by Vasanth; hook config/audit backend by Gnanam. |
| `/agents` | Vasanth | Anandan | M4 | Requires Gnanam parent/child session API. |
| `/help` | Vasanth | Vasanth | M2 | Command metadata registry. |
| `/config` | Vasanth | Gnanam | M2/M3 | Inspect in M2, validation source errors in M3. |
| `/doctor` | Vasanth | Gnanam | M2 | Backend CLI and TUI share same checks. |
| `/feedback` | Vasanth | Anandan | M3/M5 | Redaction by Gnanam; GitHub/product integration by Anandan. |
## Headless Exec Ownership
Headless `exec` is split to avoid conflict:
| Area | Owner | Scope |
|---|---|---|
| CLI command and flags | Vasanth | `zero exec`, `--model`, `--output-format`, help text, exit message display. |
| Runtime execution | Gnanam | Provider factory, stream events, usage, tool loop hooks, session recording, stream-json. |
| Packaging and CI | Anandan | Binary command works across platforms, CI tests headless modes, release artifacts. |
## Permission Ownership
| Area | Owner | Scope |
|---|---|---|
| Prompt UI | Vasanth | Allow/deny/always controls and TUI display. |
| Policy engine | Gnanam | Risk classification, grants, persistent decisions, MCP permissions, sandbox policy. |
| Platform adapters | Gnanam + Anandan | Gnanam owns Linux/macOS; Anandan owns Windows using same policy. |
## Definition Of Done Per PR
Every PR must include:
- Tests for changed behavior.
- `go test ./...` must pass when the PR modifies any Go code; docs-only PRs are exempt and must say so in the PR description.
- `go build ./cmd/zero` must pass when the PR modifies `cmd/zero` or runtime packages used by the entrypoint; docs-only and npm-wrapper-only PRs are exempt and must say so in the PR description.
- The npm wrapper smoke checklist must pass when the PR modifies npm distribution files.
- PR description explains what changed and why.
- No unrelated refactors.
- Redaction used for secrets in logs/errors/tests.
- Cross-owner API changes include a small contract note or test.
## Review Rotation
| Author | Primary reviewer | Secondary reviewer when cross-owner |
|---|---|---|
| Vasanth | Gnanam | Anandan for distribution/VS Code impact |
| Gnanam | Anandan | Vasanth for TUI/UX impact |
| Anandan | Vasanth | Gnanam for runtime/protocol impact |
## Balanced Workload Summary
| Milestone | Vasanth load | Gnanam load | Anandan load |
|---|---|---|---|
| M0 | Core tools + TUI baseline | Runtime contracts | Scripts + CI baseline |
| M1 | Model/headless UI | Registry + providers | CI + binary + packaging |
| M2 | Core slash UX + compaction UI | Cost + doctor + search + events | Install + update + perf |
| M3 | Session UI + themes | Sessions + stream-json | VS Code + docs + Homebrew |
| M4 | Extensibility UI | MCP + plugins + permissions | Subagents + agent docs |
| M5 | Verification/web UX | Git + tests + self-verify | GitHub review automation |
| M6 | Security UX | Linux/macOS sandbox + grants | Windows sandbox + release security |
This keeps workload balanced while preserving clean ownership boundaries.
## Immediate Next Steps
1. Reset M0 for the Go migration and treat old TS/Bun PR #6 as historical only.
2. Anandan starts `feat/go-m0-module-entrypoint` with `go.mod`, `cmd/zero`, `go test ./...`, and `go build ./cmd/zero`.
3. Gnanam starts `feat/go-m0-runtime-contracts` for the Go provider/tool/runtime contracts.
4. Vasanth starts `feat/go-m0-tools-tui` for core tools, Bubble Tea startup shell, and basic agent loop integration.
5. Anandan adds CI smoke coverage for the Go commands and the npm wrapper smoke checklist when wrapper files land.
6. Start M1 model registry, provider factory, and model selector work only after the Go M0 foundation PRs are merged.
-35
View File
@@ -1,35 +0,0 @@
module github.com/Gitlawb/zero
go 1.24.2
toolchain go1.24.13
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
-52
View File
@@ -1,52 +0,0 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
-1
View File
@@ -1 +0,0 @@
console.log("Hello via Bun!");
-209
View File
@@ -1,209 +0,0 @@
package agent
import (
"context"
"encoding/json"
"errors"
"sort"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const defaultSystemPrompt = "You are Zero, a terminal coding agent. Help with the current workspace and use tools when needed."
const maxTurnsAnswer = "Agent reached maximum number of turns without a final answer."
func Run(ctx context.Context, prompt string, provider Provider, options Options) (Result, error) {
if provider == nil {
return Result{}, errors.New("agent provider is required")
}
maxTurns := options.MaxTurns
if maxTurns <= 0 {
maxTurns = 12
}
registry := options.Registry
if registry == nil {
registry = tools.NewRegistry()
}
permissionMode := options.PermissionMode
if permissionMode == "" {
permissionMode = PermissionModeAuto
}
messages := zeroruntime.SeedMessages(defaultSystemPrompt, prompt)
result := Result{Messages: copyMessages(messages)}
for turn := 0; turn < maxTurns; turn++ {
result.Turns = turn + 1
request := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: toolDefinitions(registry, permissionMode),
}
stream, err := provider.StreamCompletion(ctx, request)
if err != nil {
result.Messages = copyMessages(messages)
return result, err
}
collected := zeroruntime.CollectStreamWithOptions(ctx, stream, zeroruntime.CollectOptions{
OnText: options.OnText,
OnUsage: options.OnUsage,
})
if collected.Error != "" {
result.Messages = copyMessages(messages)
return result, errors.New(collected.Error)
}
if ctx.Err() != nil {
result.Messages = copyMessages(messages)
return result, ctx.Err()
}
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleAssistant,
Content: collected.Text,
ToolCalls: collected.ToolCalls,
})
if len(collected.ToolCalls) == 0 {
result.FinalAnswer = collected.Text
result.Messages = copyMessages(messages)
return result, nil
}
for _, call := range collected.ToolCalls {
if options.OnToolCall != nil {
options.OnToolCall(call)
}
toolResult := executeToolCall(ctx, registry, call, permissionMode)
if options.OnToolResult != nil {
options.OnToolResult(toolResult)
}
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleTool,
Content: toolResult.Output,
ToolCallID: toolResult.ToolCallID,
})
}
}
result.FinalAnswer = maxTurnsAnswer
result.Messages = copyMessages(messages)
return result, nil
}
func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCall, permissionMode PermissionMode) ToolResult {
args := map[string]any{}
if call.Arguments != "" {
if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: Failed to parse arguments for " + call.Name + ": " + err.Error(),
}
}
}
permissionGranted := permissionMode == PermissionModeUnsafe
if tool, ok := registry.Get(call.Name); ok && tool.Safety().Permission == tools.PermissionAllow {
permissionGranted = true
}
result := registry.RunWithOptions(ctx, call.Name, args, tools.RunOptions{
PermissionGranted: permissionGranted,
})
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.Output,
}
}
func toolDefinitions(registry *tools.Registry, permissionMode PermissionMode) []zeroruntime.ToolDefinition {
registeredTools := registry.All()
definitions := make([]zeroruntime.ToolDefinition, 0, len(registeredTools))
for _, tool := range registeredTools {
if !isAdvertised(tool, permissionMode) {
continue
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: tool.Name(),
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
})
}
sort.Slice(definitions, func(left int, right int) bool {
return definitions[left].Name < definitions[right].Name
})
return definitions
}
func schemaToRuntimeMap(schema tools.Schema) map[string]any {
parameters := map[string]any{
"type": schema.Type,
"additionalProperties": schema.AdditionalProperties,
}
if len(schema.Required) > 0 {
parameters["required"] = append([]string{}, schema.Required...)
}
if len(schema.Properties) > 0 {
properties := make(map[string]any, len(schema.Properties))
for name, property := range schema.Properties {
properties[name] = propertyToRuntimeMap(property)
}
parameters["properties"] = properties
}
return parameters
}
func propertyToRuntimeMap(property tools.PropertySchema) map[string]any {
schema := map[string]any{
"type": property.Type,
}
if property.Description != "" {
schema["description"] = property.Description
}
if len(property.Enum) > 0 {
schema["enum"] = append([]string{}, property.Enum...)
}
if property.Default != nil {
schema["default"] = property.Default
}
if property.Minimum != nil {
schema["minimum"] = *property.Minimum
}
if property.Maximum != nil {
schema["maximum"] = *property.Maximum
}
return schema
}
func isAdvertised(tool tools.Tool, permissionMode PermissionMode) bool {
if tool.Safety().Permission == tools.PermissionDeny {
return false
}
if permissionMode == PermissionModeAuto {
return tool.Safety().Permission == tools.PermissionAllow
}
return true
}
func copyMessages(messages []Message) []Message {
copied := make([]Message, len(messages))
for index, message := range messages {
copied[index] = message
if message.ToolCalls != nil {
copied[index].ToolCalls = append([]ToolCall{}, message.ToolCalls...)
}
}
return copied
}
-327
View File
@@ -1,327 +0,0 @@
package agent
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type mockProvider struct {
turns [][]zeroruntime.StreamEvent
requests []zeroruntime.CompletionRequest
}
func (provider *mockProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
provider.requests = append(provider.requests, request)
events := []zeroruntime.StreamEvent{{Type: zeroruntime.StreamEventDone}}
if len(provider.turns) >= len(provider.requests) {
events = provider.turns[len(provider.requests)-1]
}
ch := make(chan zeroruntime.StreamEvent, len(events))
for _, event := range events {
ch <- event
}
close(ch)
return ch, nil
}
func TestRunReturnsProviderText(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "hello"},
{Type: zeroruntime.StreamEventText, Content: " zero"},
{Type: zeroruntime.StreamEventDone},
}},
}
result, err := Run(context.Background(), "say hi", provider, Options{
Registry: tools.NewRegistry(),
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "hello zero" {
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
}
if len(provider.requests) != 1 {
t.Fatalf("expected one provider turn, got %d", len(provider.requests))
}
assertMessage(t, provider.requests[0].Messages[0], zeroruntime.MessageRoleSystem, "")
assertMessage(t, provider.requests[0].Messages[1], zeroruntime.MessageRoleUser, "say hi")
}
func TestRunEmitsTextDeltas(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "hello"},
{Type: zeroruntime.StreamEventText, Content: " zero"},
{Type: zeroruntime.StreamEventDone},
}},
}
var deltas []string
_, err := Run(context.Background(), "say hi", provider, Options{
Registry: tools.NewRegistry(),
OnText: func(delta string) { deltas = append(deltas, delta) },
})
if err != nil {
t.Fatal(err)
}
if strings.Join(deltas, "|") != "hello| zero" {
t.Fatalf("expected text deltas, got %#v", deltas)
}
}
func TestRunEmitsUsageEvents(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{PromptTokens: 12, CompletionTokens: 5, CachedInputTokens: 2}},
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}},
}
var usages []zeroruntime.Usage
_, err := Run(context.Background(), "track usage", provider, Options{
Registry: tools.NewRegistry(),
OnUsage: func(usage zeroruntime.Usage) { usages = append(usages, usage) },
})
if err != nil {
t.Fatal(err)
}
if len(usages) != 1 {
t.Fatalf("expected one usage event, got %#v", usages)
}
if usages[0].PromptTokens != 12 || usages[0].CompletionTokens != 5 || usages[0].CachedInputTokens != 2 {
t.Fatalf("unexpected usage event: %#v", usages[0])
}
}
func TestRunAdvertisesRuntimeToolDefinitions(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}},
}
_, err := Run(context.Background(), "what tools exist?", provider, Options{
Registry: registry,
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) != 1 {
t.Fatalf("expected one request, got %d", len(provider.requests))
}
if len(provider.requests[0].Tools) != 1 {
t.Fatalf("expected one advertised tool, got %#v", provider.requests[0].Tools)
}
toolDefinition := provider.requests[0].Tools[0]
if toolDefinition.Name != "read_file" {
t.Fatalf("expected read_file definition, got %#v", toolDefinition)
}
parameters := toolDefinition.Parameters
if parameters["type"] != "object" {
t.Fatalf("expected object schema, got %#v", parameters)
}
if parameters["additionalProperties"] != false {
t.Fatalf("expected additionalProperties=false, got %#v", parameters["additionalProperties"])
}
properties, ok := parameters["properties"].(map[string]any)
if !ok {
t.Fatalf("expected properties map, got %#v", parameters["properties"])
}
pathProperty, ok := properties["path"].(map[string]any)
if !ok {
t.Fatalf("expected path property map, got %#v", properties["path"])
}
if pathProperty["type"] != "string" || pathProperty["description"] == "" {
t.Fatalf("unexpected path property schema: %#v", pathProperty)
}
required, ok := parameters["required"].([]string)
if !ok || len(required) != 1 || required[0] != "path" {
t.Fatalf("unexpected required fields: %#v", parameters["required"])
}
}
func TestRunExecutesToolCallThroughRegistry(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\n")
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "read done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var toolResults []ToolResult
result, err := Run(context.Background(), "read notes", provider, Options{
Registry: registry,
OnToolResult: func(result ToolResult) { toolResults = append(toolResults, result) },
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "read done" {
t.Fatalf("expected final answer from second turn, got %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected provider to be called twice, got %d", len(provider.requests))
}
lastMessage := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
assertMessage(t, lastMessage, zeroruntime.MessageRoleTool, "alpha")
if lastMessage.ToolCallID != "call-1" {
t.Fatalf("expected tool_call_id call-1, got %q", lastMessage.ToolCallID)
}
if len(toolResults) != 1 || toolResults[0].Status != tools.StatusOK {
t.Fatalf("expected one ok tool result, got %#v", toolResults)
}
}
func TestRunDeniesPromptToolWithoutUnsafePermission(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewWriteFileTool(root))
provider := providerCallingWriteFileThenAnswer("write denied")
result, err := Run(context.Background(), "write notes", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "write denied" {
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
}
if _, err := os.Stat(filepath.Join(root, "notes.txt")); !os.IsNotExist(err) {
t.Fatalf("expected denied write to leave file missing, stat error: %v", err)
}
lastMessage := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
if !strings.Contains(lastMessage.Content, "Permission required for write_file") {
t.Fatalf("expected permission denial tool result, got %q", lastMessage.Content)
}
}
func TestRunGrantsPromptToolInUnsafeMode(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewWriteFileTool(root))
provider := providerCallingWriteFileThenAnswer("write done")
result, err := Run(context.Background(), "write notes", provider, Options{
Registry: registry,
PermissionMode: PermissionModeUnsafe,
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "write done" {
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
}
content, err := os.ReadFile(filepath.Join(root, "notes.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "hello" {
t.Fatalf("expected written content, got %q", content)
}
}
func TestRunStopsAfterMaxTurns(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
}},
}
result, err := Run(context.Background(), "loop", provider, Options{
Registry: registry,
MaxTurns: 1,
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "Agent reached maximum number of turns without a final answer." {
t.Fatalf("expected max-turns answer, got %q", result.FinalAnswer)
}
if result.Turns != 1 {
t.Fatalf("expected one turn, got %d", result.Turns)
}
}
func providerCallingWriteFileThenAnswer(answer string) *mockProvider {
return &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "write_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt","content":"hello"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: answer},
{Type: zeroruntime.StreamEventDone},
},
},
}
}
func assertMessage(t *testing.T, message zeroruntime.Message, role zeroruntime.MessageRole, contentContains string) {
t.Helper()
if message.Role != role {
t.Fatalf("expected role %s, got %s", role, message.Role)
}
if contentContains != "" && !strings.Contains(message.Content, contentContains) {
t.Fatalf("expected message content to contain %q, got %q", contentContains, message.Content)
}
}
func writeAgentTestFile(t *testing.T, path string, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
-42
View File
@@ -1,42 +0,0 @@
package agent
import (
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type Message = zeroruntime.Message
type Provider = zeroruntime.Provider
type ToolCall = zeroruntime.ToolCall
type Usage = zeroruntime.Usage
type PermissionMode string
const (
PermissionModeAuto PermissionMode = "auto"
PermissionModeAsk PermissionMode = "ask"
PermissionModeUnsafe PermissionMode = "unsafe"
)
type ToolResult struct {
ToolCallID string
Name string
Status tools.Status
Output string
}
type Options struct {
MaxTurns int
Registry *tools.Registry
PermissionMode PermissionMode
OnText func(string)
OnToolCall func(ToolCall)
OnToolResult func(ToolResult)
OnUsage func(Usage)
}
type Result struct {
FinalAnswer string
Turns int
Messages []Message
}
-226
View File
@@ -1,226 +0,0 @@
package cli
import (
"context"
"fmt"
"io"
"os"
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tui"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
var version = "dev"
type appDeps struct {
getwd func() (string, error)
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
newSessionStore func() *sessions.Store
runTUI func(context.Context, tui.Options) int
now func() time.Time
}
// Run executes the minimal Go CLI surface. It returns an exit code so tests can
// exercise command behavior without terminating the test process.
func Run(args []string, stdout io.Writer, stderr io.Writer) int {
return runWithDeps(args, stdout, stderr, defaultAppDeps())
}
func defaultAppDeps() appDeps {
return appDeps{
getwd: os.Getwd,
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
options, err := config.DefaultResolveOptions(workspaceRoot)
if err != nil {
return config.ResolvedConfig{}, err
}
options.Overrides = overrides
return config.Resolve(options)
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return providers.New(profile, providers.Options{UserAgent: userAgent()})
},
newSessionStore: func() *sessions.Store {
return sessions.NewStore(sessions.StoreOptions{})
},
runTUI: tui.Run,
now: time.Now,
}
}
func userAgent() string {
return "zero/" + version
}
func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
deps = fillAppDeps(deps)
if len(args) == 0 {
return runInteractiveTUI(stderr, deps)
}
switch args[0] {
case "-h", "--help", "help":
if err := writeHelp(stdout); err != nil {
return 1
}
return 0
case "-v", "--version", "version":
if _, err := fmt.Fprintf(stdout, "zero %s\n", version); err != nil {
return 1
}
return 0
case "-p", "--prompt":
if len(args) < 2 {
return writePromptRequired(stderr)
}
execArgs := append([]string{"--prompt", args[1]}, args[2:]...)
return runExec(execArgs, stdout, stderr, deps)
case "exec":
return runExec(args[1:], stdout, stderr, deps)
case "doctor":
return runDoctor(args[1:], stdout, stderr, deps)
case "search", "find":
return runSearch(args[1:], stdout, stderr, deps)
default:
if _, err := fmt.Fprintf(stderr, "unknown command %q\n", args[0]); err != nil {
return 1
}
if _, err := fmt.Fprintln(stderr, "Run zero --help for usage."); err != nil {
return 1
}
return 2
}
}
func fillAppDeps(deps appDeps) appDeps {
defaults := defaultAppDeps()
if deps.getwd == nil {
deps.getwd = defaults.getwd
}
if deps.resolveConfig == nil {
deps.resolveConfig = defaults.resolveConfig
}
if deps.newProvider == nil {
deps.newProvider = defaults.newProvider
}
if deps.newSessionStore == nil {
deps.newSessionStore = defaults.newSessionStore
}
if deps.runTUI == nil {
deps.runTUI = defaults.runTUI
}
if deps.now == nil {
deps.now = defaults.now
}
return deps
}
func runInteractiveTUI(stderr io.Writer, deps appDeps) int {
workspaceRoot, err := deps.getwd()
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), 1)
}
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
provider, err := buildProvider(resolved, deps)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
registry := newCoreRegistry(workspaceRoot)
permissionMode := agent.PermissionModeAuto
return deps.runTUI(context.Background(), tui.Options{
Cwd: workspaceRoot,
ProviderName: resolved.Provider.Name,
ModelName: resolved.Provider.Model,
Provider: provider,
Registry: registry,
AgentOptions: agent.Options{
MaxTurns: resolved.MaxTurns,
Registry: registry,
PermissionMode: permissionMode,
},
PermissionMode: permissionMode,
})
}
func buildProvider(resolved config.ResolvedConfig, deps appDeps) (zeroruntime.Provider, error) {
if resolved.Provider == (config.ProviderProfile{}) {
return nil, nil
}
return deps.newProvider(resolved.Provider)
}
func newCoreRegistry(workspaceRoot string) *tools.Registry {
registry := tools.NewRegistry()
for _, tool := range tools.CoreTools(workspaceRoot) {
registry.Register(tool)
}
return registry
}
func writeAppError(stderr io.Writer, message string, exitCode int) int {
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", message); err != nil {
return 1
}
return exitCode
}
func writeHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `ZERO terminal coding agent
Usage:
zero [command]
Commands:
exec Run a one-shot prompt through the Go agent runtime
doctor Run backend health checks for config and provider setup
search Search persisted local Zero session events
find Alias for search
help Show this help
version Print version
Flags:
-h, --help Show this help
-v, --version Print version
-p, --prompt Run a one-shot prompt
`)
return err
}
func writePromptRequired(stderr io.Writer) int {
if _, err := fmt.Fprintln(stderr, "[zero] Prompt required. Use `zero exec \"prompt\"` or `zero exec --file prompt.txt`."); err != nil {
return 1
}
return 2
}
func writeExecHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero exec [flags] [prompt]
Runs a one-shot prompt through the Go agent runtime.
Flags:
-f, --file <path> Read prompt text from a file
-m, --model <model> Select the model for provider setup
--max-turns <number> Override the maximum agent loop turns
-C, --cwd <path> Set the workspace directory
-o, --output-format text|json Select text or newline-delimited JSON output
--prompt <prompt> Provide prompt text as a flag
--skip-permissions-unsafe Allow prompt-gated tools without approval
`)
return err
}
-363
View File
@@ -1,363 +0,0 @@
package cli
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tui"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
var errWriteFailed = errors.New("write failed")
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) {
return 0, errWriteFailed
}
func TestRunPrintsVersion(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"--version"}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}
if got := stdout.String(); got != "zero dev\n" {
t.Fatalf("expected version output, got %q", got)
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
}
func TestRunPrintsHelp(t *testing.T) {
for _, args := range [][]string{
{"--help"},
{"-h"},
{"help"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
assertHelpOutput(t, args)
})
}
}
func TestRunNoArgsLaunchesTUIWithNilProviderWhenNoProviderConfigured(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
var launchedOptions tui.Options
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
return config.ResolvedConfig{MaxTurns: 12}, nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
t.Fatal("newProvider should not be called without a resolved provider")
return nil, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
launchedOptions = options
return 7
},
})
if exitCode != 7 {
t.Fatalf("expected TUI exit code 7, got %d", exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
if launchedOptions.Cwd != cwd {
t.Fatalf("Cwd = %q, want %q", launchedOptions.Cwd, cwd)
}
if launchedOptions.Provider != nil {
t.Fatalf("Provider = %#v, want nil", launchedOptions.Provider)
}
if launchedOptions.ProviderName != "" || launchedOptions.ModelName != "" {
t.Fatalf("provider metadata = %q/%q, want empty", launchedOptions.ProviderName, launchedOptions.ModelName)
}
assertCoreRegistry(t, launchedOptions.Registry)
assertAgentOptions(t, launchedOptions, 12, agent.PermissionModeAuto)
}
func TestRunNoArgsLaunchesTUIWithResolvedProviderMetadata(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
fake := &cliFakeProvider{}
var launchedOptions tui.Options
var providerProfile config.ProviderProfile
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
return config.ResolvedConfig{
ActiveProvider: "work",
Provider: config.ProviderProfile{
Name: "work",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
APIKey: "sk-test",
Model: "gpt-test",
},
MaxTurns: 5,
}, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
providerProfile = profile
return fake, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
launchedOptions = options
return 0
},
})
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}
if providerProfile.Name != "work" || providerProfile.Model != "gpt-test" {
t.Fatalf("providerProfile = %#v, want resolved provider", providerProfile)
}
if launchedOptions.Provider != fake {
t.Fatalf("Provider = %#v, want fake provider", launchedOptions.Provider)
}
if launchedOptions.ProviderName != "work" {
t.Fatalf("ProviderName = %q, want work", launchedOptions.ProviderName)
}
if launchedOptions.ModelName != "gpt-test" {
t.Fatalf("ModelName = %q, want gpt-test", launchedOptions.ModelName)
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
assertCoreRegistry(t, launchedOptions.Registry)
assertAgentOptions(t, launchedOptions, 5, agent.PermissionModeAuto)
}
func TestRunNoArgsReportsConfigErrorsWithoutLaunchingTUI(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
launchCalled := false
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return t.TempDir(), nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, errors.New("bad config")
},
runTUI: func(ctx context.Context, options tui.Options) int {
launchCalled = true
return 0
},
})
if exitCode != 1 {
t.Fatalf("expected exit code 1, got %d", exitCode)
}
if launchCalled {
t.Fatal("TUI launcher should not be called when config fails")
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
if got := stderr.String(); !strings.Contains(got, "bad config") {
t.Fatalf("expected config error on stderr, got %q", got)
}
}
func TestRunCommandsDoNotLaunchTUI(t *testing.T) {
for _, args := range [][]string{
{"--help"},
{"-h"},
{"help"},
{"--version"},
{"version"},
{"wat"},
{"exec"},
{"doctor"},
{"search"},
{"find"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
launchCalled := false
_ = runWithDeps(args, &stdout, &stderr, appDeps{
runTUI: func(ctx context.Context, options tui.Options) int {
launchCalled = true
return 0
},
})
if launchCalled {
t.Fatalf("TUI launcher should not be called for args %#v", args)
}
})
}
}
func assertHelpOutput(t *testing.T, args []string) {
t.Helper()
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run(args, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}
output := stdout.String()
for _, want := range []string{
"ZERO terminal coding agent",
"Usage:",
"zero [command]",
"exec",
"doctor",
"search",
"--version",
} {
if !strings.Contains(output, want) {
t.Fatalf("expected help output to contain %q, got %q", want, output)
}
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
}
func TestRunExecRequiresPrompt(t *testing.T) {
for _, args := range [][]string{
{"exec"},
{"exec", ""},
{"exec", " "},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run(args, &stdout, &stderr)
if exitCode != 2 {
t.Fatalf("expected exit code 2, got %d", exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
if !strings.Contains(stderr.String(), "Prompt required") {
t.Fatalf("expected prompt error, got %q", stderr.String())
}
})
}
}
func TestRunReturnsFailureWhenStdoutWriteFails(t *testing.T) {
exitCode := Run([]string{"--version"}, failingWriter{}, io.Discard)
if exitCode != 1 {
t.Fatalf("expected exit code 1, got %d", exitCode)
}
}
func TestRunReturnsFailureWhenStderrWriteFails(t *testing.T) {
exitCode := Run([]string{"wat"}, io.Discard, failingWriter{})
if exitCode != 1 {
t.Fatalf("expected exit code 1, got %d", exitCode)
}
}
func TestRunRejectsUnknownCommand(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"wat"}, &stdout, &stderr)
if exitCode != 2 {
t.Fatalf("expected exit code 2, got %d", exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
if got := stderr.String(); !strings.Contains(got, `unknown command "wat"`) {
t.Fatalf("expected unknown command error, got %q", got)
}
}
type cliFakeProvider struct{}
func (cliFakeProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
ch := make(chan zeroruntime.StreamEvent)
close(ch)
return ch, nil
}
func assertCoreRegistry(t *testing.T, registry *tools.Registry) {
t.Helper()
if registry == nil {
t.Fatal("Registry = nil, want core tool registry")
}
for _, name := range []string{
"read_file",
"list_directory",
"glob",
"grep",
"write_file",
"edit_file",
"apply_patch",
"update_plan",
"bash",
} {
if _, ok := registry.Get(name); !ok {
t.Fatalf("expected registry to include core tool %q", name)
}
}
}
func assertAgentOptions(t *testing.T, options tui.Options, maxTurns int, permissionMode agent.PermissionMode) {
t.Helper()
if options.AgentOptions.MaxTurns != maxTurns {
t.Fatalf("AgentOptions.MaxTurns = %d, want %d", options.AgentOptions.MaxTurns, maxTurns)
}
if options.AgentOptions.PermissionMode != permissionMode {
t.Fatalf("AgentOptions.PermissionMode = %q, want %q", options.AgentOptions.PermissionMode, permissionMode)
}
if options.PermissionMode != permissionMode {
t.Fatalf("PermissionMode = %q, want %q", options.PermissionMode, permissionMode)
}
}
-478
View File
@@ -1,478 +0,0 @@
package cli
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
)
const (
exitSuccess = 0
exitCrash = 1
exitUsage = 2
exitProvider = 3
)
type execOutputFormat string
const (
execOutputText execOutputFormat = "text"
execOutputJSON execOutputFormat = "json"
)
type execOptions struct {
promptParts []string
file string
model string
maxTurns int
cwd string
outputFormat execOutputFormat
skipPermissionsUnsafe bool
}
type execUsageError struct {
message string
}
func (err execUsageError) Error() string {
return err.message
}
func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseExecArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeExecHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
prompt, err := resolveExecPrompt(options, workspaceRoot)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
overrides := config.Overrides{}
if options.model != "" {
overrides.Provider.Model = options.model
}
if options.maxTurns > 0 {
overrides.MaxTurns = options.maxTurns
}
resolved, err := deps.resolveConfig(workspaceRoot, overrides)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
}
if resolved.Provider == (config.ProviderProfile{}) {
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", "No provider configured. Set OPENAI_MODEL/OPENAI_API_KEY or add .zero/config.json.")
}
provider, err := buildProvider(resolved, deps)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
}
permissionMode := agent.PermissionModeAuto
if options.skipPermissionsUnsafe {
permissionMode = agent.PermissionModeUnsafe
}
registry := newCoreRegistry(workspaceRoot)
writer := execEventWriter{
stdout: stdout,
stderr: stderr,
format: options.outputFormat,
streamedText: &strings.Builder{},
}
writer.runStart(workspaceRoot, resolved.Provider, permissionMode)
if writer.err != nil {
return exitCrash
}
if options.skipPermissionsUnsafe {
writer.warning("Unsafe permissions are active for this run because --skip-permissions-unsafe was passed.")
if writer.err != nil {
return exitCrash
}
}
result, err := agent.Run(context.Background(), prompt, provider, agent.Options{
MaxTurns: resolved.MaxTurns,
Registry: registry,
PermissionMode: permissionMode,
OnText: writer.text,
OnToolCall: writer.toolCall,
OnToolResult: writer.toolResult,
OnUsage: writer.usage,
})
if writer.err != nil {
return exitCrash
}
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
}
writer.final(result.FinalAnswer)
if writer.err != nil {
return exitCrash
}
return exitSuccess
}
func parseExecArgs(args []string) (execOptions, bool, error) {
options := execOptions{outputFormat: execOutputText}
if len(args) == 0 {
return options, false, execUsageError{"Prompt required. Use `zero exec \"prompt\"` or `zero exec --file prompt.txt`."}
}
for index := 0; index < len(args); index++ {
arg := args[index]
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--skip-permissions-unsafe":
options.skipPermissionsUnsafe = true
case arg == "-f" || arg == "--file":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.file = value
index = next
case strings.HasPrefix(arg, "--file="):
options.file = strings.TrimSpace(strings.TrimPrefix(arg, "--file="))
case arg == "-m" || arg == "--model":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.model = value
index = next
case strings.HasPrefix(arg, "--model="):
options.model = strings.TrimSpace(strings.TrimPrefix(arg, "--model="))
case arg == "--max-turns":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
maxTurns, err := parseExecMaxTurns(value)
if err != nil {
return options, false, err
}
options.maxTurns = maxTurns
index = next
case strings.HasPrefix(arg, "--max-turns="):
maxTurns, err := parseExecMaxTurns(strings.TrimSpace(strings.TrimPrefix(arg, "--max-turns=")))
if err != nil {
return options, false, err
}
options.maxTurns = maxTurns
case arg == "-C" || arg == "--cwd":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.cwd = value
index = next
case strings.HasPrefix(arg, "--cwd="):
options.cwd = strings.TrimSpace(strings.TrimPrefix(arg, "--cwd="))
case arg == "-o" || arg == "--output-format":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
format, err := parseExecOutputFormat(value)
if err != nil {
return options, false, err
}
options.outputFormat = format
index = next
case strings.HasPrefix(arg, "--output-format="):
format, err := parseExecOutputFormat(strings.TrimSpace(strings.TrimPrefix(arg, "--output-format=")))
if err != nil {
return options, false, err
}
options.outputFormat = format
case arg == "--prompt":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.promptParts = append(options.promptParts, value)
index = next
case strings.HasPrefix(arg, "--prompt="):
options.promptParts = append(options.promptParts, strings.TrimSpace(strings.TrimPrefix(arg, "--prompt=")))
case arg == "--":
options.promptParts = append(options.promptParts, args[index+1:]...)
index = len(args)
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown exec flag %q", arg)}
default:
options.promptParts = append(options.promptParts, arg)
}
}
if options.file == "" && strings.TrimSpace(strings.Join(options.promptParts, " ")) == "" {
return options, false, execUsageError{"Prompt required. Use `zero exec \"prompt\"` or `zero exec --file prompt.txt`."}
}
return options, false, nil
}
func parseExecMaxTurns(value string) (int, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return 0, execUsageError{"--max-turns requires a value"}
}
maxTurns, err := strconv.Atoi(trimmed)
if err != nil || maxTurns <= 0 {
return 0, execUsageError{fmt.Sprintf("invalid --max-turns %q. Expected a positive integer.", value)}
}
return maxTurns, nil
}
func nextFlagValue(args []string, index int, flag string) (string, int, error) {
if index+1 >= len(args) || strings.TrimSpace(args[index+1]) == "" {
return "", index, execUsageError{fmt.Sprintf("%s requires a value", flag)}
}
return strings.TrimSpace(args[index+1]), index + 1, nil
}
func parseExecOutputFormat(value string) (execOutputFormat, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", string(execOutputText):
return execOutputText, nil
case string(execOutputJSON):
return execOutputJSON, nil
default:
return "", execUsageError{fmt.Sprintf("invalid output format %q. Expected text or json.", value)}
}
}
func resolveWorkspaceRoot(cwd string, deps appDeps) (string, error) {
current, err := deps.getwd()
if err != nil {
return "", fmt.Errorf("failed to resolve workspace: %w", err)
}
workspaceRoot := strings.TrimSpace(cwd)
if workspaceRoot == "" {
workspaceRoot = current
} else if !filepath.IsAbs(workspaceRoot) {
workspaceRoot = filepath.Join(current, workspaceRoot)
}
workspaceRoot = filepath.Clean(workspaceRoot)
info, err := os.Stat(workspaceRoot)
if err != nil || !info.IsDir() {
return "", execUsageError{fmt.Sprintf("cwd must be an existing directory: %s", workspaceRoot)}
}
return workspaceRoot, nil
}
func resolveExecPrompt(options execOptions, workspaceRoot string) (string, error) {
parts := []string{}
inlinePrompt := strings.TrimSpace(strings.Join(options.promptParts, " "))
if inlinePrompt != "" {
parts = append(parts, inlinePrompt)
}
if options.file != "" {
promptPath := options.file
if !filepath.IsAbs(promptPath) {
promptPath = filepath.Join(workspaceRoot, promptPath)
}
data, err := os.ReadFile(promptPath)
if err != nil {
return "", execUsageError{fmt.Sprintf("prompt file not found: %s", promptPath)}
}
filePrompt := strings.TrimSpace(string(data))
if filePrompt == "" {
return "", execUsageError{fmt.Sprintf("prompt file is empty: %s", promptPath)}
}
parts = append(parts, filePrompt)
}
prompt := strings.TrimSpace(strings.Join(parts, "\n\n"))
if prompt == "" {
return "", execUsageError{"Prompt required. Use `zero exec \"prompt\"` or `zero exec --file prompt.txt`."}
}
return prompt, nil
}
func writeExecUsageError(stderr io.Writer, message string) int {
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", message); err != nil {
return exitCrash
}
return exitUsage
}
func writeExecProviderError(stdout io.Writer, stderr io.Writer, format execOutputFormat, code string, message string) int {
if format == execOutputJSON {
if err := writeJSONLine(stdout, map[string]any{
"type": "error",
"code": code,
"message": message,
}); err != nil {
return exitCrash
}
if err := writeJSONLine(stdout, map[string]any{
"type": "done",
"exit_code": exitProvider,
}); err != nil {
return exitCrash
}
return exitProvider
}
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", message); err != nil {
return exitCrash
}
return exitProvider
}
type execEventWriter struct {
stdout io.Writer
stderr io.Writer
format execOutputFormat
streamedText *strings.Builder
err error
}
func (writer *execEventWriter) runStart(cwd string, provider config.ProviderProfile, permissionMode agent.PermissionMode) {
if writer.format != execOutputJSON {
return
}
writer.writeJSON(map[string]any{
"type": "run_start",
"cwd": cwd,
"provider": provider.Name,
"model": provider.Model,
"permission_mode": string(permissionMode),
})
}
func (writer *execEventWriter) warning(message string) {
if writer.format == execOutputJSON {
writer.writeJSON(map[string]any{"type": "warning", "message": message})
return
}
writer.writeStderr("[zero] WARNING: " + message + "\n")
}
func (writer *execEventWriter) text(delta string) {
writer.streamedText.WriteString(delta)
if writer.format == execOutputJSON {
writer.writeJSON(map[string]any{"type": "text", "delta": delta})
return
}
writer.writeStdout(delta)
}
func (writer *execEventWriter) toolCall(call agent.ToolCall) {
if writer.format == execOutputJSON {
writer.writeJSON(map[string]any{
"type": "tool_call",
"id": call.ID,
"name": call.Name,
"arguments": call.Arguments,
})
return
}
writer.writeStderr("[tool] " + call.Name + "\n")
}
func (writer *execEventWriter) toolResult(result agent.ToolResult) {
if writer.format == execOutputJSON {
writer.writeJSON(map[string]any{
"type": "tool_result",
"tool_call_id": result.ToolCallID,
"name": result.Name,
"status": string(result.Status),
"output": result.Output,
})
return
}
writer.writeStderr("[result] " + truncateForStatus(result.Output) + "\n")
}
func (writer *execEventWriter) usage(usage agent.Usage) {
if writer.format != execOutputJSON {
return
}
writer.writeJSON(map[string]any{
"type": "usage",
"prompt_tokens": usage.PromptTokens,
"completion_tokens": usage.CompletionTokens,
"total_tokens": usage.TotalTokens(),
})
}
func (writer *execEventWriter) final(answer string) {
if writer.format == execOutputJSON {
writer.writeJSON(map[string]any{"type": "final", "text": answer})
writer.writeJSON(map[string]any{"type": "done", "exit_code": exitSuccess})
return
}
if writer.streamedText.Len() == 0 && answer != "" {
writer.writeStdout(answer)
writer.streamedText.WriteString(answer)
}
if writer.streamedText.Len() > 0 && !strings.HasSuffix(writer.streamedText.String(), "\n") {
writer.writeStdout("\n")
}
}
func (writer *execEventWriter) writeJSON(payload map[string]any) {
if writer.err != nil {
return
}
writer.err = writeJSONLine(writer.stdout, payload)
}
func (writer *execEventWriter) writeStdout(value string) {
if writer.err != nil {
return
}
_, writer.err = io.WriteString(writer.stdout, value)
}
func (writer *execEventWriter) writeStderr(value string) {
if writer.err != nil {
return
}
_, writer.err = io.WriteString(writer.stderr, value)
}
func writeJSONLine(w io.Writer, payload map[string]any) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
if _, err := w.Write(append(data, '\n')); err != nil {
return err
}
return nil
}
func truncateForStatus(value string) string {
compact := strings.Join(strings.Fields(value), " ")
if len(compact) > 200 {
return compact[:200] + "..."
}
return compact
}
-565
View File
@@ -1,565 +0,0 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestRunExecHelpDocumentsM1Flags(t *testing.T) {
for _, args := range [][]string{
{"exec", "--help"},
{"exec", "--help", "--model", "m1"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run(args, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}
for _, want := range []string{
"-f, --file",
"-m, --model",
"--max-turns",
"-C, --cwd",
"-o, --output-format text|json",
"--prompt",
"--skip-permissions-unsafe",
} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("expected exec help to contain %q, got %q", want, stdout.String())
}
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
})
}
}
func TestRunExecRejectsInvalidMaxTurnsBeforeRuntime(t *testing.T) {
for _, tc := range []struct {
value string
want string
}{
{value: "nope", want: "invalid --max-turns"},
{value: "-1", want: "invalid --max-turns"},
{value: "0", want: "invalid --max-turns"},
} {
t.Run(tc.value, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"exec", "--max-turns", tc.value, "hello"}, &stdout, &stderr)
if exitCode != exitUsage {
t.Fatalf("expected exit code %d, got %d", exitUsage, exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout before runtime, got %q", stdout.String())
}
if got := stderr.String(); !strings.Contains(got, tc.want) {
t.Fatalf("expected max-turns validation error containing %q, got %q", tc.want, got)
}
})
}
t.Run("equals-empty", func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"exec", "--max-turns=", "hello"}, &stdout, &stderr)
if exitCode != exitUsage {
t.Fatalf("expected exit code %d, got %d", exitUsage, exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout before runtime, got %q", stdout.String())
}
if got := stderr.String(); !strings.Contains(got, "--max-turns requires a value") {
t.Fatalf("expected empty max-turns validation error, got %q", got)
}
})
}
func TestRunExecMaxTurnsReachesConfigOverrides(t *testing.T) {
cwd := t.TempDir()
var gotMaxTurns int
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--max-turns", "7", "hello"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
gotMaxTurns = overrides.MaxTurns
return config.ResolvedConfig{}, errors.New("stop before provider")
},
})
if exitCode != exitProvider {
t.Fatalf("expected provider exit %d, got %d", exitProvider, exitCode)
}
if gotMaxTurns != 7 {
t.Fatalf("overrides.MaxTurns = %d, want 7", gotMaxTurns)
}
}
func TestRunExecJSONRunStartWriteFailureSkipsAgent(t *testing.T) {
cwd := t.TempDir()
called := false
exitCode := runWithDeps([]string{"exec", "-o", "json", "hello"}, failingWriter{}, io.Discard, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return recordingExecProvider{called: &called}, nil
},
})
if exitCode != exitCrash {
t.Fatalf("expected exit code %d, got %d", exitCrash, exitCode)
}
if called {
t.Fatal("expected agent provider not to run after run_start write failure")
}
}
func TestRunExecUnsafeWarningWriteFailureSkipsAgent(t *testing.T) {
cwd := t.TempDir()
called := false
exitCode := runWithDeps([]string{"exec", "--skip-permissions-unsafe", "hello"}, io.Discard, failingWriter{}, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return recordingExecProvider{called: &called}, nil
},
})
if exitCode != exitCrash {
t.Fatalf("expected exit code %d, got %d", exitCrash, exitCode)
}
if called {
t.Fatal("expected agent provider not to run after warning write failure")
}
}
func TestRunExecJSONProviderErrorWriteFailureReturnsCrash(t *testing.T) {
cwd := t.TempDir()
exitCode := runWithDeps([]string{"exec", "-o", "json", "hello"}, failingWriter{}, io.Discard, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, errors.New("provider config failed")
},
})
if exitCode != exitCrash {
t.Fatalf("expected exit code %d, got %d", exitCrash, exitCode)
}
}
func execResolvedConfig() config.ResolvedConfig {
return config.ResolvedConfig{
ActiveProvider: "echo",
Provider: config.ProviderProfile{
Name: "echo",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "http://127.0.0.1/v1",
Model: "echo-model",
},
}
}
type recordingExecProvider struct {
called *bool
}
func (provider recordingExecProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
*provider.called = true
return nil, errors.New("provider should not run")
}
func TestRunPromptFlagRoutesToExecRunner(t *testing.T) {
execExitCode, execStdout, execStderr := runExecWithEcho(t, []string{"exec", "hello zero"})
for _, args := range [][]string{
{"-p", "hello zero"},
{"--prompt", "hello zero"},
} {
t.Run(args[0], func(t *testing.T) {
exitCode, stdout, stderr := runExecWithEcho(t, args)
if exitCode != execExitCode {
t.Fatalf("expected exit code %d, got %d", execExitCode, exitCode)
}
if stdout != execStdout {
t.Fatalf("expected stdout %q, got %q", execStdout, stdout)
}
if stderr != execStderr {
t.Fatalf("expected stderr %q, got %q", execStderr, stderr)
}
})
}
}
func TestRunExecAssemblesInlineAndFilePromptRelativeToCwd(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "prompt.txt"), []byte("file prompt\n"), 0o600); err != nil {
t.Fatal(err)
}
exitCode, stdout, stderr := runExecWithEcho(t, []string{"exec", "--cwd", root, "--file", "prompt.txt", "inline prompt"})
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
}
if !strings.Contains(stdout, "inline prompt\n\nfile prompt") {
t.Fatalf("expected inline and file prompt joined by blank line, got %q", stdout)
}
if stderr != "" {
t.Fatalf("expected empty stderr, got %q", stderr)
}
}
func TestRunExecAcceptsFileOnlyPrompt(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "prompt.txt"), []byte("file only prompt\n"), 0o600); err != nil {
t.Fatal(err)
}
exitCode, stdout, stderr := runExecWithEcho(t, []string{"exec", "-C", root, "-f", "prompt.txt"})
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
}
if !strings.Contains(stdout, "file only prompt") {
t.Fatalf("expected file prompt output, got %q", stdout)
}
if stderr != "" {
t.Fatalf("expected empty stderr, got %q", stderr)
}
}
func TestRunExecRejectsInvalidCwdBeforeRuntime(t *testing.T) {
root := t.TempDir()
filePath := filepath.Join(root, "not-a-directory.txt")
if err := os.WriteFile(filePath, []byte("nope"), 0o600); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
cwd string
}{
{name: "missing", cwd: filepath.Join(root, "missing")},
{name: "file", cwd: filePath},
} {
t.Run(tc.name, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"exec", "--cwd", tc.cwd, "hello"}, &stdout, &stderr)
if exitCode != 2 {
t.Fatalf("expected exit code 2, got %d", exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout before runtime, got %q", stdout.String())
}
if got := stderr.String(); !strings.Contains(got, "cwd must be an existing directory") {
t.Fatalf("expected cwd validation error, got %q", got)
}
if strings.Contains(stdout.String()+stderr.String(), "Go agent runtime ready") {
t.Fatalf("expected validation before runtime, got stdout %q stderr %q", stdout.String(), stderr.String())
}
})
}
}
func TestRunExecRejectsInvalidOutputFormatBeforeRuntime(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"exec", "-o", "yaml", "hello"}, &stdout, &stderr)
if exitCode != 2 {
t.Fatalf("expected exit code 2, got %d", exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout before runtime, got %q", stdout.String())
}
if got := stderr.String(); !strings.Contains(got, `invalid output format "yaml"`) {
t.Fatalf("expected output format validation error, got %q", got)
}
if strings.Contains(stdout.String()+stderr.String(), "Go agent runtime ready") {
t.Fatalf("expected validation before runtime, got stdout %q stderr %q", stdout.String(), stderr.String())
}
}
func TestRunExecUnsafeTextModeWarns(t *testing.T) {
exitCode, stdout, stderr := runExecWithEcho(t, []string{"exec", "--skip-permissions-unsafe", "hello"})
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
}
if !strings.Contains(stdout, "hello") {
t.Fatalf("expected prompt in stdout, got %q", stdout)
}
if got := stderr; !strings.Contains(got, "WARNING") || !strings.Contains(got, "--skip-permissions-unsafe") {
t.Fatalf("expected unsafe warning, got %q", got)
}
}
func TestRunExecJSONOutputsNDJSONEvents(t *testing.T) {
root := t.TempDir()
exitCode, stdout, stderr := runExecWithEcho(t, []string{"exec", "--cwd", root, "-m", "m1-test", "-o", "json", "hello json"})
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
}
if stderr != "" {
t.Fatalf("expected empty stderr, got %q", stderr)
}
events := decodeJSONLines(t, stdout)
eventTypes := jsonEventTypes(events)
for _, want := range []string{"run_start", "text", "final", "done"} {
if !slices.Contains(eventTypes, want) {
t.Fatalf("expected JSON event %q in %v; output %q", want, eventTypes, stdout)
}
}
if got := events[0]["type"]; got != "run_start" {
t.Fatalf("expected first event run_start, got %v", got)
}
if got := events[0]["model"]; got != "m1-test" {
t.Fatalf("expected run_start model m1-test, got %v", got)
}
if got := events[0]["cwd"]; got != root {
t.Fatalf("expected run_start cwd %q, got %v", root, got)
}
if got := events[0]["permission_mode"]; got != "auto" {
t.Fatalf("expected default permission_mode auto, got %v", got)
}
}
func TestRunExecJSONUnsafeOutputsWarningEvent(t *testing.T) {
exitCode, stdout, stderr := runExecWithEcho(t, []string{"exec", "--skip-permissions-unsafe", "-o", "json", "hello"})
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
}
if stderr != "" {
t.Fatalf("expected empty stderr, got %q", stderr)
}
events := decodeJSONLines(t, stdout)
eventTypes := jsonEventTypes(events)
if !slices.Contains(eventTypes, "warning") {
t.Fatalf("expected JSON warning event in %v; output %q", eventTypes, stdout)
}
if got := events[0]["permission_mode"]; got != "unsafe" {
t.Fatalf("expected run_start permission_mode unsafe, got %v", got)
}
}
func TestRunExecUsesProjectConfigAndOpenAICompatibleProvider(t *testing.T) {
clearProviderEnv(t)
root := t.TempDir()
configDir := filepath.Join(root, ".zero")
if err := os.MkdirAll(configDir, 0o700); err != nil {
t.Fatal(err)
}
var gotAuth string
var gotMethod string
var gotPath string
var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode provider request: %v", err)
}
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"provider ok\"}}]}\n\n")
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}))
defer server.Close()
writeConfig := `{
"activeProvider": "local",
"providers": [{
"name": "local",
"provider_kind": "openai-compatible",
"base_url": "` + server.URL + `",
"api_key": "sk-local",
"model": "local-model"
}]
}`
if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(writeConfig), 0o600); err != nil {
t.Fatal(err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"exec", "--cwd", root, "hello provider"}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr.String())
}
if strings.TrimSpace(stdout.String()) != "provider ok" {
t.Fatalf("stdout = %q, want provider response", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
if gotAuth != "Bearer sk-local" {
t.Fatalf("Authorization = %q, want project config token", gotAuth)
}
if gotMethod != http.MethodPost {
t.Fatalf("method = %q, want %q", gotMethod, http.MethodPost)
}
if !strings.HasSuffix(gotPath, "/chat/completions") {
t.Fatalf("path = %q, want suffix /chat/completions", gotPath)
}
if gotBody["model"] != "local-model" {
t.Fatalf("provider model = %v, want local-model", gotBody["model"])
}
messages, ok := gotBody["messages"].([]any)
if !ok || len(messages) == 0 {
t.Fatalf("messages = %#v, want non-empty []any", gotBody["messages"])
}
lastMessage, ok := messages[len(messages)-1].(map[string]any)
if !ok {
t.Fatalf("last message = %#v, want map[string]any", messages[len(messages)-1])
}
if lastMessage["content"] != "hello provider" {
t.Fatalf("last provider message = %#v, want prompt", lastMessage)
}
}
func runExecWithEcho(t *testing.T, args []string) (int, string, string) {
t.Helper()
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "echo-model"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
return config.ResolvedConfig{
ActiveProvider: "echo",
Provider: config.ProviderProfile{
Name: "echo",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "http://127.0.0.1/v1",
Model: model,
},
MaxTurns: 3,
}, nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return echoExecProvider{}, nil
},
})
return exitCode, stdout.String(), stderr.String()
}
type echoExecProvider struct{}
func (echoExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
prompt := ""
for index := len(request.Messages) - 1; index >= 0; index-- {
if request.Messages[index].Role == zeroruntime.MessageRoleUser {
prompt = request.Messages[index].Content
break
}
}
ch := make(chan zeroruntime.StreamEvent, 2)
select {
case <-ctx.Done():
close(ch)
return ch, ctx.Err()
case ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: prompt}:
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
func clearProviderEnv(t *testing.T) {
t.Helper()
for _, key := range []string{
"ZERO_PROVIDER_COMMAND",
"ZERO_PROVIDER",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"OPENAI_MODEL",
} {
t.Setenv(key, "")
}
}
func decodeJSONLines(t *testing.T, output string) []map[string]any {
t.Helper()
lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) == 0 || lines[0] == "" {
t.Fatalf("expected JSON lines, got %q", output)
}
events := make([]map[string]any, 0, len(lines))
for _, line := range lines {
var event map[string]any
if err := json.Unmarshal([]byte(line), &event); err != nil {
t.Fatalf("expected JSON object line, got %q: %v", line, err)
}
events = append(events, event)
}
return events
}
func jsonEventTypes(events []map[string]any) []string {
types := make([]string, 0, len(events))
for _, event := range events {
eventType, _ := event["type"].(string)
types = append(types, eventType)
}
return types
}
-259
View File
@@ -1,259 +0,0 @@
package cli
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/doctor"
zsearch "github.com/Gitlawb/zero/internal/search"
"github.com/Gitlawb/zero/internal/sessions"
)
type doctorOptions struct {
json bool
connectivity bool
}
func runDoctor(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseDoctorArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeDoctorHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
workspaceRoot, err := resolveWorkspaceRoot("", deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
return writeAppError(stderr, err.Error(), exitProvider)
}
report := doctor.Run(doctor.Options{
Now: deps.now,
Runtime: "go",
Provider: resolved.Provider,
Connectivity: options.connectivity,
})
if options.json {
if err := writePrettyJSON(stdout, report); err != nil {
return exitCrash
}
if !report.OK {
return exitProvider
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, doctor.Format(report)); err != nil {
return exitCrash
}
if !report.OK {
return exitProvider
}
return exitSuccess
}
type searchOptions struct {
query string
json bool
limit int
contextChars int
sessionID string
eventType sessions.EventType
reindex bool
}
func runSearch(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseSearchArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeSearchHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
if strings.TrimSpace(options.query) == "" {
return writeExecUsageError(stderr, "search query required. Use `zero search <query>`.")
}
result, err := zsearch.Sessions(options.query, zsearch.Options{
Store: deps.newSessionStore(),
Limit: options.limit,
ContextChars: options.contextChars,
SessionID: options.sessionID,
Type: options.eventType,
Reindex: options.reindex,
Now: deps.now,
})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if options.json {
if err := writePrettyJSON(stdout, zsearch.RedactResult(result)); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, zsearch.FormatResult(result)); err != nil {
return exitCrash
}
return exitSuccess
}
func parseDoctorArgs(args []string) (doctorOptions, bool, error) {
options := doctorOptions{}
for _, arg := range args {
switch arg {
case "-h", "--help", "help":
return options, true, nil
case "--json":
options.json = true
case "--connectivity":
options.connectivity = true
default:
return options, false, execUsageError{fmt.Sprintf("unknown doctor flag %q", arg)}
}
}
return options, false, nil
}
func parseSearchArgs(args []string) (searchOptions, bool, error) {
options := searchOptions{}
queryParts := []string{}
for index := 0; index < len(args); index++ {
arg := args[index]
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--json":
options.json = true
case arg == "--reindex":
options.reindex = true
case arg == "--limit":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
limit, err := parsePositiveOrZeroInt(value, "--limit")
if err != nil {
return options, false, err
}
options.limit = limit
index = next
case strings.HasPrefix(arg, "--limit="):
limit, err := parsePositiveOrZeroInt(strings.TrimSpace(strings.TrimPrefix(arg, "--limit=")), "--limit")
if err != nil {
return options, false, err
}
options.limit = limit
case arg == "--context-chars":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
contextChars, err := parsePositiveOrZeroInt(value, "--context-chars")
if err != nil {
return options, false, err
}
options.contextChars = contextChars
index = next
case strings.HasPrefix(arg, "--context-chars="):
contextChars, err := parsePositiveOrZeroInt(strings.TrimSpace(strings.TrimPrefix(arg, "--context-chars=")), "--context-chars")
if err != nil {
return options, false, err
}
options.contextChars = contextChars
case arg == "--session-id":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.sessionID = value
index = next
case strings.HasPrefix(arg, "--session-id="):
options.sessionID = strings.TrimSpace(strings.TrimPrefix(arg, "--session-id="))
case arg == "--type":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.eventType = sessions.EventType(value)
index = next
case strings.HasPrefix(arg, "--type="):
options.eventType = sessions.EventType(strings.TrimSpace(strings.TrimPrefix(arg, "--type=")))
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown search flag %q", arg)}
default:
queryParts = append(queryParts, arg)
}
}
options.query = strings.TrimSpace(strings.Join(queryParts, " "))
return options, false, nil
}
func parsePositiveOrZeroInt(value string, label string) (int, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return 0, execUsageError{fmt.Sprintf("%s requires a value", label)}
}
parsed, err := strconv.Atoi(trimmed)
if err != nil || parsed < 0 {
return 0, execUsageError{fmt.Sprintf("invalid %s %q. Expected a non-negative integer.", label, value)}
}
return parsed, nil
}
func writePrettyJSON(w io.Writer, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
if _, err := w.Write(append(data, '\n')); err != nil {
return err
}
return nil
}
func writeDoctorHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero doctor [flags]
Runs Go backend health checks for config and provider setup.
Flags:
--json Print JSON report
--connectivity Include provider endpoint connectivity probe when available
-h, --help Show this help
`)
return err
}
func writeSearchHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero search [flags] <query>
Searches persisted local Zero session events.
Flags:
--json Print JSON results
--limit <number> Maximum hits to return
--context-chars <n> Characters of context around each match
--session-id <id> Search one session
--type <event-type> Filter by session event type
--reindex Rebuild cached search indexes
-h, --help Show this help
`)
return err
}
-220
View File
@@ -1,220 +0,0 @@
package cli
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/sessions"
)
func TestRunDoctorFormatsRedactedProviderDiagnostics(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
exitCode := runWithDeps([]string{"doctor"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
return config.ResolvedConfig{
Provider: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
APIKey: "sk-proj-secret1234567890",
Model: "gpt-4.1",
},
}, nil
},
now: fixedCLITime("2026-06-04T16:00:00Z"),
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
output := stdout.String()
for _, want := range []string{"Zero doctor report", "Overall: pass", "[pass] provider.config", "[warn] provider.connectivity"} {
if !strings.Contains(output, want) {
t.Fatalf("expected doctor output to contain %q, got %q", want, output)
}
}
if strings.Contains(output, "sk-proj-secret") {
t.Fatalf("doctor output leaked secret: %q", output)
}
}
func TestRunDoctorJSONReturnsFailureForMissingProvider(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"doctor", "--json"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return t.TempDir(), nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, nil
},
now: fixedCLITime("2026-06-04T16:30:00Z"),
})
if exitCode != exitProvider {
t.Fatalf("expected provider exit %d, got %d", exitProvider, exitCode)
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
var report struct {
OK bool `json:"ok"`
Checks []struct {
ID string `json:"id"`
Status string `json:"status"`
} `json:"checks"`
}
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
t.Fatalf("doctor JSON did not decode: %v\n%s", err, stdout.String())
}
if report.OK {
t.Fatalf("expected report ok=false, got %#v", report)
}
}
func TestRunSearchFindsSessionEventsFromInjectedStore(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedCLITime("2026-06-04T17:00:00Z")})
session, err := store.Create(sessions.CreateInput{SessionID: "cli_search", Title: "CLI Search", Cwd: "/repo", ModelID: "gpt-4.1", Provider: "openai"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]string{"content": "needle with token=sk-secret1234567890"}}); err != nil {
t.Fatalf("AppendEvent returned error: %v", err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"search", "needle", "--limit", "3"}, &stdout, &stderr, appDeps{
newSessionStore: func() *sessions.Store {
return store
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
output := stdout.String()
if !strings.Contains(output, "Found 1 local session event") || !strings.Contains(output, "cli_search") {
t.Fatalf("unexpected search output: %q", output)
}
if strings.Contains(output, "sk-secret") {
t.Fatalf("search output leaked secret: %q", output)
}
}
func TestRunSearchJSONAndValidation(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedCLITime("2026-06-04T17:30:00Z")})
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"search", "--json", "missing"}, &stdout, &stderr, appDeps{
newSessionStore: func() *sessions.Store {
return store
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
var result struct {
Query string `json:"query"`
TotalHits int `json:"totalHits"`
}
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
t.Fatalf("search JSON did not decode: %v\n%s", err, stdout.String())
}
if result.Query != "missing" || result.TotalHits != 0 {
t.Fatalf("unexpected search result: %#v", result)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"search"}, &stdout, &stderr, appDeps{})
if exitCode != exitUsage {
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
}
if !strings.Contains(stderr.String(), "search query required") {
t.Fatalf("expected search validation error, got %q", stderr.String())
}
}
func TestRunSearchJSONRedactsQueryAndSessionMetadata(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedCLITime("2026-06-04T18:00:00Z")})
querySecret := "sk-proj-querysecret1234567890"
metadataSecret := "sk-proj-metadatasecret1234567890"
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"search", "--json", querySecret}, &stdout, &stderr, appDeps{
newSessionStore: func() *sessions.Store {
return store
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if strings.Contains(stdout.String(), querySecret) {
t.Fatalf("search JSON leaked raw query secret: %q", stdout.String())
}
if !strings.Contains(stdout.String(), "[REDACTED]") {
t.Fatalf("expected redacted query marker in JSON output: %q", stdout.String())
}
session, err := store.Create(sessions.CreateInput{
SessionID: "json_metadata_secret",
Title: "Title " + metadataSecret,
Cwd: "/repo/" + metadataSecret,
ModelID: "model-" + metadataSecret,
Provider: "provider-token=" + metadataSecret,
})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]string{"content": "metadata needle"}}); err != nil {
t.Fatalf("AppendEvent returned error: %v", err)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"search", "--json", "metadata", "needle"}, &stdout, &stderr, appDeps{
newSessionStore: func() *sessions.Store {
return store
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if strings.Contains(stdout.String(), metadataSecret) {
t.Fatalf("search JSON leaked session metadata secret: %q", stdout.String())
}
if !strings.Contains(stdout.String(), "[REDACTED]") {
t.Fatalf("expected redacted metadata marker in JSON output: %q", stdout.String())
}
}
func fixedCLITime(value string) func() time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return func() time.Time { return parsed }
}
-117
View File
@@ -1,117 +0,0 @@
package config
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os/exec"
"runtime"
"strings"
"time"
)
const providerCommandTimeout = 5 * time.Second
func LoadProviderCommand(command string) (FileConfig, error) {
stdout, stderr, err := runProviderCommand(command, providerCommandTimeout)
if err != nil {
if errors.Is(err, errProviderCommandTimeout) {
return FileConfig{}, fmt.Errorf("provider command timed out after 5s")
}
return FileConfig{}, fmt.Errorf("provider command failed: %w%s", err, commandOutput(stderr))
}
cfg, err := parseProviderCommandJSON(stdout)
if err != nil {
return FileConfig{}, err
}
if len(cfg.Providers) == 0 {
return FileConfig{}, fmt.Errorf("provider command returned no providers")
}
providers, _, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider)
if err != nil {
return FileConfig{}, err
}
cfg.Providers = providers
return cfg, nil
}
var errProviderCommandTimeout = errors.New("provider command timeout")
func runProviderCommand(command string, timeout time.Duration) ([]byte, []byte, error) {
cmd := shellCommand(command)
configureCommandProcess(cmd)
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return nil, nil, err
}
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case err := <-done:
return stdout.Bytes(), stderr.Bytes(), err
case <-timer.C:
terminateCommandProcess(cmd)
<-done
return stdout.Bytes(), stderr.Bytes(), errProviderCommandTimeout
}
}
func shellCommand(command string) *exec.Cmd {
if runtime.GOOS == "windows" {
if strings.HasPrefix(strings.TrimSpace(command), `"`) {
command = "call " + command
}
return exec.Command("cmd", "/C", command)
}
return exec.Command("sh", "-c", command)
}
func commandOutput(stderr []byte) string {
output := strings.TrimSpace(string(stderr))
if output == "" {
return ""
}
return ": " + redactSecrets(output)
}
func parseProviderCommandJSON(data []byte) (FileConfig, error) {
data = []byte(strings.TrimSpace(string(data)))
if len(data) == 0 {
return FileConfig{}, fmt.Errorf("provider command returned empty JSON")
}
var cfg FileConfig
if err := json.Unmarshal(data, &cfg); err == nil && (len(cfg.Providers) > 0 || cfg.ActiveProvider != "" || cfg.MaxTurns > 0) {
if cfg.ActiveProvider == "" && len(cfg.Providers) == 1 {
cfg.ActiveProvider = cfg.Providers[0].Name
}
return cfg, nil
}
var profile ProviderProfile
if err := json.Unmarshal(data, &profile); err != nil {
return FileConfig{}, fmt.Errorf("invalid provider command JSON: %w", err)
}
if profile.Name == "" {
profile.Name = string(ProviderKindOpenAI)
}
return FileConfig{
ActiveProvider: profile.Name,
Providers: []ProviderProfile{profile},
}, nil
}
-157
View File
@@ -1,157 +0,0 @@
package config
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
func TestLoadProviderCommandSuccess(t *testing.T) {
command := writeCommand(t, commandScript{
Stdout: `{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}`,
})
cfg, err := LoadProviderCommand(command)
if err != nil {
t.Fatalf("LoadProviderCommand() error = %v", err)
}
if len(cfg.Providers) != 1 {
t.Fatalf("providers length = %d, want 1", len(cfg.Providers))
}
provider := cfg.Providers[0]
if provider.Name != "cmd" || provider.APIKey != "sk-command" || provider.Model != "gpt-command" {
t.Fatalf("provider = %#v, want command provider", provider)
}
}
func TestLoadProviderCommandFailureIncludesExitAndRedactsOutput(t *testing.T) {
command := writeCommand(t, commandScript{
Stderr: "failed with sk-command-secret",
ExitCode: 7,
})
_, err := LoadProviderCommand(command)
if err == nil {
t.Fatal("LoadProviderCommand() error = nil, want command failure")
}
if !strings.Contains(err.Error(), "provider command failed") || !strings.Contains(err.Error(), "exit status") {
t.Fatalf("error = %q, want command failure with exit status", err.Error())
}
if strings.Contains(err.Error(), "sk-command-secret") {
t.Fatalf("error leaked command secret: %q", err.Error())
}
}
func TestLoadProviderCommandTimeout(t *testing.T) {
command := writeCommand(t, commandScript{SleepSeconds: 10})
start := time.Now()
_, err := LoadProviderCommand(command)
elapsed := time.Since(start)
if err == nil {
t.Fatal("LoadProviderCommand() error = nil, want timeout")
}
if !strings.Contains(err.Error(), "timed out after 5s") {
t.Fatalf("error = %q, want timeout", err.Error())
}
maxElapsed := 7 * time.Second
if runtime.GOOS == "windows" {
maxElapsed = 9 * time.Second
}
if elapsed > maxElapsed {
t.Fatalf("timeout returned after %s, want roughly 5s", elapsed)
}
}
func TestLoadProviderCommandInvalidJSON(t *testing.T) {
command := writeCommand(t, commandScript{Stdout: `{not-json`})
_, err := LoadProviderCommand(command)
if err == nil {
t.Fatal("LoadProviderCommand() error = nil, want JSON error")
}
if !strings.Contains(err.Error(), "invalid provider command JSON") {
t.Fatalf("error = %q, want invalid JSON", err.Error())
}
}
func TestLoadProviderCommandMissingModel(t *testing.T) {
command := writeCommand(t, commandScript{
Stdout: `{"name":"cmd","provider":"openai","apiKey":"sk-command"}`,
})
_, err := LoadProviderCommand(command)
if err == nil {
t.Fatal("LoadProviderCommand() error = nil, want missing model")
}
if !strings.Contains(err.Error(), "provider cmd requires model") {
t.Fatalf("error = %q, want missing model", err.Error())
}
}
type commandScript struct {
Stdout string
Stderr string
ExitCode int
SleepSeconds int
}
func writeCommand(t *testing.T, script commandScript) string {
t.Helper()
dir := t.TempDir()
if runtime.GOOS == "windows" {
path := filepath.Join(dir, "provider.cmd")
lines := []string{"@echo off"}
if script.SleepSeconds > 0 {
lines = append(lines, "powershell -NoProfile -Command \"Start-Sleep -Seconds "+itoa(script.SleepSeconds)+"\"")
}
if script.Stdout != "" {
lines = append(lines, "echo "+script.Stdout)
}
if script.Stderr != "" {
lines = append(lines, "echo "+script.Stderr+" 1>&2")
}
lines = append(lines, "exit /b "+itoa(script.ExitCode))
if err := os.WriteFile(path, []byte(strings.Join(lines, "\r\n")), 0o700); err != nil {
t.Fatalf("write command: %v", err)
}
return path
}
path := filepath.Join(dir, "provider.sh")
lines := []string{"#!/bin/sh"}
if script.SleepSeconds > 0 {
lines = append(lines, "sleep "+itoa(script.SleepSeconds))
}
if script.Stdout != "" {
lines = append(lines, "printf '%s\\n' '"+script.Stdout+"'")
}
if script.Stderr != "" {
lines = append(lines, "printf '%s\\n' '"+script.Stderr+"' >&2")
}
lines = append(lines, "exit "+itoa(script.ExitCode))
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o700); err != nil {
t.Fatalf("write command: %v", err)
}
return path
}
func itoa(value int) string {
if value == 0 {
return "0"
}
var digits [20]byte
index := len(digits)
for value > 0 {
index--
digits[index] = byte('0' + value%10)
value /= 10
}
return string(digits[index:])
}
-73
View File
@@ -1,73 +0,0 @@
package config
const ContractOwnerRuntime = "runtime"
type ContractGap struct {
Field string
Owner string
Milestone string
Reason string
}
func DefaultContractGaps() []ContractGap {
return []ContractGap{
{
Field: "env.OPENAI_API_KEY",
Owner: ContractOwnerRuntime,
Milestone: "M0",
Reason: "OpenAI-compatible provider startup needs a stable environment key contract.",
},
{
Field: "env.ZERO_PROVIDER",
Owner: ContractOwnerRuntime,
Milestone: "M0",
Reason: "Provider selection needs an explicit environment override before provider resolution is wired.",
},
{
Field: "providers[].timeout",
Owner: ContractOwnerRuntime,
Milestone: "M1",
Reason: "Provider adapters need bounded request and stream waits.",
},
{
Field: "providers[].retryPolicy",
Owner: ContractOwnerRuntime,
Milestone: "M1",
Reason: "Provider runtime needs consistent retry behavior across adapters.",
},
{
Field: "providers[].maxOutputTokens",
Owner: ContractOwnerRuntime,
Milestone: "M0",
Reason: "The provider contract must bound generated output for chat and tool loops.",
},
{
Field: "providers[].reasoningEffort",
Owner: ContractOwnerRuntime,
Milestone: "M1",
Reason: "Reasoning-capable models need a normalized config surface.",
},
{
Field: "permissions.mode",
Owner: ContractOwnerRuntime,
Milestone: "M0",
Reason: "Runtime permission behavior must be explicit before tools execute mutating actions.",
},
{
Field: "sandbox.policy",
Owner: ContractOwnerRuntime,
Milestone: "M0",
Reason: "Sandbox policy needs a shared backend contract before platform adapters land.",
},
}
}
func FindContractGapsByMilestone(gaps []ContractGap, milestone string) []ContractGap {
var matches []ContractGap
for _, gap := range gaps {
if gap.Milestone == milestone {
matches = append(matches, gap)
}
}
return matches
}
-48
View File
@@ -1,48 +0,0 @@
package config
import "testing"
func TestDefaultContractGapsCoverProviderAndPolicyFields(t *testing.T) {
gaps := DefaultContractGaps()
fields := make(map[string]ContractGap)
for _, gap := range gaps {
fields[gap.Field] = gap
}
for _, field := range []string{
"env.OPENAI_API_KEY",
"env.ZERO_PROVIDER",
"providers[].timeout",
"providers[].retryPolicy",
"providers[].maxOutputTokens",
"providers[].reasoningEffort",
"permissions.mode",
"sandbox.policy",
} {
gap, ok := fields[field]
if !ok {
t.Fatalf("missing config contract gap for %s", field)
}
if gap.Owner != ContractOwnerRuntime {
t.Fatalf("gap %s owner = %q, want %q", field, gap.Owner, ContractOwnerRuntime)
}
if gap.Milestone == "" {
t.Fatalf("gap %s should include milestone", field)
}
if gap.Reason == "" {
t.Fatalf("gap %s should include reason", field)
}
}
}
func TestFindContractGapsByMilestone(t *testing.T) {
gaps := FindContractGapsByMilestone(DefaultContractGaps(), "M0")
if len(gaps) == 0 {
t.Fatal("expected M0 gaps")
}
for _, gap := range gaps {
if gap.Milestone != "M0" {
t.Fatalf("got milestone %q, want M0", gap.Milestone)
}
}
}
-47
View File
@@ -1,47 +0,0 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// DefaultResolveOptions builds config resolution inputs from the local process
// environment and workspace.
func DefaultResolveOptions(workspaceRoot string) (ResolveOptions, error) {
userConfigDir, err := os.UserConfigDir()
if err != nil {
return ResolveOptions{}, fmt.Errorf("resolve user config directory: %w", err)
}
userConfigPath, err := existingConfigFile(filepath.Join(userConfigDir, "zero", "config.json"))
if err != nil {
return ResolveOptions{}, err
}
projectConfigPath, err := existingConfigFile(filepath.Join(workspaceRoot, ".zero", "config.json"))
if err != nil {
return ResolveOptions{}, err
}
return ResolveOptions{
UserConfigPath: userConfigPath,
ProjectConfigPath: projectConfigPath,
ProviderCommand: strings.TrimSpace(os.Getenv("ZERO_PROVIDER_COMMAND")),
}, nil
}
func existingConfigFile(path string) (string, error) {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("inspect config %s: %w", path, err)
}
if info.IsDir() {
return "", fmt.Errorf("config path %s is a directory, want a file", path)
}
return path, nil
}
-132
View File
@@ -1,132 +0,0 @@
package config
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestDefaultResolveOptionsUsesExistingConfigPathsAndProviderCommand(t *testing.T) {
userConfigRoot := setUserConfigRoot(t)
workspaceRoot := t.TempDir()
userPath := filepath.Join(userConfigRoot, "zero", "config.json")
projectPath := filepath.Join(workspaceRoot, ".zero", "config.json")
writeFileAt(t, userPath, "{}")
writeFileAt(t, projectPath, "{}")
t.Setenv("ZERO_PROVIDER_COMMAND", " zero-provider ")
options, err := DefaultResolveOptions(workspaceRoot)
if err != nil {
t.Fatalf("DefaultResolveOptions() error = %v", err)
}
if options.UserConfigPath != userPath {
t.Fatalf("UserConfigPath = %q, want %q", options.UserConfigPath, userPath)
}
if options.ProjectConfigPath != projectPath {
t.Fatalf("ProjectConfigPath = %q, want %q", options.ProjectConfigPath, projectPath)
}
if options.ProviderCommand != "zero-provider" {
t.Fatalf("ProviderCommand = %q, want zero-provider", options.ProviderCommand)
}
}
func TestDefaultResolveOptionsIgnoresMissingConfigFiles(t *testing.T) {
setUserConfigRoot(t)
workspaceRoot := t.TempDir()
options, err := DefaultResolveOptions(workspaceRoot)
if err != nil {
t.Fatalf("DefaultResolveOptions() error = %v", err)
}
if options.UserConfigPath != "" {
t.Fatalf("UserConfigPath = %q, want empty for missing file", options.UserConfigPath)
}
if options.ProjectConfigPath != "" {
t.Fatalf("ProjectConfigPath = %q, want empty for missing file", options.ProjectConfigPath)
}
}
func TestDefaultResolveOptionsRejectsDirectoryConfigPaths(t *testing.T) {
tests := []struct {
name string
makeDirectory func(t *testing.T, userConfigRoot string, workspaceRoot string) string
}{
{
name: "user config",
makeDirectory: func(t *testing.T, userConfigRoot string, workspaceRoot string) string {
t.Helper()
path := filepath.Join(userConfigRoot, "zero", "config.json")
mkdirAll(t, path)
return path
},
},
{
name: "project config",
makeDirectory: func(t *testing.T, userConfigRoot string, workspaceRoot string) string {
t.Helper()
path := filepath.Join(workspaceRoot, ".zero", "config.json")
mkdirAll(t, path)
return path
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
userConfigRoot := setUserConfigRoot(t)
workspaceRoot := t.TempDir()
path := tc.makeDirectory(t, userConfigRoot, workspaceRoot)
_, err := DefaultResolveOptions(workspaceRoot)
if err == nil {
t.Fatal("DefaultResolveOptions() error = nil, want directory error")
}
if !strings.Contains(err.Error(), path) || !strings.Contains(err.Error(), "is a directory") {
t.Fatalf("error = %q, want clear directory message for %q", err.Error(), path)
}
})
}
}
func setUserConfigRoot(t *testing.T) string {
t.Helper()
root := t.TempDir()
switch runtime.GOOS {
case "windows":
t.Setenv("APPDATA", root)
case "darwin":
t.Setenv("HOME", root)
default:
t.Setenv("XDG_CONFIG_HOME", root)
}
configRoot, err := os.UserConfigDir()
if err != nil {
t.Fatalf("UserConfigDir() error = %v", err)
}
return configRoot
}
func writeFileAt(t *testing.T, path string, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatalf("create parent directories: %v", err)
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write file %s: %v", path, err)
}
}
func mkdirAll(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o700); err != nil {
t.Fatalf("create directory %s: %v", path, err)
}
}
-20
View File
@@ -1,20 +0,0 @@
//go:build !windows
package config
import (
"os/exec"
"syscall"
)
func configureCommandProcess(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
func terminateCommandProcess(cmd *exec.Cmd) {
if cmd.Process == nil {
return
}
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
_ = cmd.Process.Kill()
}
-18
View File
@@ -1,18 +0,0 @@
//go:build windows
package config
import (
"os/exec"
"strconv"
)
func configureCommandProcess(cmd *exec.Cmd) {}
func terminateCommandProcess(cmd *exec.Cmd) {
if cmd.Process == nil {
return
}
_ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(cmd.Process.Pid)).Run()
_ = cmd.Process.Kill()
}
-36
View File
@@ -1,36 +0,0 @@
package config
import (
"regexp"
"strings"
)
var secretPatterns = []*regexp.Regexp{
regexp.MustCompile(`sk-[A-Za-z0-9._-]+`),
regexp.MustCompile(`(?i)(api[_-]?key["'=:\s]+)[^"',\s)]+`),
regexp.MustCompile(`(?i)(bearer\s+)[A-Za-z0-9._-]+`),
}
func redactSecrets(value string, knownSecrets ...string) string {
redacted := value
for _, secret := range knownSecrets {
if secret == "" {
continue
}
redacted = strings.ReplaceAll(redacted, secret, "[REDACTED]")
}
for _, pattern := range secretPatterns {
redacted = pattern.ReplaceAllStringFunc(redacted, func(match string) string {
for _, prefix := range []string{"apiKey=", "api_key=", "api-key=", "Bearer "} {
if strings.HasPrefix(strings.ToLower(match), strings.ToLower(prefix)) {
return match[:len(prefix)] + "[REDACTED]"
}
}
if strings.HasPrefix(match, "sk-") {
return "[REDACTED]"
}
return "[REDACTED]"
})
}
return redacted
}
-341
View File
@@ -1,341 +0,0 @@
package config
import (
"encoding/json"
"fmt"
"os"
"strings"
)
const defaultMaxTurns = 12
func Resolve(options ResolveOptions) (ResolvedConfig, error) {
cfg := FileConfig{
MaxTurns: defaultMaxTurns,
}
for _, path := range []string{options.UserConfigPath, options.ProjectConfigPath} {
if path == "" {
continue
}
fileConfig, err := loadConfigFile(path)
if err != nil {
return ResolvedConfig{}, err
}
mergeConfig(&cfg, fileConfig)
}
applyEnv(&cfg, options.Env)
if options.ProviderCommand != "" {
commandConfig, err := LoadProviderCommand(options.ProviderCommand)
if err != nil {
return ResolvedConfig{}, err
}
mergeConfig(&cfg, commandConfig)
}
applyOverrides(&cfg, options.Overrides)
providers, active, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider)
if err != nil {
return ResolvedConfig{}, err
}
return ResolvedConfig{
ActiveProvider: active.Name,
Providers: providers,
Provider: active,
MaxTurns: cfg.MaxTurns,
}, nil
}
func loadConfigFile(path string) (FileConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
var cfg FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
return cfg, nil
}
func mergeConfig(dst *FileConfig, src FileConfig) {
if activeProvider := strings.TrimSpace(src.ActiveProvider); activeProvider != "" {
dst.ActiveProvider = activeProvider
}
if src.MaxTurns > 0 {
dst.MaxTurns = src.MaxTurns
}
for _, provider := range src.Providers {
mergeProvider(dst, provider)
}
}
func mergeProvider(cfg *FileConfig, provider ProviderProfile) {
provider.Name = strings.TrimSpace(provider.Name)
if provider.Name == "" {
provider.Name = strings.TrimSpace(cfg.ActiveProvider)
}
if provider.Name == "" {
provider.Name = string(ProviderKindOpenAI)
}
for index := range cfg.Providers {
if cfg.Providers[index].Name == provider.Name {
cfg.Providers[index] = mergeProfile(cfg.Providers[index], provider)
return
}
}
cfg.Providers = append(cfg.Providers, provider)
}
func mergeProfile(base ProviderProfile, next ProviderProfile) ProviderProfile {
if next.Name != "" {
base.Name = next.Name
}
if next.Provider != "" {
base.Provider = next.Provider
}
if next.ProviderKind != "" {
base.ProviderKind = next.ProviderKind
}
if next.BaseURL != "" {
base.BaseURL = next.BaseURL
}
if next.APIKey != "" {
base.APIKey = next.APIKey
}
if next.Model != "" {
base.Model = next.Model
}
if next.Description != "" {
base.Description = next.Description
}
return base
}
func applyEnv(cfg *FileConfig, env map[string]string) {
activeProvider := strings.TrimSpace(envValue(env, "ZERO_PROVIDER"))
if activeProvider != "" {
cfg.ActiveProvider = activeProvider
}
applyProviderEnv(cfg, ProviderKindOpenAI, envProfile{
Name: string(ProviderKindOpenAI),
APIKey: envValue(env, "OPENAI_API_KEY"),
BaseURL: envValue(env, "OPENAI_BASE_URL"),
Model: envValue(env, "OPENAI_MODEL"),
})
applyProviderEnv(cfg, ProviderKindAnthropic, envProfile{
Name: string(ProviderKindAnthropic),
APIKey: envValue(env, "ANTHROPIC_API_KEY"),
BaseURL: envValue(env, "ANTHROPIC_BASE_URL"),
Model: envValue(env, "ANTHROPIC_MODEL"),
})
applyProviderEnv(cfg, ProviderKindGoogle, envProfile{
Name: string(ProviderKindGoogle),
APIKey: firstNonEmpty(envValue(env, "GEMINI_API_KEY"), envValue(env, "GOOGLE_API_KEY")),
BaseURL: firstNonEmpty(envValue(env, "GEMINI_BASE_URL"), envValue(env, "GOOGLE_BASE_URL")),
Model: firstNonEmpty(envValue(env, "GEMINI_MODEL"), envValue(env, "GOOGLE_MODEL")),
})
}
type envProfile struct {
Name string
APIKey string
BaseURL string
Model string
}
func applyProviderEnv(cfg *FileConfig, providerKind ProviderKind, env envProfile) {
apiKey := strings.TrimSpace(env.APIKey)
baseURL := strings.TrimSpace(env.BaseURL)
model := strings.TrimSpace(env.Model)
if apiKey == "" && baseURL == "" && model == "" {
return
}
profile := ProviderProfile{
Name: providerEnvTargetName(cfg, providerKind, env.Name),
ProviderKind: providerKind,
APIKey: apiKey,
BaseURL: baseURL,
Model: model,
}
if profile.Name == "" {
profile.Name = string(providerKind)
}
if providerKind == ProviderKindOpenAI && baseURL != "" && !isOfficialOpenAIBaseURL(baseURL) {
profile.ProviderKind = ProviderKindOpenAICompatible
}
mergeProvider(cfg, profile)
}
func providerEnvTargetName(cfg *FileConfig, providerKind ProviderKind, fallback string) string {
if activeName := strings.TrimSpace(cfg.ActiveProvider); activeName != "" {
for _, provider := range cfg.Providers {
if strings.TrimSpace(provider.Name) != activeName {
continue
}
if normalizedProfileKind(provider) == providerKind {
return activeName
}
break
}
}
for _, provider := range cfg.Providers {
if normalizedProfileKind(provider) != providerKind {
continue
}
if name := strings.TrimSpace(provider.Name); name != "" {
return name
}
}
return strings.TrimSpace(fallback)
}
func normalizedProfileKind(profile ProviderProfile) ProviderKind {
kind := strings.TrimSpace(string(profile.ProviderKind))
if kind == "" {
kind = strings.TrimSpace(profile.Provider)
}
return ProviderKind(strings.ToLower(kind))
}
func envValue(env map[string]string, key string) string {
if env != nil {
return env[key]
}
return os.Getenv(key)
}
func applyOverrides(cfg *FileConfig, overrides Overrides) {
if activeProvider := strings.TrimSpace(overrides.ActiveProvider); activeProvider != "" {
cfg.ActiveProvider = activeProvider
}
if overrides.MaxTurns > 0 {
cfg.MaxTurns = overrides.MaxTurns
}
for _, provider := range overrides.Providers {
mergeProvider(cfg, provider)
}
if hasProviderFields(overrides.Provider) {
mergeProvider(cfg, overrides.Provider)
}
}
func hasProviderFields(profile ProviderProfile) bool {
return profile.Name != "" ||
profile.Provider != "" ||
profile.ProviderKind != "" ||
profile.BaseURL != "" ||
profile.APIKey != "" ||
profile.Model != "" ||
profile.Description != ""
}
func normalizeProviders(providers []ProviderProfile, activeName string) ([]ProviderProfile, ProviderProfile, error) {
activeName = strings.TrimSpace(activeName)
if len(providers) == 0 {
if activeName != "" {
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", activeName)
}
return []ProviderProfile{}, ProviderProfile{}, nil
}
if activeName == "" && len(providers) == 1 {
activeName = providers[0].Name
}
normalized := make([]ProviderProfile, 0, len(providers))
var active ProviderProfile
activeFound := false
for _, provider := range providers {
next, err := normalizeProvider(provider)
if err != nil {
return nil, ProviderProfile{}, err
}
normalized = append(normalized, next)
if next.Name == activeName {
active = next
activeFound = true
}
}
if !activeFound {
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", activeName)
}
if active.Model == "" {
return nil, ProviderProfile{}, providerError(active, "provider %s requires model", active.Name)
}
return normalized, active, nil
}
func normalizeProvider(profile ProviderProfile) (ProviderProfile, error) {
profile.Name = strings.TrimSpace(profile.Name)
profile.Provider = strings.TrimSpace(profile.Provider)
profile.ProviderKind = ProviderKind(strings.TrimSpace(strings.ToLower(string(profile.ProviderKind))))
profile.BaseURL = strings.TrimSpace(profile.BaseURL)
profile.Model = strings.TrimSpace(profile.Model)
if profile.Name == "" {
profile.Name = string(ProviderKindOpenAI)
}
if profile.ProviderKind == "" && profile.Provider != "" {
profile.ProviderKind = ProviderKind(strings.ToLower(profile.Provider))
}
if profile.ProviderKind == "" {
profile.ProviderKind = ProviderKindOpenAI
}
switch profile.ProviderKind {
case ProviderKindOpenAI:
if profile.BaseURL == "" || isOfficialOpenAIBaseURL(profile.BaseURL) {
profile.BaseURL = OpenAIBaseURL
return profile, nil
}
return ProviderProfile{}, providerError(profile, "openai provider %s requires official baseURL %s", profile.Name, OpenAIBaseURL)
case ProviderKindOpenAICompatible:
if profile.BaseURL == "" {
return ProviderProfile{}, providerError(profile, "openai-compatible provider %s requires baseURL", profile.Name)
}
if isOfficialOpenAIBaseURL(profile.BaseURL) {
return ProviderProfile{}, providerError(profile, "openai-compatible provider %s requires custom baseURL", profile.Name)
}
return profile, nil
case ProviderKindAnthropic:
if profile.BaseURL == "" {
profile.BaseURL = AnthropicBaseURL
}
return profile, nil
case ProviderKindGoogle:
if profile.BaseURL == "" {
profile.BaseURL = GoogleBaseURL
}
return profile, nil
default:
return ProviderProfile{}, providerError(profile, "unknown provider kind %q for provider %s", profile.ProviderKind, profile.Name)
}
}
func isOfficialOpenAIBaseURL(baseURL string) bool {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
return baseURL == "" ||
baseURL == "openai" ||
baseURL == strings.TrimRight(OpenAIBaseURL, "/")
}
func providerError(profile ProviderProfile, format string, args ...any) error {
message := fmt.Sprintf(format, args...)
if profile.APIKey != "" {
message += " (apiKey=[REDACTED])"
}
return fmt.Errorf("%s", redactSecrets(message, profile.APIKey))
}
-472
View File
@@ -1,472 +0,0 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestResolveAppliesLayerPrecedence(t *testing.T) {
userPath := writeConfig(t, `{
"activeProvider": "user",
"maxTurns": 3,
"providers": [{
"name": "user",
"provider": "openai",
"api_key": "sk-user",
"model_id": "gpt-user"
}]
}`)
projectPath := writeConfig(t, `{
"activeProvider": "project",
"maxTurns": 4,
"providers": [{
"name": "project",
"provider_kind": "openai-compatible",
"base_url": "https://project.example/v1",
"apiKey": "sk-project",
"model": "project-model"
}]
}`)
resolved, err := Resolve(ResolveOptions{
UserConfigPath: userPath,
ProjectConfigPath: projectPath,
Env: map[string]string{
"ZERO_PROVIDER": "env",
"OPENAI_MODEL": "env-model",
},
Overrides: Overrides{
ActiveProvider: "cli",
MaxTurns: 9,
Provider: ProviderProfile{
Name: "cli",
ProviderKind: "openai-compatible",
BaseURL: "https://cli.example/v1",
APIKey: "sk-cli",
Model: "cli-model",
},
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.ActiveProvider != "cli" {
t.Fatalf("ActiveProvider = %q, want cli", resolved.ActiveProvider)
}
if resolved.MaxTurns != 9 {
t.Fatalf("MaxTurns = %d, want 9", resolved.MaxTurns)
}
if resolved.Provider.Name != "cli" || resolved.Provider.Model != "cli-model" {
t.Fatalf("Provider = %#v, want CLI provider", resolved.Provider)
}
if resolved.Provider.BaseURL != "https://cli.example/v1" {
t.Fatalf("BaseURL = %q, want CLI custom URL", resolved.Provider.BaseURL)
}
}
func TestResolveSelectsActiveProviderProfile(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "beta",
"providers": [
{"name": "alpha", "provider": "openai", "apiKey": "sk-alpha", "model": "gpt-alpha"},
{"name": "beta", "provider": "openai", "apiKey": "sk-beta", "model": "gpt-beta"}
]
}`)
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.Name != "beta" {
t.Fatalf("Provider.Name = %q, want beta", resolved.Provider.Name)
}
if resolved.Provider.APIKey != "sk-beta" {
t.Fatalf("Provider.APIKey = %q, want sk-beta", resolved.Provider.APIKey)
}
}
func TestResolveIgnoresWhitespaceOnlyActiveProviderLayers(t *testing.T) {
userPath := writeConfig(t, `{
"activeProvider": "alpha",
"providers": [
{"name": "alpha", "provider": "openai", "apiKey": "sk-alpha", "model": "gpt-alpha"},
{"name": "beta", "provider": "openai", "apiKey": "sk-beta", "model": "gpt-beta"}
]
}`)
projectPath := writeConfig(t, `{"activeProvider": " "}`)
resolved, err := Resolve(ResolveOptions{
UserConfigPath: userPath,
ProjectConfigPath: projectPath,
Env: map[string]string{},
Overrides: Overrides{ActiveProvider: " "},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.ActiveProvider != "alpha" {
t.Fatalf("ActiveProvider = %q, want alpha", resolved.ActiveProvider)
}
}
func TestResolveUsesOpenAIEnvFallback(t *testing.T) {
resolved, err := Resolve(ResolveOptions{
Env: map[string]string{
"OPENAI_API_KEY": "sk-env",
"OPENAI_BASE_URL": "https://env.example/v1",
"OPENAI_MODEL": "env-model",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.ActiveProvider != "openai" {
t.Fatalf("ActiveProvider = %q, want openai", resolved.ActiveProvider)
}
if resolved.Provider.ProviderKind != ProviderKindOpenAICompatible {
t.Fatalf("ProviderKind = %q, want openai-compatible", resolved.Provider.ProviderKind)
}
if resolved.Provider.APIKey != "sk-env" || resolved.Provider.Model != "env-model" {
t.Fatalf("Provider = %#v, want env credentials/model", resolved.Provider)
}
if resolved.Provider.BaseURL != "https://env.example/v1" {
t.Fatalf("BaseURL = %q, want env URL", resolved.Provider.BaseURL)
}
}
func TestResolveUsesAnthropicEnvFallback(t *testing.T) {
resolved, err := Resolve(ResolveOptions{
Env: map[string]string{
"ZERO_PROVIDER": "anthropic",
"ANTHROPIC_API_KEY": "sk-ant-env",
"ANTHROPIC_BASE_URL": "https://anthropic.example",
"ANTHROPIC_MODEL": "claude-sonnet-4.5",
"OPENAI_API_KEY": "sk-openai-env",
"OPENAI_MODEL": "gpt-4.1",
"GEMINI_API_KEY": "sk-google-env",
"GEMINI_MODEL": "gemini-2.5-flash",
"GEMINI_BASE_URL": "https://google.example",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.ActiveProvider != "anthropic" {
t.Fatalf("ActiveProvider = %q, want anthropic", resolved.ActiveProvider)
}
if resolved.Provider.ProviderKind != ProviderKindAnthropic {
t.Fatalf("ProviderKind = %q, want anthropic", resolved.Provider.ProviderKind)
}
if resolved.Provider.APIKey != "sk-ant-env" || resolved.Provider.Model != "claude-sonnet-4.5" {
t.Fatalf("Provider = %#v, want Anthropic env credentials/model", resolved.Provider)
}
if resolved.Provider.BaseURL != "https://anthropic.example" {
t.Fatalf("BaseURL = %q, want Anthropic env URL", resolved.Provider.BaseURL)
}
if len(resolved.Providers) != 3 {
t.Fatalf("Providers = %#v, want openai, anthropic, and google env profiles", resolved.Providers)
}
}
func TestResolveUsesAnthropicEnvFallbackWithCustomProfile(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "claude-prod",
"providers": [{
"name": "claude-prod",
"provider_kind": "anthropic",
"description": "production Claude"
}]
}`)
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{
"ZERO_PROVIDER": "claude-prod",
"ANTHROPIC_API_KEY": "sk-ant-env",
"ANTHROPIC_BASE_URL": "https://anthropic.example",
"ANTHROPIC_MODEL": "claude-sonnet-4.5",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.ActiveProvider != "claude-prod" {
t.Fatalf("ActiveProvider = %q, want claude-prod", resolved.ActiveProvider)
}
if resolved.Provider.ProviderKind != ProviderKindAnthropic {
t.Fatalf("ProviderKind = %q, want anthropic", resolved.Provider.ProviderKind)
}
if resolved.Provider.APIKey != "sk-ant-env" || resolved.Provider.Model != "claude-sonnet-4.5" {
t.Fatalf("Provider = %#v, want Anthropic env credentials/model on custom profile", resolved.Provider)
}
if resolved.Provider.BaseURL != "https://anthropic.example" {
t.Fatalf("BaseURL = %q, want Anthropic env URL", resolved.Provider.BaseURL)
}
if resolved.Provider.Description != "production Claude" {
t.Fatalf("Description = %q, want existing custom profile metadata preserved", resolved.Provider.Description)
}
if len(resolved.Providers) != 1 {
t.Fatalf("Providers = %#v, want only custom Anthropic profile", resolved.Providers)
}
}
func TestResolveUsesGoogleEnvFallbackAliases(t *testing.T) {
resolved, err := Resolve(ResolveOptions{
Env: map[string]string{
"ZERO_PROVIDER": "google",
"GOOGLE_API_KEY": "sk-google-env",
"GOOGLE_BASE_URL": "https://google.example",
"GOOGLE_MODEL": "gemini-2.5-pro",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.ActiveProvider != "google" {
t.Fatalf("ActiveProvider = %q, want google", resolved.ActiveProvider)
}
if resolved.Provider.ProviderKind != ProviderKindGoogle {
t.Fatalf("ProviderKind = %q, want google", resolved.Provider.ProviderKind)
}
if resolved.Provider.APIKey != "sk-google-env" || resolved.Provider.Model != "gemini-2.5-pro" {
t.Fatalf("Provider = %#v, want Google env credentials/model", resolved.Provider)
}
if resolved.Provider.BaseURL != "https://google.example" {
t.Fatalf("BaseURL = %q, want Google env URL", resolved.Provider.BaseURL)
}
}
func TestResolveProviderCommandOverridesEnvProviderFields(t *testing.T) {
command := writeCommand(t, commandScript{
Stdout: `{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}`,
})
resolved, err := Resolve(ResolveOptions{
ProviderCommand: command,
Env: map[string]string{
"OPENAI_API_KEY": "sk-env",
"OPENAI_MODEL": "gpt-env",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.ActiveProvider != "cmd" {
t.Fatalf("ActiveProvider = %q, want cmd", resolved.ActiveProvider)
}
if resolved.Provider.APIKey != "sk-command" {
t.Fatalf("APIKey = %q, want provider command key", resolved.Provider.APIKey)
}
if resolved.Provider.Model != "gpt-command" {
t.Fatalf("Model = %q, want provider command model", resolved.Provider.Model)
}
}
func TestResolveAllowsNoConfiguredProviders(t *testing.T) {
resolved, err := Resolve(ResolveOptions{Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.ActiveProvider != "" {
t.Fatalf("ActiveProvider = %q, want empty", resolved.ActiveProvider)
}
if len(resolved.Providers) != 0 {
t.Fatalf("Providers = %#v, want empty", resolved.Providers)
}
if resolved.Provider != (ProviderProfile{}) {
t.Fatalf("Provider = %#v, want zero value", resolved.Provider)
}
if resolved.MaxTurns != defaultMaxTurns {
t.Fatalf("MaxTurns = %d, want %d", resolved.MaxTurns, defaultMaxTurns)
}
}
func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
path := writeConfig(t, `{"activeProvider":"ghost","providers":[]}`)
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err == nil {
t.Fatal("Resolve() error = nil, want missing active provider error")
}
if !strings.Contains(err.Error(), `active provider "ghost" not found`) {
t.Fatalf("error = %q, want active provider missing message", err.Error())
}
if resolved.ActiveProvider != "" {
t.Fatalf("ActiveProvider = %q, want empty", resolved.ActiveProvider)
}
if len(resolved.Providers) != 0 {
t.Fatalf("Providers = %#v, want empty", resolved.Providers)
}
if resolved.Provider != (ProviderProfile{}) {
t.Fatalf("Provider = %#v, want zero value", resolved.Provider)
}
if resolved.MaxTurns != 0 {
t.Fatalf("MaxTurns = %d, want zero on failed resolve", resolved.MaxTurns)
}
}
func TestResolveTrimsProviderProfileAliasesBeforeFallback(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "custom",
"providers": [{
"name": "custom",
"provider_kind": "openai-compatible",
"baseURL": " ",
"base_url": "https://custom.example/v1",
"apiKey": " ",
"api_key": "sk-custom",
"model": " ",
"model_id": "custom-model"
}]
}`)
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.BaseURL != "https://custom.example/v1" {
t.Fatalf("BaseURL = %q, want alias fallback", resolved.Provider.BaseURL)
}
if resolved.Provider.APIKey != "sk-custom" {
t.Fatalf("APIKey = %q, want alias fallback", resolved.Provider.APIKey)
}
if resolved.Provider.Model != "custom-model" {
t.Fatalf("Model = %q, want alias fallback", resolved.Provider.Model)
}
}
func TestResolveNormalizesOfficialOpenAIBaseURL(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "openai",
"providers": [{
"name": "openai",
"provider": "openai",
"baseURL": "openai",
"apiKey": "sk-official",
"model": "gpt-4.1"
}]
}`)
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.ProviderKind != ProviderKindOpenAI {
t.Fatalf("ProviderKind = %q, want openai", resolved.Provider.ProviderKind)
}
if resolved.Provider.BaseURL != OpenAIBaseURL {
t.Fatalf("BaseURL = %q, want %q", resolved.Provider.BaseURL, OpenAIBaseURL)
}
}
func TestResolveAcceptsOfficialAnthropicAndGoogleProfiles(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "claude",
"providers": [
{"name": "claude", "provider": "anthropic", "apiKey": "sk-ant", "model": "claude-sonnet-4.5"},
{"name": "gemini", "provider": "google", "apiKey": "sk-google", "model": "gemini-2.5-flash"}
]
}`)
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.ProviderKind != ProviderKindAnthropic {
t.Fatalf("ProviderKind = %q, want anthropic", resolved.Provider.ProviderKind)
}
if resolved.Provider.BaseURL != AnthropicBaseURL {
t.Fatalf("BaseURL = %q, want default Anthropic URL", resolved.Provider.BaseURL)
}
if resolved.Providers[1].ProviderKind != ProviderKindGoogle {
t.Fatalf("Google ProviderKind = %q, want google", resolved.Providers[1].ProviderKind)
}
if resolved.Providers[1].BaseURL != GoogleBaseURL {
t.Fatalf("Google BaseURL = %q, want default Google URL", resolved.Providers[1].BaseURL)
}
}
func TestResolveRejectsOpenAICompatibleWithoutBaseURL(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "custom",
"providers": [{
"name": "custom",
"provider_kind": "openai-compatible",
"apiKey": "sk-custom",
"model": "custom-model"
}]
}`)
_, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err == nil {
t.Fatal("Resolve() error = nil, want validation error")
}
if !strings.Contains(err.Error(), "openai-compatible provider custom requires baseURL") {
t.Fatalf("error = %q, want missing baseURL message", err.Error())
}
}
func TestResolveRejectsUnknownProviderKind(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "bad",
"providers": [{
"name": "bad",
"provider": "bedrock",
"apiKey": "sk-bad",
"model": "model"
}]
}`)
_, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err == nil {
t.Fatal("Resolve() error = nil, want validation error")
}
if !strings.Contains(err.Error(), `unknown provider kind "bedrock"`) {
t.Fatalf("error = %q, want unknown provider kind", err.Error())
}
}
func TestResolveRedactsSecretsFromErrors(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "custom",
"providers": [{
"name": "custom",
"provider_kind": "openai-compatible",
"apiKey": "sk-secret-value",
"model": "custom-model"
}]
}`)
_, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err == nil {
t.Fatal("Resolve() error = nil, want validation error")
}
if strings.Contains(err.Error(), "sk-secret-value") {
t.Fatalf("error leaked secret: %q", err.Error())
}
if !strings.Contains(err.Error(), "[REDACTED]") {
t.Fatalf("error = %q, want redaction marker", err.Error())
}
}
func writeConfig(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "zero.json")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
return path
}
-96
View File
@@ -1,96 +0,0 @@
package config
import (
"encoding/json"
"strings"
)
const OpenAIBaseURL = "https://api.openai.com/v1"
const AnthropicBaseURL = "https://api.anthropic.com"
const GoogleBaseURL = "https://generativelanguage.googleapis.com"
type ProviderKind string
const (
ProviderKindOpenAI ProviderKind = "openai"
ProviderKindAnthropic ProviderKind = "anthropic"
ProviderKindGoogle ProviderKind = "google"
ProviderKindOpenAICompatible ProviderKind = "openai-compatible"
)
type ProviderProfile struct {
Name string `json:"name"`
Provider string `json:"provider,omitempty"`
ProviderKind ProviderKind `json:"provider_kind,omitempty"`
BaseURL string `json:"baseURL,omitempty"`
APIKey string `json:"apiKey,omitempty"`
Model string `json:"model,omitempty"`
Description string `json:"description,omitempty"`
}
type FileConfig struct {
ActiveProvider string `json:"activeProvider,omitempty"`
Providers []ProviderProfile `json:"providers,omitempty"`
MaxTurns int `json:"maxTurns,omitempty"`
}
type ResolveOptions struct {
UserConfigPath string
ProjectConfigPath string
ProviderCommand string
Env map[string]string
Overrides Overrides
}
type Overrides struct {
ActiveProvider string
Providers []ProviderProfile
Provider ProviderProfile
MaxTurns int
}
type ResolvedConfig struct {
ActiveProvider string
Providers []ProviderProfile
Provider ProviderProfile
MaxTurns int
}
func (profile *ProviderProfile) UnmarshalJSON(data []byte) error {
type rawProfile struct {
Name string `json:"name"`
Provider string `json:"provider"`
ProviderKind string `json:"provider_kind"`
BaseURL string `json:"baseURL"`
BaseURLSnake string `json:"base_url"`
APIKey string `json:"apiKey"`
APIKeySnake string `json:"api_key"`
Model string `json:"model"`
ModelID string `json:"model_id"`
Description string `json:"description"`
}
var raw rawProfile
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
profile.Name = strings.TrimSpace(raw.Name)
profile.Provider = strings.TrimSpace(raw.Provider)
profile.ProviderKind = ProviderKind(firstNonEmpty(raw.ProviderKind, raw.Provider))
profile.BaseURL = strings.TrimSpace(firstNonEmpty(raw.BaseURL, raw.BaseURLSnake))
profile.APIKey = firstNonEmpty(raw.APIKey, raw.APIKeySnake)
profile.Model = strings.TrimSpace(firstNonEmpty(raw.Model, raw.ModelID))
profile.Description = strings.TrimSpace(raw.Description)
return nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}
-233
View File
@@ -1,233 +0,0 @@
package doctor
import (
"fmt"
"sort"
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/redaction"
)
type Status string
const (
StatusPass Status = "pass"
StatusWarn Status = "warn"
StatusFail Status = "fail"
)
type Check struct {
ID string `json:"id"`
Label string `json:"label"`
Status Status `json:"status"`
Message string `json:"message"`
Details map[string]any `json:"details,omitempty"`
}
type Report struct {
GeneratedAt string `json:"generatedAt"`
OK bool `json:"ok"`
Checks []Check `json:"checks"`
}
type Options struct {
Now func() time.Time
Runtime string
UserConfig string
ProjectConfig string
Provider config.ProviderProfile
Connectivity bool
}
func Run(options Options) Report {
now := options.Now
if now == nil {
now = time.Now
}
checks := []Check{
runtimeCheck(options.Runtime),
configFilesCheck(options.UserConfig, options.ProjectConfig),
}
providerCheck := providerConfigCheck(options.Provider)
checks = append(checks, providerCheck)
modelCheck := providerModelCheck(options.Provider)
checks = append(checks, modelCheck)
checks = append(checks, connectivityCheck(options.Provider, options.Connectivity, modelCheck.Status))
report := Report{
GeneratedAt: now().UTC().Format(time.RFC3339),
OK: true,
Checks: checks,
}
for _, check := range checks {
if check.Status == StatusFail {
report.OK = false
break
}
}
return report
}
func (report Report) Check(id string) *Check {
for index := range report.Checks {
if report.Checks[index].ID == id {
return &report.Checks[index]
}
}
return nil
}
func Format(report Report) string {
lines := []string{
fmt.Sprintf("Zero doctor report (%s)", redaction.RedactString(report.GeneratedAt, redaction.Options{})),
fmt.Sprintf("Overall: %s", passFail(report.OK)),
}
for _, check := range report.Checks {
lines = append(lines, fmt.Sprintf("[%s] %s - %s", check.Status, redaction.RedactString(check.ID, redaction.Options{}), redaction.RedactString(check.Message, redaction.Options{})))
if details := formatDetails(check.Details); details != "" {
lines = append(lines, " "+details)
}
}
return strings.Join(lines, "\n")
}
func runtimeCheck(runtime string) Check {
runtime = strings.TrimSpace(runtime)
if runtime == "" {
runtime = "go"
}
return check("runtime.go", "Go runtime", StatusPass, fmt.Sprintf("Zero Go runtime is available (%s).", runtime), map[string]any{"runtime": runtime})
}
func configFilesCheck(userPath string, projectPath string) Check {
details := map[string]any{}
if strings.TrimSpace(userPath) != "" {
details["userConfigPath"] = userPath
}
if strings.TrimSpace(projectPath) != "" {
details["projectConfigPath"] = projectPath
}
if len(details) == 0 {
return check("config.files", "Config files", StatusWarn, "No explicit Zero config files were inspected.", nil)
}
return check("config.files", "Config files", StatusPass, "Zero config file inputs are available for inspection.", details)
}
func providerConfigCheck(profile config.ProviderProfile) Check {
if profile == (config.ProviderProfile{}) {
return check("provider.config", "Provider config", StatusFail, "No LLM provider is configured.", map[string]any{"help": "Set a provider in config or environment."})
}
return check("provider.config", "Provider config", StatusPass, fmt.Sprintf("Provider config loaded for %s.", providerName(profile)), map[string]any{
"name": profile.Name,
"provider": profile.ProviderKind,
"baseURL": profile.BaseURL,
"model": profile.Model,
"apiKey": profile.APIKey,
})
}
func providerModelCheck(profile config.ProviderProfile) Check {
if profile == (config.ProviderProfile{}) {
return check("provider.model", "Provider model", StatusWarn, "Model validity was skipped because provider config is unavailable.", nil)
}
registry, err := modelregistry.DefaultRegistry()
if err != nil {
return check("provider.model", "Provider model", StatusFail, "Model registry could not be loaded: "+err.Error(), nil)
}
model, err := registry.Require(profile.Model)
if err != nil {
if profile.ProviderKind == config.ProviderKindOpenAICompatible {
return check("provider.model", "Provider model", StatusWarn, "Custom OpenAI-compatible model was not found in the Zero registry; runtime will pass it through to the configured provider.", map[string]any{"model": profile.Model, "provider": providerName(profile)})
}
return check("provider.model", "Provider model", StatusFail, "Provider model is invalid: "+err.Error(), map[string]any{"model": profile.Model})
}
if !model.AllowsProvider(toModelProvider(profile)) {
return check("provider.model", "Provider model", StatusFail, fmt.Sprintf("Model %s is not available for provider %s.", model.ID, providerName(profile)), map[string]any{"model": model.ID, "provider": providerName(profile)})
}
return check("provider.model", "Provider model", StatusPass, fmt.Sprintf("Model %s resolves to %s.", model.ID, model.Provider), map[string]any{
"modelId": model.ID,
"apiModel": model.APIModel,
"provider": model.Provider,
"capabilities": model.Capabilities,
})
}
func connectivityCheck(profile config.ProviderProfile, enabled bool, modelStatus Status) Check {
if profile == (config.ProviderProfile{}) || modelStatus == StatusFail {
return check("provider.connectivity", "Provider connectivity", StatusWarn, "Connectivity check was skipped because provider runtime did not resolve.", nil)
}
if !enabled {
return check("provider.connectivity", "Provider connectivity", StatusWarn, "Connectivity probe skipped. Run `zero doctor --connectivity` to probe the provider endpoint.", map[string]any{"baseURL": profile.BaseURL})
}
return check("provider.connectivity", "Provider connectivity", StatusWarn, "Connectivity probing is not wired in the Go doctor backend yet.", map[string]any{"baseURL": profile.BaseURL})
}
func check(id string, label string, status Status, message string, details map[string]any) Check {
redacted := redaction.RedactValue(map[string]any{
"id": id,
"label": label,
"status": string(status),
"message": message,
"details": details,
}, redaction.Options{}).(map[string]any)
out := Check{
ID: redacted["id"].(string),
Label: redacted["label"].(string),
Status: Status(redacted["status"].(string)),
Message: redacted["message"].(string),
}
if detailsValue, ok := redacted["details"].(map[string]any); ok && len(detailsValue) > 0 {
out.Details = detailsValue
}
return out
}
func providerName(profile config.ProviderProfile) string {
if strings.TrimSpace(profile.Name) != "" {
return strings.TrimSpace(profile.Name)
}
if strings.TrimSpace(string(profile.ProviderKind)) != "" {
return strings.TrimSpace(string(profile.ProviderKind))
}
return strings.TrimSpace(profile.Provider)
}
func toModelProvider(profile config.ProviderProfile) modelregistry.ProviderKind {
switch profile.ProviderKind {
case config.ProviderKindAnthropic:
return modelregistry.ProviderAnthropic
case config.ProviderKindGoogle:
return modelregistry.ProviderGoogle
case config.ProviderKindOpenAICompatible:
return modelregistry.ProviderOpenAICompatible
default:
return modelregistry.ProviderOpenAI
}
}
func formatDetails(details map[string]any) string {
if len(details) == 0 {
return ""
}
keys := make([]string, 0, len(details))
for key := range details {
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(details))
for _, key := range keys {
value := details[key]
parts = append(parts, fmt.Sprintf("%s: %v", redaction.RedactString(key, redaction.Options{}), redaction.RedactValue(value, redaction.Options{})))
}
return strings.Join(parts, " | ")
}
func passFail(ok bool) string {
if ok {
return "pass"
}
return "fail"
}
-93
View File
@@ -1,93 +0,0 @@
package doctor
import (
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
)
func TestRunReportRedactsProviderSecretsAndWarnsWithoutConnectivity(t *testing.T) {
report := Run(Options{
Now: fixedDoctorClock("2026-06-04T15:00:00Z"),
Runtime: "go",
UserConfig: "missing",
Provider: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
APIKey: "sk-proj-secret1234567890",
Model: "gpt-4.1",
},
})
if !report.OK {
t.Fatalf("report should be ok when only connectivity is skipped: %#v", report)
}
if got := report.Check("provider.config"); got == nil || got.Status != StatusPass {
t.Fatalf("provider config check missing/pass expected: %#v", report.Checks)
}
formatted := Format(report)
if strings.Contains(formatted, "sk-proj-secret") {
t.Fatalf("formatted report leaked provider secret: %q", formatted)
}
if !strings.Contains(formatted, "[warn] provider.connectivity") {
t.Fatalf("expected skipped connectivity warning: %q", formatted)
}
}
func TestRunReportFailsInvalidModelAndMissingProvider(t *testing.T) {
missing := Run(Options{Now: fixedDoctorClock("2026-06-04T15:30:00Z"), Runtime: "go"})
if missing.OK {
t.Fatalf("missing provider should fail: %#v", missing)
}
if check := missing.Check("provider.config"); check == nil || check.Status != StatusFail {
t.Fatalf("expected provider config failure: %#v", missing.Checks)
}
invalid := Run(Options{
Now: fixedDoctorClock("2026-06-04T15:30:00Z"),
Runtime: "go",
Provider: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
Model: "not-a-zero-model",
},
})
if invalid.OK {
t.Fatalf("invalid model should fail: %#v", invalid)
}
if check := invalid.Check("provider.model"); check == nil || check.Status != StatusFail || !strings.Contains(check.Message, "unknown Zero model") {
t.Fatalf("expected model failure: %#v", invalid.Checks)
}
}
func TestRunReportWarnsForUnknownOpenAICompatibleModel(t *testing.T) {
report := Run(Options{
Now: fixedDoctorClock("2026-06-04T15:45:00Z"),
Runtime: "go",
Provider: config.ProviderProfile{
Name: "local",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "http://127.0.0.1:11434/v1",
Model: "local-custom-model",
},
})
if !report.OK {
t.Fatalf("unknown custom model should warn, not fail: %#v", report)
}
if check := report.Check("provider.model"); check == nil || check.Status != StatusWarn || !strings.Contains(check.Message, "pass it through") {
t.Fatalf("expected custom model warning: %#v", report.Checks)
}
}
func fixedDoctorClock(value string) func() time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return func() time.Time { return parsed }
}
-204
View File
@@ -1,204 +0,0 @@
package modelregistry
import "fmt"
const DefaultModelID = "gpt-4.1"
const sourceLastVerified = "2026-06-04"
const (
openAIPricingSource = "https://platform.openai.com/docs/pricing/"
anthropicPricingSource = "https://platform.claude.com/docs/en/about-claude/pricing"
googlePricingSource = "https://ai.google.dev/gemini-api/docs/pricing"
)
type ListOptions struct {
IncludeDeprecated bool
Provider ProviderKind
Capability ModelCapability
}
func DefaultRegistry() (Registry, error) {
return NewRegistry(DefaultModelEntries())
}
func DefaultModelEntries() []ModelEntry {
entries := []ModelEntry{
openAIModel("gpt-4.1", "GPT-4.1", "gpt-4.1", ModelStatusActive, []string{"openai:gpt-4.1"}, ContextLimits{ContextWindow: 1_047_576, MaxOutputTokens: 16_384}, ModelCost{InputPerMillion: 2, CachedInputPerMillion: 0.5, OutputPerMillion: 8}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityLongContext}, "OpenAI stable long-context model for general coding sessions."),
openAIModel("gpt-4.1-mini", "GPT-4.1 mini", "gpt-4.1-mini", ModelStatusActive, []string{"openai:gpt-4.1-mini"}, ContextLimits{ContextWindow: 1_047_576, MaxOutputTokens: 32_768}, ModelCost{InputPerMillion: 0.4, CachedInputPerMillion: 0.1, OutputPerMillion: 1.6}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityLongContext}, "OpenAI lower-cost long-context model for frequent edit loops."),
openAIModel("gpt-4.1-nano", "GPT-4.1 nano", "gpt-4.1-nano", ModelStatusActive, []string{"openai:gpt-4.1-nano"}, ContextLimits{ContextWindow: 1_047_576, MaxOutputTokens: 32_768}, ModelCost{InputPerMillion: 0.1, CachedInputPerMillion: 0.025, OutputPerMillion: 0.4}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityLongContext}, "OpenAI smallest GPT-4.1 model for routing, summaries, and light checks."),
openAIModel("gpt-4o", "GPT-4o", "gpt-4o", ModelStatusActive, []string{"openai:gpt-4o"}, ContextLimits{ContextWindow: 128_000, MaxOutputTokens: 16_384}, ModelCost{InputPerMillion: 2.5, CachedInputPerMillion: 1.25, OutputPerMillion: 10}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode}, "OpenAI multimodal model kept for compatibility with existing configs."),
openAIModel("gpt-4o-mini", "GPT-4o mini", "gpt-4o-mini", ModelStatusActive, []string{"openai:gpt-4o-mini"}, ContextLimits{ContextWindow: 128_000, MaxOutputTokens: 16_384}, ModelCost{InputPerMillion: 0.15, CachedInputPerMillion: 0.075, OutputPerMillion: 0.6}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode}, "OpenAI low-cost multimodal model for lightweight sessions."),
openAIModel("gpt-4-turbo", "GPT-4 Turbo", "gpt-4-turbo", ModelStatusDeprecated, []string{"openai:gpt-4-turbo"}, ContextLimits{ContextWindow: 128_000, MaxOutputTokens: 4_096}, ModelCost{InputPerMillion: 10, OutputPerMillion: 30}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode}, "Deprecated OpenAI model retained for config migration and historical usage display."),
anthropicModel("claude-opus-4.1", "Claude Opus 4.1", "claude-opus-4-1-20250805", ModelStatusActive, []string{"anthropic:claude-opus-4.1", "opus-4.1"}, ContextLimits{ContextWindow: 200_000, MaxOutputTokens: 32_000}, ModelCost{InputPerMillion: 15, CachedInputPerMillion: 1.5, OutputPerMillion: 75}, []ModelCapability{ModelCapabilityVision, ModelCapabilityReasoning, ModelCapabilityPromptCache}, standardReasoningEfforts(), "Anthropic high-capability Opus model for deep coding and planning."),
anthropicModel("claude-sonnet-4.5", "Claude Sonnet 4.5", "claude-sonnet-4-5-20250929", ModelStatusActive, []string{"anthropic:claude-sonnet-4.5", "sonnet-4.5"}, ContextLimits{ContextWindow: 200_000, MaxOutputTokens: 64_000}, ModelCost{InputPerMillion: 3, CachedInputPerMillion: 0.3, OutputPerMillion: 15}, []ModelCapability{ModelCapabilityVision, ModelCapabilityReasoning, ModelCapabilityPromptCache}, standardReasoningEfforts(), "Anthropic balanced coding model for high-quality daily agent work."),
anthropicModel("claude-haiku-4.5", "Claude Haiku 4.5", "claude-haiku-4-5-20251001", ModelStatusActive, []string{"anthropic:claude-haiku-4.5", "haiku-4.5"}, ContextLimits{ContextWindow: 200_000, MaxOutputTokens: 64_000}, ModelCost{InputPerMillion: 1, CachedInputPerMillion: 0.1, OutputPerMillion: 5}, []ModelCapability{ModelCapabilityVision, ModelCapabilityReasoning, ModelCapabilityPromptCache}, standardReasoningEfforts(), "Anthropic fast model for lightweight coding support and summaries."),
anthropicModel("claude-haiku-3.5", "Claude Haiku 3.5", "claude-3-5-haiku-20241022", ModelStatusDeprecated, []string{"anthropic:claude-haiku-3.5", "haiku-3.5"}, ContextLimits{ContextWindow: 200_000, MaxOutputTokens: 8_192}, ModelCost{InputPerMillion: 0.8, CachedInputPerMillion: 0.08, OutputPerMillion: 4}, []ModelCapability{ModelCapabilityVision, ModelCapabilityPromptCache}, nil, "Retired Anthropic Haiku model retained for migration and historical usage display."),
googleModel("gemini-2.5-pro", "Gemini 2.5 Pro", "gemini-2.5-pro", ModelStatusActive, []string{"google:gemini-2.5-pro", "gemini-pro"}, ContextLimits{ContextWindow: 1_048_576, MaxOutputTokens: 65_536}, ModelCost{Tiers: []ModelCostTier{{UpToInputTokens: 200_000, InputPerMillion: 1.25, CachedInputPerMillion: 0.125, OutputPerMillion: 10, Note: "Prompts up to 200k tokens."}, {InputPerMillion: 2.5, CachedInputPerMillion: 0.25, OutputPerMillion: 15, Note: "Prompts above 200k tokens."}}}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityReasoning, ModelCapabilityLongContext}, standardReasoningEfforts(), "Google general-purpose Pro model with tiered long-context pricing."),
googleModel("gemini-2.5-flash", "Gemini 2.5 Flash", "gemini-2.5-flash", ModelStatusActive, []string{"google:gemini-2.5-flash", "gemini-flash"}, ContextLimits{ContextWindow: 1_048_576, MaxOutputTokens: 65_536}, ModelCost{InputPerMillion: 0.3, CachedInputPerMillion: 0.03, OutputPerMillion: 2.5}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityReasoning, ModelCapabilityLongContext}, standardReasoningEfforts(), "Google Flash model for low-latency coding interactions."),
googleModel("gemini-2.5-flash-lite", "Gemini 2.5 Flash-Lite", "gemini-2.5-flash-lite", ModelStatusActive, []string{"google:gemini-2.5-flash-lite", "gemini-flash-lite"}, ContextLimits{ContextWindow: 1_048_576, MaxOutputTokens: 65_536}, ModelCost{InputPerMillion: 0.1, CachedInputPerMillion: 0.01, OutputPerMillion: 0.4}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityReasoning, ModelCapabilityLongContext}, standardReasoningEfforts(), "Google low-cost Flash model for background routing and summaries."),
}
return cloneModelEntries(entries)
}
func (registry Registry) List(options ListOptions) []ModelEntry {
models := make([]ModelEntry, 0, len(registry.models))
for _, model := range registry.models {
if !options.IncludeDeprecated && model.Status == ModelStatusDeprecated {
continue
}
if options.Provider != "" && model.Provider != options.Provider {
continue
}
if options.Capability != "" && !model.Supports(options.Capability) {
continue
}
models = append(models, cloneModelEntry(model))
}
return models
}
func (registry Registry) ListByProvider(provider ProviderKind) []ModelEntry {
return registry.List(ListOptions{Provider: provider})
}
func (registry Registry) ListByCapability(capability ModelCapability) []ModelEntry {
return registry.List(ListOptions{Capability: capability})
}
func (registry Registry) ResolveID(pattern string) (string, bool) {
model, ok := registry.Get(pattern)
if !ok {
return "", false
}
return model.ID, true
}
func (registry Registry) Require(pattern string) (ModelEntry, error) {
model, ok := registry.Get(pattern)
if !ok {
return ModelEntry{}, fmt.Errorf("unknown Zero model %q", pattern)
}
return model, nil
}
func (registry Registry) SupportsCapability(pattern string, capability ModelCapability) bool {
model, ok := registry.Get(pattern)
return ok && model.Supports(capability)
}
func (registry Registry) ReasoningEfforts(pattern string) []ReasoningEffort {
model, ok := registry.Get(pattern)
if !ok {
return nil
}
return append([]ReasoningEffort{}, model.ReasoningEfforts...)
}
func (registry Registry) RequireProvider(pattern string, provider ProviderKind) (ModelEntry, error) {
model, err := registry.Require(pattern)
if err != nil {
return ModelEntry{}, err
}
if model.Provider != provider {
return ModelEntry{}, fmt.Errorf("zero model %s belongs to %s, not %s", model.ID, model.Provider, provider)
}
return model, nil
}
func openAIModel(id string, displayName string, apiModel string, status ModelStatus, aliases []string, limits ContextLimits, cost ModelCost, extraCapabilities []ModelCapability, description string) ModelEntry {
cost.Currency = "USD"
cost.Unit = "per_1m_tokens"
cost.Source = openAIPricingSource
cost.SourceLastVerified = sourceLastVerified
return ModelEntry{
ID: id,
DisplayName: displayName,
APIModel: apiModel,
Provider: ProviderOpenAI,
APIProviders: []ProviderKind{ProviderOpenAI, ProviderOpenAICompatible},
ContextLimits: limits,
Capabilities: withBaseCapabilities(extraCapabilities...),
Cost: cost,
Status: status,
Aliases: aliases,
Description: description,
}
}
func anthropicModel(id string, displayName string, apiModel string, status ModelStatus, aliases []string, limits ContextLimits, cost ModelCost, extraCapabilities []ModelCapability, efforts []ReasoningEffort, description string) ModelEntry {
cost.Currency = "USD"
cost.Unit = "per_1m_tokens"
cost.Source = anthropicPricingSource
cost.SourceLastVerified = sourceLastVerified
cost.Notes = append(cost.Notes, "Claude cached input pricing models cache hits and refreshes.")
return ModelEntry{
ID: id,
DisplayName: displayName,
APIModel: apiModel,
Provider: ProviderAnthropic,
ContextLimits: limits,
ReasoningEfforts: efforts,
Capabilities: withBaseCapabilities(extraCapabilities...),
Cost: cost,
Status: status,
Aliases: aliases,
Description: description,
}
}
func googleModel(id string, displayName string, apiModel string, status ModelStatus, aliases []string, limits ContextLimits, cost ModelCost, extraCapabilities []ModelCapability, efforts []ReasoningEffort, description string) ModelEntry {
cost.Currency = "USD"
cost.Unit = "per_1m_tokens"
cost.Source = googlePricingSource
cost.SourceLastVerified = sourceLastVerified
return ModelEntry{
ID: id,
DisplayName: displayName,
APIModel: apiModel,
Provider: ProviderGoogle,
ContextLimits: limits,
ReasoningEfforts: efforts,
Capabilities: withBaseCapabilities(extraCapabilities...),
Cost: cost,
Status: status,
Aliases: aliases,
Description: description,
}
}
func withBaseCapabilities(extra ...ModelCapability) []ModelCapability {
capabilities := []ModelCapability{
ModelCapabilityChat,
ModelCapabilityStreaming,
ModelCapabilityToolCalling,
ModelCapabilitySystemPrompt,
}
return append(capabilities, extra...)
}
func standardReasoningEfforts() []ReasoningEffort {
return []ReasoningEffort{
ReasoningEffortLow,
ReasoningEffortMedium,
ReasoningEffortHigh,
}
}
func cloneModelEntries(entries []ModelEntry) []ModelEntry {
cloned := make([]ModelEntry, len(entries))
for index, entry := range entries {
cloned[index] = cloneModelEntry(entry)
}
return cloned
}
func cloneModelEntry(entry ModelEntry) ModelEntry {
entry.APIProviders = append([]ProviderKind{}, entry.APIProviders...)
entry.ReasoningEfforts = append([]ReasoningEffort{}, entry.ReasoningEfforts...)
entry.Capabilities = append([]ModelCapability{}, entry.Capabilities...)
entry.Aliases = append([]string{}, entry.Aliases...)
entry.Cost.Tiers = append([]ModelCostTier{}, entry.Cost.Tiers...)
entry.Cost.Notes = append([]string{}, entry.Cost.Notes...)
return entry
}
-156
View File
@@ -1,156 +0,0 @@
package modelregistry
import (
"strings"
"testing"
)
func TestDefaultRegistryCoversM1ModelCatalog(t *testing.T) {
registry, err := DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry returned error: %v", err)
}
models := registry.List(ListOptions{})
if len(models) < 10 {
t.Fatalf("default registry has %d active/preview models, want at least 10", len(models))
}
for _, model := range models {
if model.Status == ModelStatusDeprecated {
t.Fatalf("List without include deprecated returned deprecated model %q", model.ID)
}
if model.ID == "" || model.DisplayName == "" || model.APIModel == "" {
t.Fatalf("model should expose complete identity metadata: %#v", model)
}
if model.ContextLimits.ContextWindow <= 0 || model.ContextLimits.MaxOutputTokens <= 0 {
t.Fatalf("model should expose context limits: %#v", model)
}
if !model.Supports(ModelCapabilityChat) || !model.Supports(ModelCapabilityStreaming) {
t.Fatalf("model %q should expose chat and streaming capabilities", model.ID)
}
if model.Cost.Currency != "USD" || model.Cost.Unit != "per_1m_tokens" {
t.Fatalf("model %q should expose USD token pricing: %#v", model.ID, model.Cost)
}
if !strings.HasPrefix(model.Cost.Source, "https://") {
t.Fatalf("model %q should expose source URL: %#v", model.ID, model.Cost)
}
}
providers := map[ProviderKind]bool{}
for _, model := range models {
providers[model.Provider] = true
}
for _, provider := range []ProviderKind{ProviderOpenAI, ProviderAnthropic, ProviderGoogle} {
if !providers[provider] {
t.Fatalf("default registry missing provider %q", provider)
}
}
if _, err := registry.Require("gpt-4-turbo"); err != nil {
t.Fatalf("deprecated models should remain resolvable for migration/history: %v", err)
}
if !containsModelID(registry.List(ListOptions{IncludeDeprecated: true}), "gpt-4-turbo") {
t.Fatal("include deprecated list should retain migration metadata")
}
if DefaultModelID != "gpt-4.1" {
t.Fatalf("DefaultModelID = %q, want gpt-4.1", DefaultModelID)
}
haiku, err := registry.Require("claude-haiku-4.5")
if err != nil {
t.Fatalf("claude-haiku-4.5 should be resolvable: %v", err)
}
if haiku.APIModel != "claude-haiku-4-5-20251001" {
t.Fatalf("claude-haiku-4.5 API model = %q, want claude-haiku-4-5-20251001", haiku.APIModel)
}
if haiku.ContextLimits.ContextWindow != 200_000 || haiku.ContextLimits.MaxOutputTokens != 64_000 {
t.Fatalf("claude-haiku-4.5 limits = %#v, want 200k context and 64k max output", haiku.ContextLimits)
}
}
func TestDefaultRegistryResolvesAliasesAndStableFilters(t *testing.T) {
registry, err := DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry returned error: %v", err)
}
if id, ok := registry.ResolveID(" OPENAI:GPT-4.1 "); !ok || id != "gpt-4.1" {
t.Fatalf("ResolveID(openai alias) = %q/%v, want gpt-4.1/true", id, ok)
}
if id, ok := registry.ResolveID("sonnet-4.5"); !ok || id != "claude-sonnet-4.5" {
t.Fatalf("ResolveID(sonnet alias) = %q/%v, want claude-sonnet-4.5/true", id, ok)
}
if id, ok := registry.ResolveID("gemini-flash"); !ok || id != "gemini-2.5-flash" {
t.Fatalf("ResolveID(gemini alias) = %q/%v, want gemini-2.5-flash/true", id, ok)
}
if _, ok := registry.ResolveID("gpt-latest"); ok {
t.Fatal("registry should not expose speculative latest aliases")
}
for _, model := range registry.ListByProvider(ProviderOpenAI) {
if model.Provider != ProviderOpenAI {
t.Fatalf("ListByProvider(openai) returned %q", model.Provider)
}
}
visionModels := registry.ListByCapability(ModelCapabilityVision)
if len(visionModels) == 0 {
t.Fatal("expected at least one vision model")
}
for _, model := range visionModels {
if !model.Supports(ModelCapabilityVision) {
t.Fatalf("ListByCapability(vision) returned model without vision: %#v", model)
}
}
if !registry.SupportsCapability("gemini-2.5-pro", ModelCapabilityReasoning) {
t.Fatal("gemini-2.5-pro should expose reasoning capability")
}
}
func TestDefaultRegistryReasoningAndProviderAssertions(t *testing.T) {
registry, err := DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry returned error: %v", err)
}
efforts := registry.ReasoningEfforts("gemini-2.5-pro")
wantEfforts := []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}
if len(efforts) != len(wantEfforts) {
t.Fatalf("ReasoningEfforts length = %d, want %d: %#v", len(efforts), len(wantEfforts), efforts)
}
for index, effort := range wantEfforts {
if efforts[index] != effort {
t.Fatalf("ReasoningEfforts[%d] = %q, want %q", index, efforts[index], effort)
}
}
if efforts := registry.ReasoningEfforts("gpt-4o"); len(efforts) != 0 {
t.Fatalf("gpt-4o reasoning efforts = %#v, want empty", efforts)
}
model, err := registry.RequireProvider("gpt-4.1", ProviderOpenAI)
if err != nil {
t.Fatalf("RequireProvider(openai) returned error: %v", err)
}
if model.ID != "gpt-4.1" {
t.Fatalf("RequireProvider returned %q, want gpt-4.1", model.ID)
}
if _, err := registry.RequireProvider("gpt-4.1", ProviderAnthropic); err == nil {
t.Fatal("RequireProvider should reject provider mismatches")
} else if !strings.Contains(err.Error(), "belongs to openai") {
t.Fatalf("provider mismatch error = %q, want belongs to openai", err.Error())
}
if _, err := registry.Require("unknown-model"); err == nil {
t.Fatal("Require should reject unknown models")
} else if !strings.Contains(err.Error(), "unknown Zero model") {
t.Fatalf("unknown model error = %q, want unknown Zero model", err.Error())
}
}
func containsModelID(models []ModelEntry, id string) bool {
for _, model := range models {
if model.ID == id {
return true
}
}
return false
}
-172
View File
@@ -1,172 +0,0 @@
package modelregistry
import (
"fmt"
"math"
"sort"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const tokensPerMillion = 1_000_000
type CostBreakdown struct {
ModelID string
Provider ProviderKind
Currency string
InputTokens int
CachedInputTokens int
OutputTokens int
ReasoningTokens int
InputCost float64
CachedInputCost float64
OutputCost float64
TotalCost float64
PricingTier *ModelCostTier
}
func (registry Registry) EstimateCost(pattern string, usage zeroruntime.Usage) (CostBreakdown, error) {
model, err := registry.Require(pattern)
if err != nil {
return CostBreakdown{}, err
}
return CalculateCost(model, usage)
}
func CalculateCost(model ModelEntry, usage zeroruntime.Usage) (CostBreakdown, error) {
inputTokens, err := nonNegativeUsage(usage.EffectiveInputTokens(), "input tokens")
if err != nil {
return CostBreakdown{}, err
}
outputTokens, err := nonNegativeUsage(usage.EffectiveOutputTokens(), "output tokens")
if err != nil {
return CostBreakdown{}, err
}
reasoningTokens, err := nonNegativeUsage(usage.ReasoningTokens, "reasoning tokens")
if err != nil {
return CostBreakdown{}, err
}
requestedCachedInputTokens, err := nonNegativeUsage(usage.CachedInputTokens, "cached input tokens")
if err != nil {
return CostBreakdown{}, err
}
if requestedCachedInputTokens > inputTokens {
requestedCachedInputTokens = inputTokens
}
tier, err := selectCostTier(model.Cost, inputTokens)
if err != nil {
return CostBreakdown{}, err
}
inputRate, outputRate, cachedRate, err := costRates(model.Cost, tier)
if err != nil {
return CostBreakdown{}, err
}
cachedInputTokens := 0
if cachedRate > 0 {
cachedInputTokens = requestedCachedInputTokens
}
uncachedInputTokens := inputTokens - cachedInputTokens
billableOutputTokens := outputTokens + reasoningTokens
inputCost := costForTokens(uncachedInputTokens, inputRate)
cachedInputCost := costForTokens(cachedInputTokens, cachedRate)
outputCost := costForTokens(billableOutputTokens, outputRate)
breakdown := CostBreakdown{
ModelID: model.ID,
Provider: model.Provider,
Currency: model.Cost.Currency,
InputTokens: inputTokens,
CachedInputTokens: cachedInputTokens,
OutputTokens: outputTokens,
ReasoningTokens: reasoningTokens,
InputCost: inputCost,
CachedInputCost: cachedInputCost,
OutputCost: outputCost,
TotalCost: inputCost + cachedInputCost + outputCost,
}
if tier != nil {
tierCopy := *tier
breakdown.PricingTier = &tierCopy
}
return breakdown, nil
}
func FormatCostUSD(cost float64) (string, error) {
if math.IsNaN(cost) || math.IsInf(cost, 0) || cost < 0 {
return "", fmt.Errorf("invalid model cost: %v", cost)
}
if cost > 0 && cost < 0.01 {
return fmt.Sprintf("$%.6f", cost), nil
}
return fmt.Sprintf("$%.4f", cost), nil
}
func selectCostTier(cost ModelCost, inputTokens int) (*ModelCostTier, error) {
if len(cost.Tiers) == 0 {
return nil, nil
}
tiers := append([]ModelCostTier{}, cost.Tiers...)
sort.SliceStable(tiers, func(left int, right int) bool {
leftBound := tiers[left].UpToInputTokens
rightBound := tiers[right].UpToInputTokens
if leftBound == 0 {
return false
}
if rightBound == 0 {
return true
}
return leftBound < rightBound
})
for _, tier := range tiers {
if tier.UpToInputTokens > 0 && inputTokens <= tier.UpToInputTokens {
return &tier, nil
}
}
for _, tier := range tiers {
if tier.UpToInputTokens == 0 {
return &tier, nil
}
}
return nil, fmt.Errorf("no model cost tier covers %d input tokens", inputTokens)
}
func costRates(cost ModelCost, tier *ModelCostTier) (float64, float64, float64, error) {
inputRate := cost.InputPerMillion
outputRate := cost.OutputPerMillion
cachedRate := cost.CachedInputPerMillion
if tier != nil {
inputRate = tier.InputPerMillion
outputRate = tier.OutputPerMillion
cachedRate = tier.CachedInputPerMillion
}
if !validRate(inputRate) || inputRate == 0 {
return 0, 0, 0, fmt.Errorf("missing model input pricing rate")
}
if !validRate(outputRate) || outputRate == 0 {
return 0, 0, 0, fmt.Errorf("missing model output pricing rate")
}
if !validRate(cachedRate) {
return 0, 0, 0, fmt.Errorf("invalid model cached input pricing rate")
}
return inputRate, outputRate, cachedRate, nil
}
func costForTokens(tokens int, perMillionRate float64) float64 {
return (float64(tokens) / tokensPerMillion) * perMillionRate
}
func nonNegativeUsage(value int, label string) (int, error) {
if value < 0 {
return 0, fmt.Errorf("%s must be non-negative", label)
}
return value, nil
}
func validRate(rate float64) bool {
return !math.IsNaN(rate) && !math.IsInf(rate, 0) && rate >= 0
}
-171
View File
@@ -1,171 +0,0 @@
package modelregistry
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestRegistryEstimatesCostFromNormalizedUsage(t *testing.T) {
registry, err := DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry returned error: %v", err)
}
cost, err := registry.EstimateCost("gpt-4.1", zeroruntime.Usage{
InputTokens: 1_000_000,
CachedInputTokens: 100_000,
OutputTokens: 500_000,
})
if err != nil {
t.Fatalf("EstimateCost returned error: %v", err)
}
if cost.ModelID != "gpt-4.1" || cost.Provider != ProviderOpenAI {
t.Fatalf("cost identity = %#v, want gpt-4.1/openai", cost)
}
assertClose(t, cost.InputCost, 1.8)
assertClose(t, cost.CachedInputCost, 0.05)
assertClose(t, cost.OutputCost, 4)
assertClose(t, cost.TotalCost, 5.85)
}
func TestRegistryCostSupportsAliasesAndFullyCachedInput(t *testing.T) {
registry, err := DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry returned error: %v", err)
}
cost, err := registry.EstimateCost("openai:gpt-4.1-mini", zeroruntime.Usage{
InputTokens: 1_000_000,
CachedInputTokens: 1_000_000,
})
if err != nil {
t.Fatalf("EstimateCost returned error: %v", err)
}
if cost.InputCost != 0 {
t.Fatalf("InputCost = %v, want 0 for fully cached input", cost.InputCost)
}
assertClose(t, cost.CachedInputCost, 0.1)
assertClose(t, cost.TotalCost, 0.1)
}
func TestRegistryCostUsesPromptAndCompletionAliases(t *testing.T) {
registry, err := DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry returned error: %v", err)
}
cost, err := registry.EstimateCost("haiku-3.5", zeroruntime.Usage{
PromptTokens: 2_000,
CompletionTokens: 1_000,
})
if err != nil {
t.Fatalf("EstimateCost returned error: %v", err)
}
if cost.InputTokens != 2_000 || cost.OutputTokens != 1_000 {
t.Fatalf("usage = %#v, want prompt/completion aliases", cost)
}
assertClose(t, cost.TotalCost, 0.0056)
}
func TestRegistryCostIgnoresCachedInputWithoutCachePricing(t *testing.T) {
registry, err := DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry returned error: %v", err)
}
cost, err := registry.EstimateCost("gpt-4-turbo", zeroruntime.Usage{
InputTokens: 1_000,
CachedInputTokens: 1_000,
OutputTokens: 1_000,
})
if err != nil {
t.Fatalf("EstimateCost returned error: %v", err)
}
if cost.CachedInputTokens != 0 || cost.CachedInputCost != 0 {
t.Fatalf("cached input should be ignored for uncached model pricing: %#v", cost)
}
assertClose(t, cost.InputCost, 0.01)
assertClose(t, cost.OutputCost, 0.03)
}
func TestRegistryCostSelectsTierForLongContextPricing(t *testing.T) {
registry, err := DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry returned error: %v", err)
}
shortPrompt, err := registry.EstimateCost("gemini-2.5-pro", zeroruntime.Usage{
InputTokens: 200_000,
OutputTokens: 1_000,
})
if err != nil {
t.Fatalf("EstimateCost(shortPrompt) returned error: %v", err)
}
longPrompt, err := registry.EstimateCost("gemini-2.5-pro", zeroruntime.Usage{
InputTokens: 200_001,
OutputTokens: 1_000,
})
if err != nil {
t.Fatalf("EstimateCost(longPrompt) returned error: %v", err)
}
if shortPrompt.PricingTier == nil || shortPrompt.PricingTier.InputPerMillion != 1.25 {
t.Fatalf("short tier = %#v, want input rate 1.25", shortPrompt.PricingTier)
}
if longPrompt.PricingTier == nil || longPrompt.PricingTier.InputPerMillion != 2.5 {
t.Fatalf("long tier = %#v, want input rate 2.5", longPrompt.PricingTier)
}
if longPrompt.TotalCost <= shortPrompt.TotalCost {
t.Fatalf("long prompt cost %v should be greater than short prompt cost %v", longPrompt.TotalCost, shortPrompt.TotalCost)
}
}
func TestCostFormattingAndValidation(t *testing.T) {
got, err := FormatCostUSD(0.000123)
if err != nil {
t.Fatalf("FormatCostUSD small returned error: %v", err)
}
if got != "$0.000123" {
t.Fatalf("FormatCostUSD small = %q, want $0.000123", got)
}
got, err = FormatCostUSD(1.23456)
if err != nil {
t.Fatalf("FormatCostUSD regular returned error: %v", err)
}
if got != "$1.2346" {
t.Fatalf("FormatCostUSD regular = %q, want $1.2346", got)
}
if _, err := FormatCostUSD(-1); err == nil {
t.Fatal("FormatCostUSD should reject negative values")
}
registry, err := DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry returned error: %v", err)
}
_, err = registry.EstimateCost("gpt-4.1", zeroruntime.Usage{InputTokens: -1})
if err == nil {
t.Fatal("EstimateCost should reject negative usage")
}
if !strings.Contains(err.Error(), "input tokens") {
t.Fatalf("negative usage error = %q, want input tokens", err.Error())
}
}
func assertClose(t *testing.T, got float64, want float64) {
t.Helper()
diff := got - want
if diff < 0 {
diff = -diff
}
if diff > 0.0000001 {
t.Fatalf("got %v, want %v", got, want)
}
}
-335
View File
@@ -1,335 +0,0 @@
package modelregistry
import (
"fmt"
"strings"
"time"
)
type ProviderKind string
const (
ProviderOpenAI ProviderKind = "openai"
ProviderAnthropic ProviderKind = "anthropic"
ProviderGoogle ProviderKind = "google"
ProviderOpenAICompatible ProviderKind = "openai-compatible"
)
type ReasoningEffort string
const (
ReasoningEffortNone ReasoningEffort = "none"
ReasoningEffortMinimal ReasoningEffort = "minimal"
ReasoningEffortLow ReasoningEffort = "low"
ReasoningEffortMedium ReasoningEffort = "medium"
ReasoningEffortHigh ReasoningEffort = "high"
ReasoningEffortXHigh ReasoningEffort = "xhigh"
)
type ModelCapability string
const (
ModelCapabilityChat ModelCapability = "chat"
ModelCapabilityStreaming ModelCapability = "streaming"
ModelCapabilityToolCalling ModelCapability = "tool-calling"
ModelCapabilityVision ModelCapability = "vision"
ModelCapabilityJSONMode ModelCapability = "json-mode"
ModelCapabilityReasoning ModelCapability = "reasoning"
ModelCapabilitySystemPrompt ModelCapability = "system-prompt"
ModelCapabilityPromptCache ModelCapability = "prompt-cache"
ModelCapabilityLongContext ModelCapability = "long-context"
)
type ModelCapabilities []ModelCapability
type ModelStatus string
const (
ModelStatusActive ModelStatus = "active"
ModelStatusPreview ModelStatus = "preview"
ModelStatusDeprecated ModelStatus = "deprecated"
)
type ContextLimits struct {
ContextWindow int
MaxOutputTokens int
}
type ModelCost struct {
Currency string
Unit string
InputPerMillion float64
OutputPerMillion float64
CachedInputPerMillion float64
Tiers []ModelCostTier
Source string
SourceLastVerified string
Notes []string
}
type ModelCostTier struct {
UpToInputTokens int
InputPerMillion float64
OutputPerMillion float64
CachedInputPerMillion float64
Note string
}
type ModelEntry struct {
ID string
DisplayName string
APIModel string
Provider ProviderKind
APIProviders []ProviderKind
ContextLimits ContextLimits
ReasoningEfforts []ReasoningEffort
Capabilities ModelCapabilities
Cost ModelCost
Status ModelStatus
Aliases []string
Description string
}
func (model ModelEntry) Validate() error {
if strings.TrimSpace(model.ID) == "" {
return fmt.Errorf("model id is required")
}
if strings.TrimSpace(model.DisplayName) == "" {
return fmt.Errorf("model display name is required")
}
if strings.TrimSpace(model.APIModel) == "" {
return fmt.Errorf("api model is required")
}
if !ValidPrimaryProviderKind(model.Provider) {
return fmt.Errorf("unknown primary provider %q", model.Provider)
}
if model.ContextLimits.ContextWindow <= 0 {
return fmt.Errorf("context window must be positive")
}
if model.ContextLimits.MaxOutputTokens <= 0 {
return fmt.Errorf("max output tokens must be positive")
}
if model.ContextLimits.MaxOutputTokens > model.ContextLimits.ContextWindow {
return fmt.Errorf("max output tokens cannot exceed context window")
}
if len(model.Capabilities) == 0 {
return fmt.Errorf("at least one model capability is required")
}
for _, capability := range model.Capabilities {
if !ValidModelCapability(capability) {
return fmt.Errorf("unknown model capability %q", capability)
}
}
for _, effort := range model.ReasoningEfforts {
if !ValidReasoningEffort(effort) {
return fmt.Errorf("unknown reasoning effort %q", effort)
}
}
if err := model.Cost.Validate(); err != nil {
return err
}
if !ValidModelStatus(model.Status) {
return fmt.Errorf("unknown model status %q", model.Status)
}
if len(model.Aliases) == 0 {
return fmt.Errorf("at least one model alias is required")
}
for _, alias := range model.Aliases {
if strings.TrimSpace(alias) == "" {
return fmt.Errorf("model aliases cannot be blank")
}
}
for _, provider := range model.APIProviders {
if !ValidRuntimeProviderKind(provider) {
return fmt.Errorf("unknown api provider %q", provider)
}
}
if len(model.APIProviders) > 0 && !model.AllowsProvider(model.Provider) {
return fmt.Errorf("primary provider %q not allowed by api providers", model.Provider)
}
return nil
}
func (cost ModelCost) Validate() error {
if cost.Currency != "USD" {
return fmt.Errorf("model cost currency must be USD")
}
if cost.Unit != "per_1m_tokens" {
return fmt.Errorf("model cost unit must be per_1m_tokens")
}
if cost.InputPerMillion < 0 || cost.OutputPerMillion < 0 || cost.CachedInputPerMillion < 0 {
return fmt.Errorf("model cost values must be non-negative")
}
if len(cost.Tiers) == 0 {
if cost.InputPerMillion == 0 && cost.OutputPerMillion == 0 {
return fmt.Errorf("model cost must include base pricing or pricing tiers")
}
if cost.InputPerMillion == 0 || cost.OutputPerMillion == 0 {
return fmt.Errorf("model cost base input and output rates must be positive")
}
}
if err := validateCostTiers(cost.Tiers); err != nil {
return err
}
if strings.TrimSpace(cost.Source) == "" {
return fmt.Errorf("model cost source is required")
}
sourceLastVerified := strings.TrimSpace(cost.SourceLastVerified)
if sourceLastVerified == "" {
return fmt.Errorf("model cost source last verified date is required")
}
if _, err := time.Parse("2006-01-02", sourceLastVerified); err != nil {
return fmt.Errorf("model cost source last verified date must use YYYY-MM-DD format")
}
return nil
}
func validateCostTiers(tiers []ModelCostTier) error {
seenFallback := false
for index, tier := range tiers {
if tier.UpToInputTokens < 0 {
return fmt.Errorf("model cost tier input ceiling must be non-negative")
}
if tier.InputPerMillion <= 0 || tier.OutputPerMillion <= 0 {
return fmt.Errorf("model cost tier input and output rates must be positive")
}
if tier.CachedInputPerMillion < 0 {
return fmt.Errorf("model cost tier cached input rate must be non-negative")
}
if tier.UpToInputTokens == 0 {
if seenFallback {
return fmt.Errorf("model cost tiers can include only one fallback tier")
}
if index != len(tiers)-1 {
return fmt.Errorf("model cost fallback tier must be last")
}
seenFallback = true
}
}
return nil
}
func (model ModelEntry) Supports(capability ModelCapability) bool {
for _, candidate := range model.Capabilities {
if candidate == capability {
return true
}
}
return false
}
func (model ModelEntry) AllowsProvider(provider ProviderKind) bool {
if len(model.APIProviders) == 0 {
return model.Provider == provider
}
for _, candidate := range model.APIProviders {
if candidate == provider {
return true
}
}
return false
}
type Registry struct {
entries map[string]ModelEntry
models []ModelEntry
}
func NewRegistry(entries []ModelEntry) (Registry, error) {
registry := Registry{
entries: make(map[string]ModelEntry),
models: make([]ModelEntry, 0, len(entries)),
}
seenModelIDs := make(map[string]struct{})
for _, entry := range entries {
if err := entry.Validate(); err != nil {
return Registry{}, fmt.Errorf("invalid model %q: %w", entry.ID, err)
}
clonedEntry := cloneModelEntry(entry)
modelID := normalizePattern(entry.ID)
if modelID == "" {
return Registry{}, fmt.Errorf("model id is required")
}
if _, ok := seenModelIDs[modelID]; ok {
return Registry{}, fmt.Errorf("duplicate model id %q", modelID)
}
seenModelIDs[modelID] = struct{}{}
if err := registry.register(entry.ID, clonedEntry); err != nil {
return Registry{}, err
}
if err := registry.register(entry.APIModel, clonedEntry); err != nil {
return Registry{}, err
}
for _, alias := range entry.Aliases {
if err := registry.register(alias, clonedEntry); err != nil {
return Registry{}, err
}
}
registry.models = append(registry.models, clonedEntry)
}
return registry, nil
}
func (registry Registry) Get(pattern string) (ModelEntry, bool) {
entry, ok := registry.entries[normalizePattern(pattern)]
if !ok {
return ModelEntry{}, false
}
return cloneModelEntry(entry), true
}
func (registry Registry) register(pattern string, entry ModelEntry) error {
normalized := normalizePattern(pattern)
if normalized == "" {
return nil
}
if existing, ok := registry.entries[normalized]; ok && existing.ID != entry.ID {
return fmt.Errorf("duplicate model lookup key %q for %q and %q", normalized, existing.ID, entry.ID)
}
registry.entries[normalized] = entry
return nil
}
func normalizePattern(pattern string) string {
return strings.ToLower(strings.TrimSpace(pattern))
}
func ValidPrimaryProviderKind(provider ProviderKind) bool {
switch provider {
case ProviderOpenAI, ProviderAnthropic, ProviderGoogle:
return true
default:
return false
}
}
func ValidRuntimeProviderKind(provider ProviderKind) bool {
return ValidPrimaryProviderKind(provider) || provider == ProviderOpenAICompatible
}
func ValidReasoningEffort(effort ReasoningEffort) bool {
switch effort {
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh:
return true
default:
return false
}
}
func ValidModelCapability(capability ModelCapability) bool {
switch capability {
case ModelCapabilityChat, ModelCapabilityStreaming, ModelCapabilityToolCalling, ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityReasoning, ModelCapabilitySystemPrompt, ModelCapabilityPromptCache, ModelCapabilityLongContext:
return true
default:
return false
}
}
func ValidModelStatus(status ModelStatus) bool {
switch status {
case ModelStatusActive, ModelStatusPreview, ModelStatusDeprecated:
return true
default:
return false
}
}
-275
View File
@@ -1,275 +0,0 @@
package modelregistry
import (
"strings"
"testing"
)
func TestModelEntryValidatesPRDShape(t *testing.T) {
model := ModelEntry{
ID: "gpt-4.1-mini",
DisplayName: "GPT-4.1 mini",
APIModel: "gpt-4.1-mini",
Provider: ProviderOpenAI,
APIProviders: []ProviderKind{
ProviderOpenAI,
ProviderOpenAICompatible,
},
ContextLimits: ContextLimits{
ContextWindow: 1_047_576,
MaxOutputTokens: 32_768,
},
Capabilities: ModelCapabilities{
ModelCapabilityChat,
ModelCapabilityStreaming,
ModelCapabilityToolCalling,
ModelCapabilitySystemPrompt,
ModelCapabilityLongContext,
},
Cost: ModelCost{
Currency: "USD",
Unit: "per_1m_tokens",
InputPerMillion: 0.4,
OutputPerMillion: 1.6,
CachedInputPerMillion: 0.1,
Source: "https://platform.openai.com/docs/pricing/",
SourceLastVerified: "2026-06-02",
},
Status: ModelStatusActive,
Aliases: []string{"openai:gpt-4.1-mini"},
Description: "OpenAI lower-cost long-context model for frequent edit loops.",
}
if err := model.Validate(); err != nil {
t.Fatalf("Validate returned error: %v", err)
}
if !model.Supports(ModelCapabilityToolCalling) {
t.Fatal("model should support tool calling")
}
if !model.AllowsProvider(ProviderOpenAICompatible) {
t.Fatal("model should allow OpenAI-compatible runtime adapter")
}
}
func TestModelEntryRejectsInvalidContextLimits(t *testing.T) {
model := validModelEntry()
model.ContextLimits.ContextWindow = 1_000
model.ContextLimits.MaxOutputTokens = 2_000
err := model.Validate()
if err == nil {
t.Fatal("expected context limit validation error")
}
if !strings.Contains(err.Error(), "max output tokens") {
t.Fatalf("error = %q, want max output tokens", err.Error())
}
}
func TestModelEntryRejectsOpenAICompatibleAsPrimaryProvider(t *testing.T) {
model := validModelEntry()
model.Provider = ProviderOpenAICompatible
err := model.Validate()
if err == nil {
t.Fatal("expected primary provider validation error")
}
if !strings.Contains(err.Error(), "primary provider") {
t.Fatalf("error = %q, want primary provider", err.Error())
}
}
func TestModelEntryRejectsProviderNotInAPIProviders(t *testing.T) {
model := validModelEntry()
model.APIProviders = []ProviderKind{ProviderAnthropic}
err := model.Validate()
if err == nil {
t.Fatal("expected provider/api providers validation error")
}
if !strings.Contains(err.Error(), "api providers") {
t.Fatalf("error = %q, want api providers", err.Error())
}
}
func TestModelEntryRejectsUnknownContractEnums(t *testing.T) {
model := validModelEntry()
model.Capabilities = ModelCapabilities{"telepathy"}
err := model.Validate()
if err == nil {
t.Fatal("expected capability validation error")
}
if !strings.Contains(err.Error(), "model capability") {
t.Fatalf("error = %q, want model capability", err.Error())
}
model = validModelEntry()
model.ReasoningEfforts = []ReasoningEffort{"warp"}
err = model.Validate()
if err == nil {
t.Fatal("expected reasoning effort validation error")
}
if !strings.Contains(err.Error(), "reasoning effort") {
t.Fatalf("error = %q, want reasoning effort", err.Error())
}
model = validModelEntry()
model.Status = "retired"
err = model.Validate()
if err == nil {
t.Fatal("expected model status validation error")
}
if !strings.Contains(err.Error(), "model status") {
t.Fatalf("error = %q, want model status", err.Error())
}
}
func TestModelEntryRejectsInvalidSourceLastVerifiedDate(t *testing.T) {
model := validModelEntry()
model.Cost.SourceLastVerified = "2026/06/02"
err := model.Validate()
if err == nil {
t.Fatal("expected source last verified date validation error")
}
if !strings.Contains(err.Error(), "YYYY-MM-DD") {
t.Fatalf("error = %q, want YYYY-MM-DD", err.Error())
}
}
func TestModelCostRequiresCompleteBasePricing(t *testing.T) {
model := validModelEntry()
model.Cost.InputPerMillion = 0
err := model.Validate()
if err == nil {
t.Fatal("expected incomplete base pricing validation error")
}
if !strings.Contains(err.Error(), "base input and output rates") {
t.Fatalf("error = %q, want base input and output rates", err.Error())
}
model = validModelEntry()
model.Cost.OutputPerMillion = 0
err = model.Validate()
if err == nil {
t.Fatal("expected incomplete base pricing validation error")
}
if !strings.Contains(err.Error(), "base input and output rates") {
t.Fatalf("error = %q, want base input and output rates", err.Error())
}
}
func TestModelRegistryResolvesStablePatterns(t *testing.T) {
registry, err := NewRegistry([]ModelEntry{validModelEntry()})
if err != nil {
t.Fatalf("NewRegistry returned error: %v", err)
}
model, ok := registry.Get("OPENAI:GPT-4.1-MINI")
if !ok {
t.Fatal("expected model to resolve from case-insensitive match pattern")
}
if model.ID != "gpt-4.1-mini" {
t.Fatalf("model ID = %q, want gpt-4.1-mini", model.ID)
}
}
func TestModelRegistryClonesLookupEntriesOnConstruction(t *testing.T) {
entries := []ModelEntry{validModelEntry()}
entries[0].Cost.Notes = []string{"original note"}
registry, err := NewRegistry(entries)
if err != nil {
t.Fatalf("NewRegistry returned error: %v", err)
}
entries[0].Aliases[0] = "mutated-alias"
entries[0].Capabilities[0] = ModelCapabilityVision
entries[0].Cost.Notes[0] = "caller-owned mutation"
model, ok := registry.Get("openai:gpt-4.1-mini")
if !ok {
t.Fatal("expected original alias to remain registered")
}
if model.Aliases[0] != "openai:gpt-4.1-mini" {
t.Fatalf("alias = %q, want original alias", model.Aliases[0])
}
if model.Capabilities[0] != ModelCapabilityChat {
t.Fatalf("capability = %q, want original capability", model.Capabilities[0])
}
if len(model.Cost.Notes) != 1 || model.Cost.Notes[0] != "original note" {
t.Fatalf("cost notes = %#v, want original note", model.Cost.Notes)
}
}
func TestRegistryRejectsInvalidModelEntries(t *testing.T) {
model := validModelEntry()
model.ContextLimits.MaxOutputTokens = model.ContextLimits.ContextWindow + 1
_, err := NewRegistry([]ModelEntry{model})
if err == nil {
t.Fatal("expected invalid model validation error")
}
if !strings.Contains(err.Error(), "max output tokens") {
t.Fatalf("error = %q, want max output tokens", err.Error())
}
}
func TestRegistryDetectsDuplicateNormalizedLookupKeys(t *testing.T) {
first := validModelEntry()
second := validModelEntry()
second.ID = "other-model"
second.DisplayName = "Other model"
second.APIModel = "other-model"
second.Aliases = []string{"GPT-4.1-MINI"}
_, err := NewRegistry([]ModelEntry{first, second})
if err == nil {
t.Fatal("expected duplicate lookup key validation error")
}
if !strings.Contains(err.Error(), "duplicate model lookup key") {
t.Fatalf("error = %q, want duplicate model lookup key", err.Error())
}
}
func TestRegistryDetectsDuplicateModelIDs(t *testing.T) {
first := validModelEntry()
second := validModelEntry()
second.APIModel = "other-api-model"
second.Aliases = []string{"other:model"}
_, err := NewRegistry([]ModelEntry{first, second})
if err == nil {
t.Fatal("expected duplicate model id validation error")
}
if !strings.Contains(err.Error(), "duplicate model id") {
t.Fatalf("error = %q, want duplicate model id", err.Error())
}
}
func validModelEntry() ModelEntry {
return ModelEntry{
ID: "gpt-4.1-mini",
DisplayName: "GPT-4.1 mini",
APIModel: "gpt-4.1-mini",
Provider: ProviderOpenAI,
ContextLimits: ContextLimits{
ContextWindow: 1_047_576,
MaxOutputTokens: 32_768,
},
Capabilities: ModelCapabilities{ModelCapabilityChat},
Cost: ModelCost{
Currency: "USD",
Unit: "per_1m_tokens",
InputPerMillion: 0.4,
OutputPerMillion: 1.6,
Source: "https://platform.openai.com/docs/pricing/",
SourceLastVerified: "2026-06-02",
},
Status: ModelStatusActive,
Aliases: []string{"openai:gpt-4.1-mini"},
}
}
-457
View File
@@ -1,457 +0,0 @@
package anthropic
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const defaultBaseURL = "https://api.anthropic.com"
const defaultVersion = "2023-06-01"
const defaultMaxTokens = 4096
// Options configures an Anthropic Messages API provider.
type Options struct {
APIKey string
BaseURL string
Model string
MaxTokens int
Version string
Beta string
HTTPClient *http.Client
UserAgent string
}
// Provider streams completions from Anthropic's Messages API.
type Provider struct {
apiKey string
baseURL string
model string
maxTokens int
version string
beta string
httpClient *http.Client
userAgent string
}
// New creates an Anthropic provider.
func New(options Options) (*Provider, error) {
model := strings.TrimSpace(options.Model)
if model == "" {
return nil, errors.New("anthropic provider requires a model")
}
maxTokens, err := providerio.PositiveOrDefault(options.MaxTokens, defaultMaxTokens, "zero Anthropic provider maxTokens")
if err != nil {
return nil, err
}
baseURL, err := providerio.NormalizeBaseURL(options.BaseURL, defaultBaseURL, "Anthropic")
if err != nil {
return nil, err
}
version := strings.TrimSpace(options.Version)
if version == "" {
version = defaultVersion
}
return &Provider{
apiKey: options.APIKey,
baseURL: baseURL,
model: model,
maxTokens: maxTokens,
version: version,
beta: strings.TrimSpace(options.Beta),
httpClient: providerio.HTTPClient(options.HTTPClient),
userAgent: options.UserAgent,
}, nil
}
// StreamCompletion sends one streaming Anthropic Messages request.
func (provider *Provider) StreamCompletion(
ctx context.Context,
request zeroruntime.CompletionRequest,
) (<-chan zeroruntime.StreamEvent, error) {
mapped, err := provider.anthropicRequest(request)
if err != nil {
return nil, err
}
body, err := json.Marshal(mapped)
if err != nil {
return nil, fmt.Errorf("encode Anthropic request: %w", err)
}
events := make(chan zeroruntime.StreamEvent, 16)
go func() {
defer close(events)
provider.stream(ctx, body, events)
}()
return events, nil
}
func (provider *Provider) stream(ctx context.Context, body []byte, events chan<- zeroruntime.StreamEvent) {
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, provider.baseURL+"/v1/messages", bytes.NewReader(body))
if err != nil {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider request error: " + err.Error())})
return
}
httpRequest.Header.Set("Content-Type", "application/json")
httpRequest.Header.Set("anthropic-version", provider.version)
if provider.apiKey != "" {
httpRequest.Header.Set("x-api-key", provider.apiKey)
}
if provider.beta != "" {
httpRequest.Header.Set("anthropic-beta", provider.beta)
}
if provider.userAgent != "" {
httpRequest.Header.Set("User-Agent", provider.userAgent)
}
response, err := provider.httpClient.Do(httpRequest)
if err != nil {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
return
}
defer func() {
_ = response.Body.Close()
}()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
provider.emitHTTPError(ctx, response, events)
return
}
state := newStreamState()
err = providerio.ScanSSEData(response.Body, func(data string) bool {
return provider.emitPayload(ctx, data, state, events)
})
if err != nil {
state.closeOpen(ctx, events)
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
return
}
if err := ctx.Err(); err != nil {
state.closeOpen(ctx, events)
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
return
}
if !state.done {
provider.emitDone(ctx, state, events)
}
}
func (provider *Provider) emitPayload(ctx context.Context, data string, state *streamState, events chan<- zeroruntime.StreamEvent) bool {
var payload streamPayload
if err := json.Unmarshal([]byte(data), &payload); err != nil {
state.closeOpen(ctx, events)
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.redact("provider stream error: malformed JSON: " + err.Error()),
})
state.done = true
return false
}
switch payload.Type {
case "message_start":
if payload.Message != nil {
state.recordUsage(payload.Message.Usage)
}
case "content_block_start":
if payload.ContentBlock != nil && payload.ContentBlock.Type == "tool_use" && payload.ContentBlock.ID != "" && payload.ContentBlock.Name != "" {
state.startTool(ctx, payload.Index, payload.ContentBlock.ID, payload.ContentBlock.Name, events)
if len(payload.ContentBlock.Input) > 0 {
encoded, err := json.Marshal(payload.ContentBlock.Input)
if err == nil {
state.deltaTool(ctx, payload.Index, string(encoded), events)
}
}
}
case "content_block_delta":
if payload.Delta == nil {
return true
}
switch payload.Delta.Type {
case "text_delta":
if payload.Delta.Text != "" {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: payload.Delta.Text})
}
case "input_json_delta":
if payload.Delta.PartialJSON != "" {
state.deltaTool(ctx, payload.Index, payload.Delta.PartialJSON, events)
}
}
case "content_block_stop":
state.stopTool(ctx, payload.Index, events)
case "message_delta":
if payload.Usage != nil {
state.recordUsage(*payload.Usage)
}
case "message_stop":
provider.emitDone(ctx, state, events)
case "error":
message := "Anthropic stream error"
if payload.Error != nil {
message = firstNonEmpty(payload.Error.Message, payload.Error.Type, message)
}
state.closeOpen(ctx, events)
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.classifiedError(http.StatusInternalServerError, message),
})
state.done = true
return false
}
return true
}
func (provider *Provider) emitDone(ctx context.Context, state *streamState, events chan<- zeroruntime.StreamEvent) {
state.closeOpen(ctx, events)
if state.hasInputUsage || state.hasOutputUsage {
usage, err := zeroruntime.NormalizeUsage(zeroruntime.TokenUsage{
InputTokens: state.inputTokens,
OutputTokens: state.outputTokens,
})
if err == nil {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: usage})
}
}
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
state.done = true
}
func (provider *Provider) emitHTTPError(ctx context.Context, response *http.Response, events chan<- zeroruntime.StreamEvent) {
body, _ := io.ReadAll(io.LimitReader(response.Body, 64*1024))
message := response.Status
if parsed := parseErrorMessage(body); parsed != "" {
message = parsed
} else if trimmed := strings.TrimSpace(string(body)); trimmed != "" {
message = trimmed
}
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.classifiedError(response.StatusCode, message),
})
}
func (provider *Provider) anthropicRequest(request zeroruntime.CompletionRequest) (messagesRequest, error) {
system, messages, err := mapMessages(request.Messages)
if err != nil {
return messagesRequest{}, err
}
if len(messages) == 0 {
return messagesRequest{}, errors.New("zero Anthropic provider requires at least one non-system message")
}
mapped := messagesRequest{
Model: provider.model,
MaxTokens: provider.maxTokens,
Messages: messages,
System: system,
Stream: true,
}
if len(request.Tools) > 0 {
mapped.Tools = make([]anthropicTool, 0, len(request.Tools))
for _, tool := range request.Tools {
mapped.Tools = append(mapped.Tools, anthropicTool{
Name: tool.Name,
Description: tool.Description,
InputSchema: tool.Parameters,
})
}
}
return mapped, nil
}
func mapMessages(messages []zeroruntime.Message) (string, []anthropicMessage, error) {
systemParts := []string{}
mapped := []anthropicMessage{}
for _, message := range messages {
content := message.Content
hasContent := strings.TrimSpace(content) != ""
switch message.Role {
case zeroruntime.MessageRoleSystem:
if hasContent {
systemParts = append(systemParts, content)
}
case zeroruntime.MessageRoleTool:
if message.ToolCallID == "" {
return "", nil, errors.New("zero Anthropic provider requires toolCallId on tool result messages")
}
appendUserBlocks(&mapped, []map[string]any{{
"type": "tool_result",
"tool_use_id": message.ToolCallID,
"content": content,
}})
case zeroruntime.MessageRoleAssistant:
blocks := []map[string]any{}
if hasContent {
blocks = append(blocks, map[string]any{"type": "text", "text": content})
}
for _, toolCall := range message.ToolCalls {
input, err := parseToolArguments(toolCall.Arguments, toolCall.Name)
if err != nil {
return "", nil, err
}
blocks = append(blocks, map[string]any{
"type": "tool_use",
"id": toolCall.ID,
"name": toolCall.Name,
"input": input,
})
}
if len(blocks) == 0 {
continue
}
var messageContent any = blocks
if len(blocks) == 1 && blocks[0]["type"] == "text" {
messageContent = blocks[0]["text"]
}
mapped = append(mapped, anthropicMessage{Role: "assistant", Content: messageContent})
default:
if hasContent {
appendUserBlocks(&mapped, []map[string]any{{"type": "text", "text": content}})
}
}
}
return strings.Join(systemParts, "\n\n"), mapped, nil
}
func appendUserBlocks(messages *[]anthropicMessage, blocks []map[string]any) {
if len(*messages) > 0 && (*messages)[len(*messages)-1].Role == "user" {
last := &(*messages)[len(*messages)-1]
last.Content = append(contentBlocks(last.Content), blocks...)
return
}
*messages = append(*messages, anthropicMessage{Role: "user", Content: blocks})
}
func contentBlocks(content any) []map[string]any {
if content == nil {
return nil
}
if text, ok := content.(string); ok {
return []map[string]any{{"type": "text", "text": text}}
}
if blocks, ok := content.([]map[string]any); ok {
return blocks
}
return nil
}
func parseToolArguments(argumentsJSON string, toolName string) (map[string]any, error) {
if strings.TrimSpace(argumentsJSON) == "" {
return map[string]any{}, nil
}
var parsed any
if err := json.Unmarshal([]byte(argumentsJSON), &parsed); err != nil {
return nil, fmt.Errorf("zero Anthropic provider could not parse tool arguments for %s as JSON", toolName)
}
object, ok := parsed.(map[string]any)
if !ok || object == nil {
return nil, fmt.Errorf("zero Anthropic provider requires tool arguments for %s to be a JSON object", toolName)
}
return object, nil
}
func parseErrorMessage(body []byte) string {
var parsed struct {
Error apiError `json:"error"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return ""
}
return firstNonEmpty(parsed.Error.Message, parsed.Message)
}
func (provider *Provider) classifiedError(statusCode int, message string) string {
return providerio.ClassifiedError(statusCode, message, provider.apiKey)
}
func (provider *Provider) redact(message string) string {
return providerio.Redact(message, provider.apiKey)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}
type toolBlock struct {
id string
name string
}
type streamState struct {
tools map[int]toolBlock
inputTokens int
outputTokens int
hasInputUsage bool
hasOutputUsage bool
done bool
}
func newStreamState() *streamState {
return &streamState{tools: make(map[int]toolBlock)}
}
func (state *streamState) recordUsage(usage usage) {
if usage.InputTokens != 0 {
state.inputTokens = usage.InputTokens
state.hasInputUsage = true
}
if usage.OutputTokens != 0 {
state.outputTokens = usage.OutputTokens
state.hasOutputUsage = true
}
}
func (state *streamState) startTool(ctx context.Context, index int, id string, name string, events chan<- zeroruntime.StreamEvent) {
state.tools[index] = toolBlock{id: id, name: name}
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallStart,
ToolCallID: id,
ToolName: name,
})
}
func (state *streamState) deltaTool(ctx context.Context, index int, fragment string, events chan<- zeroruntime.StreamEvent) {
tool, ok := state.tools[index]
if !ok || fragment == "" {
return
}
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallDelta,
ToolCallID: tool.id,
ArgumentsFragment: fragment,
})
}
func (state *streamState) stopTool(ctx context.Context, index int, events chan<- zeroruntime.StreamEvent) {
tool, ok := state.tools[index]
if !ok {
return
}
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallEnd,
ToolCallID: tool.id,
})
delete(state.tools, index)
}
func (state *streamState) closeOpen(ctx context.Context, events chan<- zeroruntime.StreamEvent) {
for index := range state.tools {
state.stopTool(ctx, index, events)
}
}
@@ -1,326 +0,0 @@
package anthropic
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestStreamCompletionPostsMessagesRequest(t *testing.T) {
var gotPath string
var gotAPIKey string
var gotVersion string
var gotUserAgent string
var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAPIKey = r.Header.Get("x-api-key")
gotVersion = r.Header.Get("anthropic-version")
gotUserAgent = r.Header.Get("User-Agent")
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSEEvent(w, "message_start", `{"type":"message_start","message":{"usage":{"input_tokens":3}}}`)
writeSSEEvent(w, "message_stop", `{"type":"message_stop"}`)
}))
defer server.Close()
provider, err := New(Options{
APIKey: "sk-ant",
BaseURL: server.URL + "/",
Model: "claude-test",
MaxTokens: 64_000,
UserAgent: "zero-test",
})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "You are Zero."},
{Role: zeroruntime.MessageRoleUser, Content: "Read the file."},
{
Role: zeroruntime.MessageRoleAssistant,
Content: "I will inspect it.",
ToolCalls: []zeroruntime.ToolCall{{
ID: "toolu_1",
Name: "read_file",
Arguments: `{"path":"src/index.ts"}`,
}},
},
{Role: zeroruntime.MessageRoleTool, Content: "file contents", ToolCallID: "toolu_1"},
},
Tools: []zeroruntime.ToolDefinition{{
Name: "read_file",
Description: "Read a file",
Parameters: map[string]any{"type": "object", "properties": map[string]any{"path": map[string]any{"type": "string"}}},
}},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
if gotPath != "/v1/messages" {
t.Fatalf("path = %q, want /v1/messages", gotPath)
}
if gotAPIKey != "sk-ant" {
t.Fatalf("x-api-key = %q, want key", gotAPIKey)
}
if gotVersion != defaultVersion {
t.Fatalf("anthropic-version = %q, want %q", gotVersion, defaultVersion)
}
if gotUserAgent != "zero-test" {
t.Fatalf("User-Agent = %q, want zero-test", gotUserAgent)
}
if gotBody["model"] != "claude-test" || gotBody["stream"] != true || gotBody["max_tokens"] != float64(64_000) {
t.Fatalf("unexpected model/stream/max_tokens: %#v", gotBody)
}
if gotBody["system"] != "You are Zero." {
t.Fatalf("system = %#v, want system prompt", gotBody["system"])
}
messages := gotBody["messages"].([]any)
if len(messages) != 3 {
t.Fatalf("messages = %#v, want user, assistant, tool-result user", messages)
}
assistant := messages[1].(map[string]any)
assistantBlocks := assistant["content"].([]any)
toolUse := assistantBlocks[1].(map[string]any)
if toolUse["type"] != "tool_use" || toolUse["id"] != "toolu_1" || toolUse["name"] != "read_file" {
t.Fatalf("unexpected tool_use block: %#v", toolUse)
}
input := toolUse["input"].(map[string]any)
if input["path"] != "src/index.ts" {
t.Fatalf("tool input = %#v, want path", input)
}
toolResult := messages[2].(map[string]any)
toolResultBlocks := toolResult["content"].([]any)
if toolResultBlocks[0].(map[string]any)["tool_use_id"] != "toolu_1" {
t.Fatalf("unexpected tool result: %#v", toolResultBlocks[0])
}
tools := gotBody["tools"].([]any)
if tools[0].(map[string]any)["input_schema"].(map[string]any)["type"] != "object" {
t.Fatalf("unexpected tool schema: %#v", tools)
}
}
func TestStreamCompletionEmitsTextUsageAndDone(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSEEvent(w, "message_start", `{"type":"message_start","message":{"usage":{"input_tokens":25}}}`)
writeSSEEvent(w, "content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}`)
writeSSEEvent(w, "content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Zero"}}`)
writeSSEEvent(w, "message_delta", `{"type":"message_delta","usage":{"output_tokens":15}}`)
writeSSEEvent(w, "message_stop", `{"type":"message_stop"}`)
})
events := collectProviderEvents(t, provider)
want := []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventText, Content: "Hello"},
{Type: zeroruntime.StreamEventText, Content: " Zero"},
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 25, OutputTokens: 15, PromptTokens: 25, CompletionTokens: 15}},
{Type: zeroruntime.StreamEventDone},
}
if !reflect.DeepEqual(events, want) {
t.Fatalf("events = %#v, want %#v", events, want)
}
}
func TestStreamCompletionEmitsToolUseBlocks(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSEEvent(w, "content_block_start", `{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"read_file","input":{}}}`)
writeSSEEvent(w, "content_block_delta", `{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"path\":"}}`)
writeSSEEvent(w, "content_block_delta", `{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"src/index.ts\"}"}}`)
writeSSEEvent(w, "content_block_stop", `{"type":"content_block_stop","index":1}`)
writeSSEEvent(w, "message_stop", `{"type":"message_stop"}`)
})
events := collectProviderEvents(t, provider)
want := []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "toolu_1", ToolName: "read_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "toolu_1", ArgumentsFragment: `{"path":`},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "toolu_1", ArgumentsFragment: `"src/index.ts"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "toolu_1"},
{Type: zeroruntime.StreamEventDone},
}
if !reflect.DeepEqual(events, want) {
t.Fatalf("events = %#v, want %#v", events, want)
}
}
func TestStreamCompletionClosesOpenToolCallOnEOF(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSEEvent(w, "content_block_start", `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"grep","input":{}}}`)
writeSSEEvent(w, "content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"pattern\":\"Zero\"}"}}`)
})
events := collectProviderEvents(t, provider)
if len(eventsOfType(events, zeroruntime.StreamEventToolCallEnd)) != 1 {
t.Fatalf("events = %#v, want one tool-call-end on EOF", events)
}
if len(eventsOfType(events, zeroruntime.StreamEventDone)) != 1 {
t.Fatalf("events = %#v, want done on EOF", events)
}
}
func TestStreamCompletionClassifiesHTTPErrorsAndRedactsToken(t *testing.T) {
cases := []struct {
name string
status int
body string
wantPrefix string
}{
{"auth", http.StatusUnauthorized, `{"error":{"message":"bad key sk-ant"}}`, "auth error:"},
{"rate limit", http.StatusTooManyRequests, `{"error":{"message":"slow down"}}`, "rate limit error:"},
{"overloaded", http.StatusServiceUnavailable, `{"error":{"message":"overloaded"}}`, "rate limit error:"},
{"bad request", http.StatusBadRequest, `{"error":{"message":"bad request"}}`, "provider request error:"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
provider := newTestProviderWithKey(t, "sk-ant", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, tc.body, tc.status)
})
stream, err := provider.StreamCompletion(context.Background(), validRequest())
if err != nil {
t.Fatalf("StreamCompletion returned setup error: %v", err)
}
events := readAll(stream)
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
t.Fatalf("events = %#v, want one error", events)
}
if !strings.HasPrefix(events[0].Error, tc.wantPrefix) {
t.Fatalf("error = %q, want prefix %q", events[0].Error, tc.wantPrefix)
}
if strings.Contains(events[0].Error, "sk-ant") {
t.Fatalf("error leaked token: %q", events[0].Error)
}
})
}
}
func TestStreamCompletionEmitsStreamErrorObject(t *testing.T) {
provider := newTestProviderWithKey(t, "sk-ant", func(w http.ResponseWriter, r *http.Request) {
writeSSEEvent(w, "error", `{"type":"error","error":{"message":"stream failed sk-ant","type":"overloaded_error"}}`)
})
events := collectProviderEvents(t, provider)
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
t.Fatalf("events = %#v, want one error", events)
}
if !strings.HasPrefix(events[0].Error, "provider error:") {
t.Fatalf("error = %q, want provider error prefix", events[0].Error)
}
if strings.Contains(events[0].Error, "sk-ant") {
t.Fatalf("error leaked token: %q", events[0].Error)
}
}
func TestStreamCompletionRejectsMalformedHistoryBeforeDispatch(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("provider should not dispatch malformed history")
})
_, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleTool, Content: "missing id"}},
})
if err == nil || !strings.Contains(err.Error(), "requires toolCallId") {
t.Fatalf("error = %v, want missing toolCallId", err)
}
_, err = provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: "call tool"},
{
Role: zeroruntime.MessageRoleAssistant,
ToolCalls: []zeroruntime.ToolCall{{ID: "toolu_1", Name: "read_file", Arguments: `"src/index.ts"`}},
},
},
})
if err == nil || !strings.Contains(err.Error(), "requires tool arguments for read_file to be a JSON object") {
t.Fatalf("error = %v, want non-object tool argument error", err)
}
}
func TestNewRequiresModelAndPositiveMaxTokens(t *testing.T) {
if _, err := New(Options{}); err == nil {
t.Fatal("New without model returned nil error")
}
if _, err := New(Options{Model: "claude-test", MaxTokens: -1}); err == nil {
t.Fatal("New with negative max tokens returned nil error")
}
}
func newTestProvider(t *testing.T, handler http.HandlerFunc) *Provider {
t.Helper()
return newTestProviderWithKey(t, "", handler)
}
func newTestProviderWithKey(t *testing.T, apiKey string, handler http.HandlerFunc) *Provider {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
provider, err := New(Options{
APIKey: apiKey,
BaseURL: server.URL,
Model: "claude-test",
})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
return provider
}
func collectProviderEvents(t *testing.T, provider *Provider) []zeroruntime.StreamEvent {
t.Helper()
stream, err := provider.StreamCompletion(context.Background(), validRequest())
if err != nil {
t.Fatalf("StreamCompletion returned setup error: %v", err)
}
return readAll(stream)
}
func validRequest() zeroruntime.CompletionRequest {
return zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
}
}
func readAll(stream <-chan zeroruntime.StreamEvent) []zeroruntime.StreamEvent {
events := []zeroruntime.StreamEvent{}
for event := range stream {
events = append(events, event)
}
return events
}
func drain(stream <-chan zeroruntime.StreamEvent) {
for range stream {
}
}
func writeSSEEvent(w http.ResponseWriter, eventName string, payload string) {
w.Header().Set("Content-Type", "text/event-stream")
if eventName != "" {
_, _ = w.Write([]byte("event: " + eventName + "\n"))
}
_, _ = w.Write([]byte("data: " + payload + "\n\n"))
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
}
func eventsOfType(events []zeroruntime.StreamEvent, eventType zeroruntime.StreamEventType) []zeroruntime.StreamEvent {
matching := []zeroruntime.StreamEvent{}
for _, event := range events {
if event.Type == eventType {
matching = append(matching, event)
}
}
return matching
}
-59
View File
@@ -1,59 +0,0 @@
package anthropic
type messagesRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
Messages []anthropicMessage `json:"messages"`
System string `json:"system,omitempty"`
Tools []anthropicTool `json:"tools,omitempty"`
Stream bool `json:"stream"`
}
type anthropicMessage struct {
Role string `json:"role"`
Content any `json:"content"`
}
type anthropicTool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema map[string]any `json:"input_schema,omitempty"`
}
type streamPayload struct {
Type string `json:"type"`
Index int `json:"index"`
Message *streamMessage `json:"message"`
ContentBlock *contentBlock `json:"content_block"`
Delta *streamDelta `json:"delta"`
Usage *usage `json:"usage"`
Error *apiError `json:"error"`
}
type streamMessage struct {
Usage usage `json:"usage"`
}
type contentBlock struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Input map[string]any `json:"input"`
}
type streamDelta struct {
Type string `json:"type"`
Text string `json:"text"`
PartialJSON string `json:"partial_json"`
StopReason string `json:"stop_reason"`
}
type usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
type apiError struct {
Type string `json:"type"`
Message string `json:"message"`
}
-138
View File
@@ -1,138 +0,0 @@
package providers
import (
"fmt"
"net/http"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/providers/anthropic"
"github.com/Gitlawb/zero/internal/providers/gemini"
"github.com/Gitlawb/zero/internal/providers/openai"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Options configures provider construction.
type Options struct {
UserAgent string
HTTPClient *http.Client
ModelRegistry *modelregistry.Registry
}
// New creates a runtime provider for a resolved provider profile.
func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider, error) {
resolved, err := resolveProfile(profile, options)
if err != nil {
return nil, err
}
switch resolved.providerKind {
case config.ProviderKindOpenAI, config.ProviderKindOpenAICompatible:
return openai.New(openai.Options{
APIKey: profile.APIKey,
BaseURL: resolved.baseURL,
Model: resolved.apiModel,
HTTPClient: options.HTTPClient,
UserAgent: options.UserAgent,
})
case config.ProviderKindAnthropic:
return anthropic.New(anthropic.Options{
APIKey: profile.APIKey,
BaseURL: resolved.baseURL,
Model: resolved.apiModel,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
UserAgent: options.UserAgent,
})
case config.ProviderKindGoogle:
return gemini.New(gemini.Options{
APIKey: profile.APIKey,
BaseURL: resolved.baseURL,
Model: resolved.apiModel,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
UserAgent: options.UserAgent,
})
default:
return nil, fmt.Errorf("unsupported provider kind %q", resolved.providerKind)
}
}
type resolvedProfile struct {
providerKind config.ProviderKind
apiModel string
baseURL string
maxOutputTokens int
}
func resolveProfile(profile config.ProviderProfile, options Options) (resolvedProfile, error) {
model := strings.TrimSpace(profile.Model)
if model == "" {
return resolvedProfile{}, fmt.Errorf("provider %s requires model", profile.Name)
}
providerKind, explicitProvider := explicitProviderKind(profile)
registry, err := defaultRegistry(options.ModelRegistry)
if err != nil {
return resolvedProfile{}, err
}
if entry, ok := registry.Get(model); ok {
modelProvider := configKind(entry.Provider)
if !explicitProvider || isImplicitOpenAI(profile, providerKind) {
providerKind = modelProvider
}
if providerKind == config.ProviderKindOpenAICompatible {
if !entry.AllowsProvider(modelregistry.ProviderOpenAICompatible) {
return resolvedProfile{}, fmt.Errorf("zero model %s belongs to %s, not %s", entry.ID, entry.Provider, modelregistry.ProviderOpenAICompatible)
}
} else if providerKind != modelProvider {
return resolvedProfile{}, fmt.Errorf("zero model %s belongs to %s, not %s", entry.ID, entry.Provider, providerKind)
}
return resolvedProfile{
providerKind: providerKind,
apiModel: entry.APIModel,
baseURL: strings.TrimSpace(profile.BaseURL),
maxOutputTokens: entry.ContextLimits.MaxOutputTokens,
}, nil
}
if providerKind == "" {
providerKind = config.ProviderKindOpenAI
}
return resolvedProfile{
providerKind: providerKind,
apiModel: model,
baseURL: strings.TrimSpace(profile.BaseURL),
}, nil
}
func explicitProviderKind(profile config.ProviderProfile) (config.ProviderKind, bool) {
providerKind := config.ProviderKind(strings.TrimSpace(strings.ToLower(string(profile.ProviderKind))))
if providerKind != "" {
return providerKind, true
}
provider := strings.TrimSpace(strings.ToLower(profile.Provider))
if provider != "" {
return config.ProviderKind(provider), true
}
return "", false
}
func isImplicitOpenAI(profile config.ProviderProfile, providerKind config.ProviderKind) bool {
return providerKind == config.ProviderKindOpenAI &&
strings.TrimSpace(string(profile.ProviderKind)) == "" &&
strings.TrimSpace(profile.Provider) == "" &&
strings.TrimSpace(profile.BaseURL) == ""
}
func configKind(provider modelregistry.ProviderKind) config.ProviderKind {
return config.ProviderKind(provider)
}
func defaultRegistry(registry *modelregistry.Registry) (modelregistry.Registry, error) {
if registry != nil {
return *registry, nil
}
return modelregistry.DefaultRegistry()
}
-222
View File
@@ -1,222 +0,0 @@
package providers
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestNewCreatesOpenAIProviderWithFactoryOptions(t *testing.T) {
transport := &captureTransport{
responseBody: "data: [DONE]\n\n",
}
client := &http.Client{Transport: transport}
provider, err := New(config.ProviderProfile{
Name: "custom",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://provider.example/v1/",
APIKey: "sk-factory",
Model: "factory-model",
}, Options{
HTTPClient: client,
UserAgent: "zero-factory-test",
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
})
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
for range stream {
}
if transport.request == nil {
t.Fatal("HTTP client was not used")
}
if transport.request.URL.String() != "https://provider.example/v1/chat/completions" {
t.Fatalf("request URL = %q, want provider base URL", transport.request.URL.String())
}
if transport.request.Header.Get("Authorization") != "Bearer sk-factory" {
t.Fatalf("Authorization = %q, want bearer token", transport.request.Header.Get("Authorization"))
}
if transport.request.Header.Get("User-Agent") != "zero-factory-test" {
t.Fatalf("User-Agent = %q, want factory user agent", transport.request.Header.Get("User-Agent"))
}
}
func TestNewSupportsOpenAIProviderKind(t *testing.T) {
provider, err := New(config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
Model: "gpt-test",
}, Options{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
if provider == nil {
t.Fatal("New() returned nil provider")
}
}
func TestNewResolvesKnownModelToAPIModelAndProvider(t *testing.T) {
transport := &captureTransport{
responseBody: "data: {\"type\":\"message_stop\"}\n\n",
}
client := &http.Client{Transport: transport}
provider, err := New(config.ProviderProfile{
Name: "claude",
APIKey: "sk-ant",
Model: "claude-sonnet-4.5",
}, Options{
HTTPClient: client,
UserAgent: "zero-factory-test",
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
})
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
for range stream {
}
if transport.request == nil {
t.Fatal("HTTP client was not used")
}
if transport.request.URL.String() != "https://api.anthropic.com/v1/messages" {
t.Fatalf("request URL = %q, want Anthropic Messages API", transport.request.URL.String())
}
if transport.request.Header.Get("x-api-key") != "sk-ant" {
t.Fatalf("x-api-key = %q, want Anthropic key", transport.request.Header.Get("x-api-key"))
}
if transport.request.Header.Get("User-Agent") != "zero-factory-test" {
t.Fatalf("User-Agent = %q, want factory user agent", transport.request.Header.Get("User-Agent"))
}
var body map[string]any
if err := json.NewDecoder(transport.body()).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if body["model"] != "claude-sonnet-4-5-20250929" {
t.Fatalf("model = %q, want registry API model", body["model"])
}
if body["max_tokens"] != float64(64000) {
t.Fatalf("max_tokens = %#v, want registry output ceiling", body["max_tokens"])
}
}
func TestNewCreatesGeminiProviderFromFactoryOptions(t *testing.T) {
transport := &captureTransport{
responseBody: "data: {}\n\n",
}
client := &http.Client{Transport: transport}
provider, err := New(config.ProviderProfile{
Name: "google",
ProviderKind: config.ProviderKindGoogle,
APIKey: "sk-google",
Model: "gemini-2.5-flash",
}, Options{
HTTPClient: client,
UserAgent: "zero-factory-test",
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
})
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
for range stream {
}
if transport.request == nil {
t.Fatal("HTTP client was not used")
}
wantURL := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
if transport.request.URL.String() != wantURL {
t.Fatalf("request URL = %q, want %s", transport.request.URL.String(), wantURL)
}
if transport.request.Header.Get("x-goog-api-key") != "sk-google" {
t.Fatalf("x-goog-api-key = %q, want Google key", transport.request.Header.Get("x-goog-api-key"))
}
var body map[string]any
if err := json.NewDecoder(transport.body()).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
generationConfig := body["generationConfig"].(map[string]any)
if generationConfig["maxOutputTokens"] != float64(65536) {
t.Fatalf("maxOutputTokens = %#v, want registry output ceiling", generationConfig["maxOutputTokens"])
}
}
func TestNewRejectsMismatchedOfficialProviderAndKnownModel(t *testing.T) {
_, err := New(config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
Model: "claude-sonnet-4.5",
}, Options{})
if err == nil {
t.Fatal("New() error = nil, want provider/model mismatch")
}
if !strings.Contains(err.Error(), "belongs to anthropic, not openai") {
t.Fatalf("error = %q, want model/provider mismatch", err.Error())
}
}
func TestNewRejectsUnsupportedProviderKind(t *testing.T) {
_, err := New(config.ProviderProfile{
Name: "bad",
ProviderKind: "bedrock",
Model: "model",
}, Options{})
if err == nil {
t.Fatal("New() error = nil, want unsupported kind error")
}
if !strings.Contains(err.Error(), `unsupported provider kind "bedrock"`) {
t.Fatalf("error = %q, want unsupported provider kind", err.Error())
}
}
type captureTransport struct {
request *http.Request
requestBody string
responseBody string
}
func (transport *captureTransport) RoundTrip(request *http.Request) (*http.Response, error) {
transport.request = request
if request.Body != nil {
body, _ := io.ReadAll(request.Body)
transport.requestBody = string(body)
}
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(transport.responseBody)),
Request: request,
}, nil
}
func (transport *captureTransport) body() io.Reader {
return strings.NewReader(transport.requestBody)
}
-428
View File
@@ -1,428 +0,0 @@
package gemini
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const defaultBaseURL = "https://generativelanguage.googleapis.com"
const defaultMaxTokens = 8192
// Options configures a Gemini streamGenerateContent provider.
type Options struct {
APIKey string
BaseURL string
Model string
MaxTokens int
HTTPClient *http.Client
UserAgent string
}
// Provider streams completions from the Gemini API.
type Provider struct {
apiKey string
baseURL string
model string
maxTokens int
httpClient *http.Client
userAgent string
}
// New creates a Gemini provider.
func New(options Options) (*Provider, error) {
model := normalizeModel(options.Model)
if model == "" {
return nil, errors.New("gemini provider requires a model")
}
maxTokens, err := providerio.PositiveOrDefault(options.MaxTokens, defaultMaxTokens, "zero Gemini provider maxTokens")
if err != nil {
return nil, err
}
baseURL, err := providerio.NormalizeBaseURL(options.BaseURL, defaultBaseURL, "Gemini")
if err != nil {
return nil, err
}
return &Provider{
apiKey: options.APIKey,
baseURL: baseURL,
model: model,
maxTokens: maxTokens,
httpClient: providerio.HTTPClient(options.HTTPClient),
userAgent: options.UserAgent,
}, nil
}
// StreamCompletion sends one streaming Gemini GenerateContent request.
func (provider *Provider) StreamCompletion(
ctx context.Context,
request zeroruntime.CompletionRequest,
) (<-chan zeroruntime.StreamEvent, error) {
mapped, err := provider.geminiRequest(request)
if err != nil {
return nil, err
}
body, err := json.Marshal(mapped)
if err != nil {
return nil, fmt.Errorf("encode Gemini request: %w", err)
}
events := make(chan zeroruntime.StreamEvent, 16)
go func() {
defer close(events)
provider.stream(ctx, body, events)
}()
return events, nil
}
func (provider *Provider) stream(ctx context.Context, body []byte, events chan<- zeroruntime.StreamEvent) {
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, provider.streamURL(), bytes.NewReader(body))
if err != nil {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider request error: " + err.Error())})
return
}
httpRequest.Header.Set("Content-Type", "application/json")
if provider.apiKey != "" {
httpRequest.Header.Set("x-goog-api-key", provider.apiKey)
}
if provider.userAgent != "" {
httpRequest.Header.Set("User-Agent", provider.userAgent)
}
response, err := provider.httpClient.Do(httpRequest)
if err != nil {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
return
}
defer func() {
_ = response.Body.Close()
}()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
provider.emitHTTPError(ctx, response, events)
return
}
state := streamState{}
err = providerio.ScanSSEData(response.Body, func(data string) bool {
return provider.emitPayload(ctx, data, &state, events)
})
if err != nil {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
return
}
if err := ctx.Err(); err != nil {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
return
}
if !state.done {
provider.emitDone(ctx, state, events)
}
}
func (provider *Provider) emitPayload(ctx context.Context, data string, state *streamState, events chan<- zeroruntime.StreamEvent) bool {
var payload streamPayload
if err := json.Unmarshal([]byte(data), &payload); err != nil {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.redact("provider stream error: malformed JSON: " + err.Error()),
})
state.done = true
return false
}
if payload.Error != nil {
message := firstNonEmpty(payload.Error.Message, payload.Error.Status, "Gemini stream error")
status := payload.Error.Code
if status == 0 {
status = http.StatusInternalServerError
}
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.classifiedError(status, message),
})
state.done = true
return false
}
if payload.PromptFeedback != nil && payload.PromptFeedback.BlockReason != "" {
message := firstNonEmpty(payload.PromptFeedback.BlockReasonMessage, payload.PromptFeedback.BlockReason)
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.redact("provider error: Content blocked: " + message),
})
state.done = true
return false
}
if payload.UsageMetadata != nil {
state.inputTokens = payload.UsageMetadata.PromptTokenCount
state.outputTokens = payload.UsageMetadata.CandidatesTokenCount
state.reasoningTokens = payload.UsageMetadata.ThoughtsTokenCount
state.hasUsage = true
}
for _, candidate := range payload.Candidates {
if candidate.Content == nil {
continue
}
for _, part := range candidate.Content.Parts {
if part.Text != "" {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: part.Text})
}
if part.FunctionCall != nil && part.FunctionCall.Name != "" {
state.syntheticToolIndex++
if !provider.emitToolCall(ctx, *part.FunctionCall, state.syntheticToolIndex, events) {
state.done = true
return false
}
}
}
}
for _, functionCall := range payload.FunctionCalls {
if functionCall.Name == "" {
continue
}
state.syntheticToolIndex++
if !provider.emitToolCall(ctx, functionCall, state.syntheticToolIndex, events) {
state.done = true
return false
}
}
return true
}
func (provider *Provider) emitDone(ctx context.Context, state streamState, events chan<- zeroruntime.StreamEvent) {
if state.hasUsage {
usage, err := zeroruntime.NormalizeUsage(zeroruntime.TokenUsage{
InputTokens: state.inputTokens,
OutputTokens: state.outputTokens,
ReasoningTokens: state.reasoningTokens,
})
if err == nil {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventUsage, Usage: usage})
}
}
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
state.done = true
}
func (provider *Provider) emitToolCall(ctx context.Context, functionCall functionCall, syntheticIndex int, events chan<- zeroruntime.StreamEvent) bool {
id := functionCall.ID
if id == "" {
id = fmt.Sprintf("gemini_tool_%d", syntheticIndex)
}
args, err := normalizeFunctionCallArgs(firstNonNil(functionCall.Args, functionCall.Arguments), functionCall.Name)
if err != nil {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider error: " + err.Error())})
return false
}
encoded, err := json.Marshal(args)
if err != nil {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider error: " + err.Error())})
return false
}
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: id, ToolName: functionCall.Name})
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: id, ArgumentsFragment: string(encoded)})
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: id})
return true
}
func (provider *Provider) emitHTTPError(ctx context.Context, response *http.Response, events chan<- zeroruntime.StreamEvent) {
body, _ := io.ReadAll(io.LimitReader(response.Body, 64*1024))
message := response.Status
if parsed := parseErrorMessage(body); parsed != "" {
message = parsed
} else if trimmed := strings.TrimSpace(string(body)); trimmed != "" {
message = trimmed
}
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.classifiedError(response.StatusCode, message),
})
}
func (provider *Provider) geminiRequest(request zeroruntime.CompletionRequest) (generateContentRequest, error) {
systemInstruction, contents, err := mapMessages(request.Messages)
if err != nil {
return generateContentRequest{}, err
}
if len(contents) == 0 {
return generateContentRequest{}, errors.New("zero Gemini provider requires at least one non-system message")
}
mapped := generateContentRequest{
SystemInstruction: systemInstruction,
Contents: contents,
GenerationConfig: generationConfig{MaxOutputTokens: provider.maxTokens},
}
if len(request.Tools) > 0 {
declarations := make([]geminiFunctionDeclaration, 0, len(request.Tools))
for _, tool := range request.Tools {
declarations = append(declarations, geminiFunctionDeclaration{
Name: tool.Name,
Description: tool.Description,
Parameters: tool.Parameters,
})
}
mapped.Tools = []geminiToolGroup{{FunctionDeclarations: declarations}}
}
return mapped, nil
}
func mapMessages(messages []zeroruntime.Message) (*geminiContent, []geminiContent, error) {
systemParts := []geminiPart{}
contents := []geminiContent{}
toolNamesByID := make(map[string]string)
for _, message := range messages {
content := message.Content
hasContent := strings.TrimSpace(content) != ""
switch message.Role {
case zeroruntime.MessageRoleSystem:
if hasContent {
systemParts = append(systemParts, geminiPart{Text: content})
}
case zeroruntime.MessageRoleTool:
if message.ToolCallID == "" {
return nil, nil, errors.New("zero Gemini provider requires toolCallId on tool result messages")
}
name := toolNamesByID[message.ToolCallID]
if name == "" {
name = message.ToolCallID
}
appendUserParts(&contents, []geminiPart{{
FunctionResponse: &geminiFunctionResponse{
ID: message.ToolCallID,
Name: name,
Response: map[string]interface{}{"result": content},
},
}})
case zeroruntime.MessageRoleAssistant:
parts := []geminiPart{}
if hasContent {
parts = append(parts, geminiPart{Text: content})
}
for _, toolCall := range message.ToolCalls {
args, err := parseToolArguments(toolCall.Arguments, toolCall.Name)
if err != nil {
return nil, nil, err
}
toolNamesByID[toolCall.ID] = toolCall.Name
parts = append(parts, geminiPart{FunctionCall: &geminiFunctionCall{
ID: toolCall.ID,
Name: toolCall.Name,
Args: args,
}})
}
if len(parts) > 0 {
contents = append(contents, geminiContent{Role: "model", Parts: parts})
}
default:
if hasContent {
appendUserParts(&contents, []geminiPart{{Text: content}})
}
}
}
var systemInstruction *geminiContent
if len(systemParts) > 0 {
systemInstruction = &geminiContent{Parts: systemParts}
}
return systemInstruction, contents, nil
}
func appendUserParts(contents *[]geminiContent, parts []geminiPart) {
if len(*contents) > 0 && (*contents)[len(*contents)-1].Role == "user" {
(*contents)[len(*contents)-1].Parts = append((*contents)[len(*contents)-1].Parts, parts...)
return
}
*contents = append(*contents, geminiContent{Role: "user", Parts: parts})
}
func parseToolArguments(argumentsJSON string, toolName string) (map[string]any, error) {
if strings.TrimSpace(argumentsJSON) == "" {
return map[string]any{}, nil
}
var parsed any
if err := json.Unmarshal([]byte(argumentsJSON), &parsed); err != nil {
return nil, fmt.Errorf("zero Gemini provider could not parse tool arguments for %s as JSON", toolName)
}
object, ok := parsed.(map[string]any)
if !ok || object == nil {
return nil, fmt.Errorf("zero Gemini provider requires tool arguments for %s to be a JSON object", toolName)
}
return object, nil
}
func normalizeFunctionCallArgs(value any, toolName string) (map[string]any, error) {
if value == nil {
return map[string]any{}, nil
}
object, ok := value.(map[string]any)
if !ok || object == nil {
return nil, fmt.Errorf("zero Gemini provider requires streamed tool arguments for %s to be a JSON object", toolName)
}
return object, nil
}
func parseErrorMessage(body []byte) string {
var parsed struct {
Error apiError `json:"error"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return ""
}
return firstNonEmpty(parsed.Error.Message, parsed.Message)
}
func (provider *Provider) streamURL() string {
return provider.baseURL + "/v1beta/models/" + url.PathEscape(provider.model) + ":streamGenerateContent?alt=sse"
}
func (provider *Provider) classifiedError(statusCode int, message string) string {
return providerio.ClassifiedError(statusCode, message, provider.apiKey)
}
func (provider *Provider) redact(message string) string {
return providerio.Redact(message, provider.apiKey)
}
func normalizeModel(model string) string {
model = strings.TrimSpace(model)
model = strings.TrimPrefix(model, "models/")
return strings.TrimSpace(model)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}
func firstNonNil(values ...any) any {
for _, value := range values {
if value != nil {
return value
}
}
return nil
}
type streamState struct {
inputTokens int
outputTokens int
reasoningTokens int
hasUsage bool
syntheticToolIndex int
done bool
}
-344
View File
@@ -1,344 +0,0 @@
package gemini
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestStreamCompletionPostsGenerateContentRequest(t *testing.T) {
var gotPath string
var gotQuery string
var gotAPIKey string
var gotUserAgent string
var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotQuery = r.URL.RawQuery
gotAPIKey = r.Header.Get("x-goog-api-key")
gotUserAgent = r.Header.Get("User-Agent")
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSE(w, `{"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":1}}`)
}))
defer server.Close()
provider, err := New(Options{
APIKey: "sk-google",
BaseURL: server.URL + "/",
Model: "models/gemini-2.5-flash",
MaxTokens: 65_536,
UserAgent: "zero-test",
})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "You are Zero."},
{Role: zeroruntime.MessageRoleUser, Content: "Read the file."},
{
Role: zeroruntime.MessageRoleAssistant,
Content: "I will inspect it.",
ToolCalls: []zeroruntime.ToolCall{{
ID: "call_1",
Name: "read_file",
Arguments: `{"path":"src/index.ts"}`,
}},
},
{Role: zeroruntime.MessageRoleTool, Content: "file contents", ToolCallID: "call_1"},
{Role: zeroruntime.MessageRoleUser, Content: "Now grep for Zero."},
},
Tools: []zeroruntime.ToolDefinition{{
Name: "read_file",
Description: "Read a file",
Parameters: map[string]any{"type": "object", "properties": map[string]any{"path": map[string]any{"type": "string"}}},
}},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
if gotPath != "/v1beta/models/gemini-2.5-flash:streamGenerateContent" {
t.Fatalf("path = %q, want Gemini stream path", gotPath)
}
if gotQuery != "alt=sse" {
t.Fatalf("query = %q, want alt=sse", gotQuery)
}
if gotAPIKey != "sk-google" {
t.Fatalf("x-goog-api-key = %q, want key", gotAPIKey)
}
if gotUserAgent != "zero-test" {
t.Fatalf("User-Agent = %q, want zero-test", gotUserAgent)
}
systemInstruction := gotBody["systemInstruction"].(map[string]any)
if _, ok := systemInstruction["role"]; ok {
t.Fatalf("systemInstruction = %#v, want omitted role", systemInstruction)
}
generationConfig := gotBody["generationConfig"].(map[string]any)
if generationConfig["maxOutputTokens"] != float64(65_536) {
t.Fatalf("maxOutputTokens = %#v, want 65536", generationConfig["maxOutputTokens"])
}
contents := gotBody["contents"].([]any)
if len(contents) != 3 {
t.Fatalf("contents = %#v, want user, model, merged user", contents)
}
modelTurn := contents[1].(map[string]any)
modelParts := modelTurn["parts"].([]any)
functionCall := modelParts[1].(map[string]any)["functionCall"].(map[string]any)
if functionCall["id"] != "call_1" || functionCall["name"] != "read_file" {
t.Fatalf("unexpected functionCall: %#v", functionCall)
}
args := functionCall["args"].(map[string]any)
if args["path"] != "src/index.ts" {
t.Fatalf("functionCall args = %#v, want path", args)
}
mergedUserParts := contents[2].(map[string]any)["parts"].([]any)
if mergedUserParts[0].(map[string]any)["functionResponse"].(map[string]any)["name"] != "read_file" {
t.Fatalf("unexpected functionResponse: %#v", mergedUserParts[0])
}
if mergedUserParts[1].(map[string]any)["text"] != "Now grep for Zero." {
t.Fatalf("user text after tool result was not merged: %#v", mergedUserParts)
}
tools := gotBody["tools"].([]any)
declarations := tools[0].(map[string]any)["functionDeclarations"].([]any)
if declarations[0].(map[string]any)["name"] != "read_file" {
t.Fatalf("unexpected tool declarations: %#v", declarations)
}
}
func TestStreamCompletionEmitsTextUsageAndReasoningTokens(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"candidates":[{"content":{"parts":[{"text":"Hello"}]}}]}`)
writeSSE(w, `{"candidates":[{"content":{"parts":[{"text":" Zero"}]}}],"usageMetadata":{"promptTokenCount":25,"candidatesTokenCount":15,"thoughtsTokenCount":3}}`)
})
events := collectProviderEvents(t, provider)
want := []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventText, Content: "Hello"},
{Type: zeroruntime.StreamEventText, Content: " Zero"},
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 25, OutputTokens: 15, PromptTokens: 25, CompletionTokens: 15, ReasoningTokens: 3}},
{Type: zeroruntime.StreamEventDone},
}
if !reflect.DeepEqual(events, want) {
t.Fatalf("events = %#v, want %#v", events, want)
}
}
func TestStreamCompletionEmitsCandidateFunctionCalls(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"candidates":[{"content":{"parts":[{"functionCall":{"id":"call_1","name":"read_file","args":{"path":"src/index.ts"}}},{"functionCall":{"id":"call_2","name":"grep","args":{"pattern":"Zero"}}}]}}]}`)
})
events := collectProviderEvents(t, provider)
want := []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "read_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{"path":"src/index.ts"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_2", ToolName: "grep"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_2", ArgumentsFragment: `{"pattern":"Zero"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_2"},
{Type: zeroruntime.StreamEventDone},
}
if !reflect.DeepEqual(events, want) {
t.Fatalf("events = %#v, want %#v", events, want)
}
}
func TestStreamCompletionEmitsTopLevelFunctionCalls(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"functionCalls":[{"id":"call_1","name":"read_file","args":{"path":"README.md"}}]}`)
})
events := collectProviderEvents(t, provider)
want := []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "read_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{"path":"README.md"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
{Type: zeroruntime.StreamEventDone},
}
if !reflect.DeepEqual(events, want) {
t.Fatalf("events = %#v, want %#v", events, want)
}
}
func TestStreamCompletionUsesSyntheticToolIDsWhenMissing(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"functionCalls":[{"name":"grep","args":{"pattern":"Zero"}}]}`)
})
events := collectProviderEvents(t, provider)
if events[0].ToolCallID != "gemini_tool_1" || events[0].ToolName != "grep" {
t.Fatalf("events = %#v, want synthetic tool id", events)
}
}
func TestStreamCompletionClassifiesHTTPAndPromptBlockErrors(t *testing.T) {
authProvider := newTestProviderWithKey(t, "sk-google", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":{"message":"API key not valid"}}`, http.StatusUnauthorized)
})
stream, err := authProvider.StreamCompletion(context.Background(), validRequest())
if err != nil {
t.Fatalf("StreamCompletion returned setup error: %v", err)
}
events := readAll(stream)
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError || !strings.HasPrefix(events[0].Error, "auth error:") {
t.Fatalf("events = %#v, want auth error", events)
}
if strings.Contains(events[0].Error, "sk-google") {
t.Fatalf("error leaked token: %q", events[0].Error)
}
blockProvider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"promptFeedback":{"blockReason":"SAFETY","blockReasonMessage":"blocked by policy"}}`)
})
events = collectProviderEvents(t, blockProvider)
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError || !strings.Contains(events[0].Error, "Content blocked: blocked by policy") {
t.Fatalf("events = %#v, want content block error", events)
}
}
func TestStreamCompletionEmitsStreamErrorObject(t *testing.T) {
provider := newTestProviderWithKey(t, "sk-google", func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"error":{"code":429,"message":"stream failed sk-google","status":"RESOURCE_EXHAUSTED"}}`)
})
events := collectProviderEvents(t, provider)
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
t.Fatalf("events = %#v, want one error", events)
}
if !strings.HasPrefix(events[0].Error, "rate limit error:") {
t.Fatalf("error = %q, want rate limit error prefix", events[0].Error)
}
if strings.Contains(events[0].Error, "sk-google") {
t.Fatalf("error leaked token: %q", events[0].Error)
}
}
func TestStreamCompletionStopsOnMalformedStreamToolArgs(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"functionCalls":[{"id":"call_1","name":"grep","args":"raw"}]}`)
writeSSE(w, `{"candidates":[{"content":{"parts":[{"text":"should not emit"}]}}]}`)
})
events := collectProviderEvents(t, provider)
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
t.Fatalf("events = %#v, want one error", events)
}
if !strings.Contains(events[0].Error, "streamed tool arguments for grep") {
t.Fatalf("error = %q, want streamed tool arguments error", events[0].Error)
}
if len(eventsOfType(events, zeroruntime.StreamEventDone)) != 0 {
t.Fatalf("events = %#v, want no done after stream tool arg error", events)
}
}
func TestStreamCompletionRejectsMalformedHistoryBeforeDispatch(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("provider should not dispatch malformed history")
})
_, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleTool, Content: "missing id"}},
})
if err == nil || !strings.Contains(err.Error(), "requires toolCallId") {
t.Fatalf("error = %v, want missing toolCallId", err)
}
_, err = provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: "call tool"},
{
Role: zeroruntime.MessageRoleAssistant,
ToolCalls: []zeroruntime.ToolCall{{ID: "call_1", Name: "read_file", Arguments: `"src/index.ts"`}},
},
},
})
if err == nil || !strings.Contains(err.Error(), "requires tool arguments for read_file to be a JSON object") {
t.Fatalf("error = %v, want non-object tool argument error", err)
}
}
func TestNewRequiresModelAndPositiveMaxTokens(t *testing.T) {
if _, err := New(Options{}); err == nil {
t.Fatal("New without model returned nil error")
}
if _, err := New(Options{Model: "gemini-test", MaxTokens: -1}); err == nil {
t.Fatal("New with negative max tokens returned nil error")
}
}
func newTestProvider(t *testing.T, handler http.HandlerFunc) *Provider {
t.Helper()
return newTestProviderWithKey(t, "", handler)
}
func newTestProviderWithKey(t *testing.T, apiKey string, handler http.HandlerFunc) *Provider {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
provider, err := New(Options{
APIKey: apiKey,
BaseURL: server.URL,
Model: "gemini-test",
})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
return provider
}
func collectProviderEvents(t *testing.T, provider *Provider) []zeroruntime.StreamEvent {
t.Helper()
stream, err := provider.StreamCompletion(context.Background(), validRequest())
if err != nil {
t.Fatalf("StreamCompletion returned setup error: %v", err)
}
return readAll(stream)
}
func validRequest() zeroruntime.CompletionRequest {
return zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
}
}
func readAll(stream <-chan zeroruntime.StreamEvent) []zeroruntime.StreamEvent {
events := []zeroruntime.StreamEvent{}
for event := range stream {
events = append(events, event)
}
return events
}
func drain(stream <-chan zeroruntime.StreamEvent) {
for range stream {
}
}
func writeSSE(w http.ResponseWriter, payload string) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: " + payload + "\n\n"))
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
}
func eventsOfType(events []zeroruntime.StreamEvent, eventType zeroruntime.StreamEventType) []zeroruntime.StreamEvent {
matching := []zeroruntime.StreamEvent{}
for _, event := range events {
if event.Type == eventType {
matching = append(matching, event)
}
}
return matching
}
-93
View File
@@ -1,93 +0,0 @@
package gemini
type generateContentRequest struct {
SystemInstruction *geminiContent `json:"systemInstruction,omitempty"`
Contents []geminiContent `json:"contents"`
GenerationConfig generationConfig `json:"generationConfig"`
Tools []geminiToolGroup `json:"tools,omitempty"`
}
type generationConfig struct {
MaxOutputTokens int `json:"maxOutputTokens"`
}
type geminiContent struct {
Role string `json:"role,omitempty"`
Parts []geminiPart `json:"parts"`
}
type geminiPart struct {
Text string `json:"text,omitempty"`
FunctionCall *geminiFunctionCall `json:"functionCall,omitempty"`
FunctionResponse *geminiFunctionResponse `json:"functionResponse,omitempty"`
}
type geminiFunctionCall struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Args map[string]any `json:"args,omitempty"`
}
type geminiFunctionResponse struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Response map[string]interface{} `json:"response"`
}
type geminiToolGroup struct {
FunctionDeclarations []geminiFunctionDeclaration `json:"functionDeclarations"`
}
type geminiFunctionDeclaration struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters map[string]any `json:"parameters,omitempty"`
}
type streamPayload struct {
Candidates []candidate `json:"candidates"`
FunctionCalls []functionCall `json:"functionCalls"`
PromptFeedback *promptFeedback `json:"promptFeedback"`
UsageMetadata *usageMetadata `json:"usageMetadata"`
Error *apiError `json:"error"`
}
type candidate struct {
Content *candidateContent `json:"content"`
FinishReason string `json:"finishReason"`
}
type candidateContent struct {
Role string `json:"role"`
Parts []streamPart `json:"parts"`
}
type streamPart struct {
Text string `json:"text"`
FunctionCall *functionCall `json:"functionCall"`
}
type functionCall struct {
ID string `json:"id"`
Name string `json:"name"`
Args any `json:"args"`
Arguments any `json:"arguments"`
}
type promptFeedback struct {
BlockReason string `json:"blockReason"`
BlockReasonMessage string `json:"blockReasonMessage"`
}
type usageMetadata struct {
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
}
type apiError struct {
Code int `json:"code"`
Message string `json:"message"`
Status string `json:"status"`
}
-306
View File
@@ -1,306 +0,0 @@
package openai
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const defaultBaseURL = "https://api.openai.com/v1"
// Options configures an OpenAI-compatible chat completions provider.
type Options struct {
APIKey string
BaseURL string
Model string
HTTPClient *http.Client
UserAgent string
}
// Provider streams completions from an OpenAI-compatible chat completions API.
type Provider struct {
apiKey string
baseURL string
model string
httpClient *http.Client
userAgent string
}
// New creates an OpenAI-compatible provider.
func New(options Options) (*Provider, error) {
model := strings.TrimSpace(options.Model)
if model == "" {
return nil, errors.New("openai provider requires a model")
}
baseURL := strings.TrimSpace(options.BaseURL)
if baseURL == "" {
baseURL = defaultBaseURL
}
baseURL = strings.TrimRight(baseURL, "/")
if _, err := url.ParseRequestURI(baseURL); err != nil {
return nil, fmt.Errorf("invalid OpenAI base URL: %w", err)
}
httpClient := options.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
return &Provider{
apiKey: options.APIKey,
baseURL: baseURL,
model: model,
httpClient: httpClient,
userAgent: options.UserAgent,
}, nil
}
// StreamCompletion sends one streaming chat completion request.
func (provider *Provider) StreamCompletion(
ctx context.Context,
request zeroruntime.CompletionRequest,
) (<-chan zeroruntime.StreamEvent, error) {
body, err := json.Marshal(provider.openAIRequest(request))
if err != nil {
return nil, fmt.Errorf("encode OpenAI request: %w", err)
}
events := make(chan zeroruntime.StreamEvent, 16)
go func() {
defer close(events)
provider.stream(ctx, body, events)
}()
return events, nil
}
func (provider *Provider) stream(ctx context.Context, body []byte, events chan<- zeroruntime.StreamEvent) {
endpoint := provider.baseURL + "/chat/completions"
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider request error: " + err.Error())})
return
}
request.Header.Set("Content-Type", "application/json")
if provider.userAgent != "" {
request.Header.Set("User-Agent", provider.userAgent)
}
if provider.apiKey != "" {
request.Header.Set("Authorization", "Bearer "+provider.apiKey)
}
response, err := provider.httpClient.Do(request)
if err != nil {
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
return
}
defer func() {
_ = response.Body.Close()
}()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
provider.emitHTTPError(ctx, response, events)
return
}
state := newToolState()
scanner := bufio.NewScanner(response.Body)
scanner.Buffer(make([]byte, 0, 4096), 16*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "" {
continue
}
if data == "[DONE]" {
state.closeOpen(ctx, events)
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
return
}
var chunk streamChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
state.closeOpen(ctx, events)
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.redact("provider stream error: malformed JSON: " + err.Error()),
})
return
}
if chunk.Error != nil {
state.closeOpen(ctx, events)
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.classifiedError(http.StatusInternalServerError, chunk.Error.Message),
})
return
}
provider.emitChunk(ctx, chunk, state, events)
}
state.closeOpen(ctx, events)
if err := scanner.Err(); err != nil {
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
return
}
if err := ctx.Err(); err != nil {
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
return
}
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
}
func (provider *Provider) emitChunk(
ctx context.Context,
chunk streamChunk,
state *toolState,
events chan<- zeroruntime.StreamEvent,
) {
for _, choice := range chunk.Choices {
if choice.Delta.Content != "" {
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventText,
Content: choice.Delta.Content,
})
}
for _, toolCall := range choice.Delta.ToolCalls {
state.applyDelta(ctx, toolCall, events)
}
if choice.FinishReason == "tool_calls" {
state.closeOpen(ctx, events)
}
}
if chunk.Usage != nil {
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventUsage,
Usage: zeroruntime.Usage{
PromptTokens: chunk.Usage.PromptTokens,
CompletionTokens: chunk.Usage.CompletionTokens,
CachedInputTokens: chunk.Usage.PromptTokensDetails.CachedTokens,
},
})
}
}
func (provider *Provider) emitHTTPError(ctx context.Context, response *http.Response, events chan<- zeroruntime.StreamEvent) {
body, _ := io.ReadAll(io.LimitReader(response.Body, 64*1024))
message := strings.TrimSpace(string(body))
var parsed struct {
Error apiError `json:"error"`
}
if err := json.Unmarshal(body, &parsed); err == nil && parsed.Error.Message != "" {
message = parsed.Error.Message
}
if message == "" {
message = response.Status
}
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.classifiedError(response.StatusCode, message),
})
}
func (provider *Provider) classifiedError(statusCode int, message string) string {
prefix := "provider error: "
switch statusCode {
case http.StatusUnauthorized, http.StatusForbidden:
prefix = "auth error: "
case http.StatusTooManyRequests:
prefix = "rate limit error: "
default:
if statusCode >= http.StatusBadRequest && statusCode < http.StatusInternalServerError {
prefix = "provider request error: "
}
}
return provider.redact(prefix + message)
}
func (provider *Provider) redact(message string) string {
if provider.apiKey != "" {
message = strings.ReplaceAll(message, provider.apiKey, "[REDACTED]")
}
words := strings.Fields(message)
for index := 0; index < len(words)-1; index++ {
if strings.EqualFold(strings.TrimRight(words[index], ":"), "Bearer") {
words[index] = "authorization"
words[index+1] = "[REDACTED]"
}
}
return strings.Join(words, " ")
}
func sendEvent(ctx context.Context, events chan<- zeroruntime.StreamEvent, event zeroruntime.StreamEvent) {
select {
case <-ctx.Done():
if event.Type == zeroruntime.StreamEventError {
select {
case events <- event:
default:
}
}
case events <- event:
}
}
func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) chatCompletionRequest {
messages := make([]chatMessage, 0, len(request.Messages))
for _, message := range request.Messages {
messages = append(messages, mapMessage(message))
}
mapped := chatCompletionRequest{
Model: provider.model,
Messages: messages,
Stream: true,
}
if len(request.Tools) > 0 {
mapped.Tools = make([]toolDefinition, 0, len(request.Tools))
for _, tool := range request.Tools {
mapped.Tools = append(mapped.Tools, toolDefinition{
Type: "function",
Function: toolFunction{
Name: tool.Name,
Description: tool.Description,
Parameters: tool.Parameters,
},
})
}
}
return mapped
}
func mapMessage(message zeroruntime.Message) chatMessage {
mapped := chatMessage{
Role: string(message.Role),
Content: message.Content,
ToolCallID: message.ToolCallID,
}
if len(message.ToolCalls) > 0 {
mapped.ToolCalls = make([]requestToolCall, 0, len(message.ToolCalls))
for _, toolCall := range message.ToolCalls {
mapped.ToolCalls = append(mapped.ToolCalls, requestToolCall{
ID: toolCall.ID,
Type: "function",
Function: requestToolCallFunction{
Name: toolCall.Name,
Arguments: toolCall.Arguments,
},
})
}
}
return mapped
}
-419
View File
@@ -1,419 +0,0 @@
package openai
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestStreamCompletionPostsChatCompletionRequest(t *testing.T) {
var gotPath string
var gotAuth string
var gotUserAgent string
var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
gotUserAgent = r.Header.Get("User-Agent")
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSE(w, `{"choices":[]}`)
writeSSE(w, `[DONE]`)
}))
defer server.Close()
provider, err := New(Options{
APIKey: "sk-secret",
BaseURL: server.URL + "/",
Model: "gpt-test",
UserAgent: "zero-test",
})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
{Role: zeroruntime.MessageRoleUser, Content: "user"},
{
Role: zeroruntime.MessageRoleAssistant,
Content: "using a tool",
ToolCalls: []zeroruntime.ToolCall{{
ID: "call_1",
Name: "read_file",
Arguments: `{"path":"README.md"}`,
}},
},
{Role: zeroruntime.MessageRoleTool, Content: "contents", ToolCallID: "call_1"},
},
Tools: []zeroruntime.ToolDefinition{{
Name: "read_file",
Description: "Read a file",
Parameters: map[string]any{"type": "object"},
}},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
if gotPath != "/chat/completions" {
t.Fatalf("path = %q, want /chat/completions", gotPath)
}
if gotAuth != "Bearer sk-secret" {
t.Fatalf("auth = %q, want bearer token", gotAuth)
}
if gotUserAgent != "zero-test" {
t.Fatalf("user agent = %q, want zero-test", gotUserAgent)
}
if gotBody["model"] != "gpt-test" || gotBody["stream"] != true {
t.Fatalf("unexpected model/stream: %#v", gotBody)
}
if _, ok := gotBody["stream_options"]; ok {
t.Fatalf("stream_options should be omitted in M0 request: %#v", gotBody["stream_options"])
}
messages := gotBody["messages"].([]any)
assistant := messages[2].(map[string]any)
toolCalls := assistant["tool_calls"].([]any)
toolCall := toolCalls[0].(map[string]any)
if toolCall["id"] != "call_1" || toolCall["type"] != "function" {
t.Fatalf("unexpected assistant tool call: %#v", toolCall)
}
function := toolCall["function"].(map[string]any)
if function["name"] != "read_file" || function["arguments"] != `{"path":"README.md"}` {
t.Fatalf("unexpected tool call function: %#v", function)
}
toolResult := messages[3].(map[string]any)
if toolResult["role"] != "tool" || toolResult["tool_call_id"] != "call_1" {
t.Fatalf("unexpected tool result message: %#v", toolResult)
}
tools := gotBody["tools"].([]any)
tool := tools[0].(map[string]any)
if tool["type"] != "function" {
t.Fatalf("unexpected tool wrapper: %#v", tool)
}
}
func TestNewRequiresModelButNotAPIKey(t *testing.T) {
if _, err := New(Options{}); err == nil {
t.Fatal("New without model returned nil error")
}
if _, err := New(Options{Model: "gpt-test"}); err != nil {
t.Fatalf("New without API key returned error: %v", err)
}
}
func TestStreamCompletionOmitsAuthAndToolsWhenEmpty(t *testing.T) {
var gotAuth string
var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSE(w, `[DONE]`)
}))
defer server.Close()
provider, err := New(Options{BaseURL: server.URL, Model: "local-model"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
if gotAuth != "" {
t.Fatalf("auth = %q, want empty", gotAuth)
}
if _, ok := gotBody["tools"]; ok {
t.Fatalf("tools present for empty tools: %#v", gotBody["tools"])
}
}
func TestStreamCompletionEmitsTextUsageAndDone(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"delta":{"content":"hello "}}]}`)
writeSSE(w, `{"choices":[{"delta":{"content":"zero"}}],"usage":{"prompt_tokens":12,"completion_tokens":5,"prompt_tokens_details":{"cached_tokens":3}}}`)
writeSSE(w, `[DONE]`)
})
events := collectProviderEvents(t, provider)
assertEvent(t, events[0], zeroruntime.StreamEventText, "hello ")
assertEvent(t, events[1], zeroruntime.StreamEventText, "zero")
if events[2].Type != zeroruntime.StreamEventUsage || events[2].Usage.PromptTokens != 12 || events[2].Usage.CompletionTokens != 5 || events[2].Usage.CachedInputTokens != 3 {
t.Fatalf("unexpected usage event: %#v", events[2])
}
if events[3].Type != zeroruntime.StreamEventDone {
t.Fatalf("last event = %#v, want done", events[3])
}
}
func TestStreamCompletionBuffersToolArgsUntilIDAndNameArrive(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"path\":"}}]}}]}`)
writeSSE(w, `{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_file","arguments":"\"README.md\"}"}}]},"finish_reason":"tool_calls"}]}`)
writeSSE(w, `[DONE]`)
})
events := collectProviderEvents(t, provider)
if len(events) != 4 {
t.Fatalf("events = %#v, want start, delta, end, done", events)
}
if events[0].Type != zeroruntime.StreamEventToolCallStart || events[0].ToolCallID != "call_1" || events[0].ToolName != "read_file" {
t.Fatalf("unexpected start event: %#v", events[0])
}
if events[1].Type != zeroruntime.StreamEventToolCallDelta || events[1].ToolCallID != "call_1" || events[1].ArgumentsFragment != `{"path":"README.md"}` {
t.Fatalf("unexpected delta event: %#v", events[1])
}
if events[2].Type != zeroruntime.StreamEventToolCallEnd || events[2].ToolCallID != "call_1" {
t.Fatalf("unexpected end event: %#v", events[2])
}
if events[3].Type != zeroruntime.StreamEventDone {
t.Fatalf("unexpected done event: %#v", events[3])
}
}
func TestStreamCompletionTracksMultipleToolCallsByIndex(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\"query\":"}},{"index":0,"id":"call_a","function":{"name":"read_file","arguments":"{\"path\":\"a\"}"}}]}}]}`)
writeSSE(w, `{"choices":[{"delta":{"tool_calls":[{"index":1,"id":"call_b","function":{"name":"grep","arguments":"\"zero\"}"}}]},"finish_reason":"tool_calls"}]}`)
writeSSE(w, `[DONE]`)
})
events := collectProviderEvents(t, provider)
starts := eventsOfType(events, zeroruntime.StreamEventToolCallStart)
deltas := eventsOfType(events, zeroruntime.StreamEventToolCallDelta)
ends := eventsOfType(events, zeroruntime.StreamEventToolCallEnd)
if len(starts) != 2 || len(deltas) != 2 || len(ends) != 2 {
t.Fatalf("events = %#v, want two starts/deltas/ends", events)
}
if deltas[0].ToolCallID != "call_a" || deltas[0].ArgumentsFragment != `{"path":"a"}` {
t.Fatalf("unexpected first delta: %#v", deltas[0])
}
if deltas[1].ToolCallID != "call_b" || deltas[1].ArgumentsFragment != `{"query":"zero"}` {
t.Fatalf("unexpected second delta: %#v", deltas[1])
}
}
func TestStreamCompletionClassifiesHTTPErrorsAndRedactsToken(t *testing.T) {
cases := []struct {
name string
status int
body string
wantPrefix string
}{
{"auth", http.StatusUnauthorized, `{"error":{"message":"bad key sk-secret","type":"invalid_request_error"}}`, "auth error:"},
{"rate limit", http.StatusTooManyRequests, `{"error":{"message":"slow down"}}`, "rate limit error:"},
{"bad request", http.StatusBadRequest, `{"error":{"message":"bad request"}}`, "provider request error:"},
{"server", http.StatusInternalServerError, `server saw Bearer sk-secret`, "provider error:"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
provider := newTestProviderWithKey(t, "sk-secret", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, tc.body, tc.status)
})
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{})
if err != nil {
t.Fatalf("StreamCompletion returned setup error: %v", err)
}
events := readAll(stream)
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
t.Fatalf("events = %#v, want one error", events)
}
if !strings.HasPrefix(events[0].Error, tc.wantPrefix) {
t.Fatalf("error = %q, want prefix %q", events[0].Error, tc.wantPrefix)
}
if strings.Contains(events[0].Error, "sk-secret") || strings.Contains(events[0].Error, "Bearer ") {
t.Fatalf("error leaked token: %q", events[0].Error)
}
})
}
}
func TestStreamCompletionEmitsStreamErrorObject(t *testing.T) {
provider := newTestProviderWithKey(t, "sk-secret", func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"error":{"message":"stream failed sk-secret","type":"server_error"}}`)
})
events := collectProviderEvents(t, provider)
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
t.Fatalf("events = %#v, want one error", events)
}
if !strings.HasPrefix(events[0].Error, "provider error:") {
t.Fatalf("error = %q, want provider error prefix", events[0].Error)
}
if strings.Contains(events[0].Error, "sk-secret") {
t.Fatalf("error leaked token: %q", events[0].Error)
}
}
func TestStreamCompletionEmitsErrorForMalformedJSON(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":`)
})
events := collectProviderEvents(t, provider)
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
t.Fatalf("events = %#v, want one error", events)
}
if !strings.HasPrefix(events[0].Error, "provider stream error: malformed JSON") {
t.Fatalf("error = %q, want malformed JSON provider stream error", events[0].Error)
}
}
func TestStreamCompletionEmitsErrorWhenContextCancels(t *testing.T) {
requestStarted := make(chan struct{})
release := make(chan struct{})
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
close(requestStarted)
<-release
})
ctx, cancel := context.WithCancel(context.Background())
stream, err := provider.StreamCompletion(ctx, zeroruntime.CompletionRequest{})
if err != nil {
t.Fatalf("StreamCompletion returned setup error: %v", err)
}
<-requestStarted
cancel()
close(release)
events := readAll(stream)
if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError {
t.Fatalf("events = %#v, want context error", events)
}
if !strings.Contains(events[0].Error, "context canceled") {
t.Fatalf("error = %q, want context canceled", events[0].Error)
}
}
func newTestProvider(t *testing.T, handler http.HandlerFunc) *Provider {
t.Helper()
return newTestProviderWithKey(t, "", handler)
}
func newTestProviderWithKey(t *testing.T, apiKey string, handler http.HandlerFunc) *Provider {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
provider, err := New(Options{
APIKey: apiKey,
BaseURL: server.URL,
Model: "gpt-test",
})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
return provider
}
func collectProviderEvents(t *testing.T, provider *Provider) []zeroruntime.StreamEvent {
t.Helper()
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{})
if err != nil {
t.Fatalf("StreamCompletion returned setup error: %v", err)
}
return readAll(stream)
}
func readAll(stream <-chan zeroruntime.StreamEvent) []zeroruntime.StreamEvent {
events := []zeroruntime.StreamEvent{}
for event := range stream {
events = append(events, event)
}
return events
}
func drain(stream <-chan zeroruntime.StreamEvent) {
for range stream {
}
}
func writeSSE(w http.ResponseWriter, payload string) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: " + payload + "\n\n"))
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
}
func assertEvent(t *testing.T, event zeroruntime.StreamEvent, eventType zeroruntime.StreamEventType, content string) {
t.Helper()
if event.Type != eventType || event.Content != content {
t.Fatalf("event = %#v, want %s %q", event, eventType, content)
}
}
func eventsOfType(events []zeroruntime.StreamEvent, eventType zeroruntime.StreamEventType) []zeroruntime.StreamEvent {
matching := []zeroruntime.StreamEvent{}
for _, event := range events {
if event.Type == eventType {
matching = append(matching, event)
}
}
return matching
}
func TestStreamCompletionDoesNotHangOnEOFWithOpenToolCall(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_file","arguments":"{\"path\":\"README.md\"}"}}]}}]}`)
})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
stream, err := provider.StreamCompletion(ctx, zeroruntime.CompletionRequest{})
if err != nil {
t.Fatalf("StreamCompletion returned setup error: %v", err)
}
events := []zeroruntime.StreamEvent{}
for {
select {
case event, ok := <-stream:
if !ok {
if len(eventsOfType(events, zeroruntime.StreamEventToolCallEnd)) != 1 {
t.Fatalf("events = %#v, want one tool-call-end on EOF", events)
}
if len(eventsOfType(events, zeroruntime.StreamEventDone)) != 1 {
t.Fatalf("events = %#v, want done on EOF", events)
}
return
}
events = append(events, event)
case <-ctx.Done():
t.Fatal("stream did not close")
}
}
}
func TestStreamCompletionSkipsNamelessToolCallOnEOF(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"arguments":"{\"path\":\"README.md\"}"}}]}}]}`)
})
events := collectProviderEvents(t, provider)
if len(eventsOfType(events, zeroruntime.StreamEventToolCallStart)) != 0 {
t.Fatalf("events = %#v, want no start for nameless tool call", events)
}
if len(eventsOfType(events, zeroruntime.StreamEventToolCallDelta)) != 0 {
t.Fatalf("events = %#v, want no delta for nameless tool call", events)
}
if len(eventsOfType(events, zeroruntime.StreamEventToolCallEnd)) != 0 {
t.Fatalf("events = %#v, want no end for nameless tool call", events)
}
if len(eventsOfType(events, zeroruntime.StreamEventDone)) != 1 {
t.Fatalf("events = %#v, want done event", events)
}
}
-103
View File
@@ -1,103 +0,0 @@
package openai
import (
"context"
"sort"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type toolState struct {
calls map[int]*pendingToolCall
}
type pendingToolCall struct {
id string
name string
arguments string
started bool
ended bool
}
func newToolState() *toolState {
return &toolState{calls: make(map[int]*pendingToolCall)}
}
func (state *toolState) applyDelta(
ctx context.Context,
delta streamToolCallDelta,
events chan<- zeroruntime.StreamEvent,
) {
call := state.calls[delta.Index]
if call == nil {
call = &pendingToolCall{}
state.calls[delta.Index] = call
}
if delta.ID != "" {
call.id = delta.ID
}
if delta.Function.Name != "" {
call.name = delta.Function.Name
}
if delta.Function.Arguments != "" {
call.arguments += delta.Function.Arguments
}
if call.id == "" || call.name == "" || call.ended {
return
}
if !call.started {
call.started = true
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallStart,
ToolCallID: call.id,
ToolName: call.name,
})
}
if call.arguments != "" {
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallDelta,
ToolCallID: call.id,
ArgumentsFragment: call.arguments,
})
call.arguments = ""
}
}
func (state *toolState) closeOpen(ctx context.Context, events chan<- zeroruntime.StreamEvent) {
indexes := make([]int, 0, len(state.calls))
for index := range state.calls {
indexes = append(indexes, index)
}
sort.Ints(indexes)
for _, index := range indexes {
call := state.calls[index]
if call == nil || call.ended || call.id == "" || call.name == "" {
continue
}
if !call.started {
call.started = true
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallStart,
ToolCallID: call.id,
ToolName: call.name,
})
}
if call.arguments != "" {
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallDelta,
ToolCallID: call.id,
ArgumentsFragment: call.arguments,
})
call.arguments = ""
}
call.ended = true
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallEnd,
ToolCallID: call.id,
})
}
}
-80
View File
@@ -1,80 +0,0 @@
package openai
type chatCompletionRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Tools []toolDefinition `json:"tools,omitempty"`
Stream bool `json:"stream"`
}
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content,omitempty"`
ToolCalls []requestToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
type requestToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function requestToolCallFunction `json:"function"`
}
type requestToolCallFunction struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type toolDefinition struct {
Type string `json:"type"`
Function toolFunction `json:"function"`
}
type toolFunction struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters map[string]any `json:"parameters,omitempty"`
}
type streamChunk struct {
Choices []streamChoice `json:"choices"`
Usage *usage `json:"usage"`
Error *apiError `json:"error"`
}
type streamChoice struct {
Delta streamDelta `json:"delta"`
FinishReason string `json:"finish_reason"`
}
type streamDelta struct {
Content string `json:"content"`
ToolCalls []streamToolCallDelta `json:"tool_calls"`
}
type streamToolCallDelta struct {
Index int `json:"index"`
ID string `json:"id"`
Function streamFunctionDelta `json:"function"`
}
type streamFunctionDelta struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
PromptTokensDetails promptTokenDetails `json:"prompt_tokens_details"`
}
type promptTokenDetails struct {
CachedTokens int `json:"cached_tokens"`
}
type apiError struct {
Message string `json:"message"`
Type string `json:"type"`
Code any `json:"code"`
}
-132
View File
@@ -1,132 +0,0 @@
package providerio
import (
"bufio"
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const maxSSELineBytes = 16 * 1024 * 1024
// NormalizeBaseURL trims trailing slashes and validates an HTTP API base URL.
func NormalizeBaseURL(baseURL string, defaultBaseURL string, label string) (string, error) {
baseURL = strings.TrimSpace(baseURL)
if baseURL == "" {
baseURL = defaultBaseURL
}
baseURL = strings.TrimRight(baseURL, "/")
if _, err := url.ParseRequestURI(baseURL); err != nil {
return "", fmt.Errorf("invalid %s base URL: %w", label, err)
}
return baseURL, nil
}
// HTTPClient returns the configured client or the process default.
func HTTPClient(client *http.Client) *http.Client {
if client != nil {
return client
}
return http.DefaultClient
}
// SendEvent writes a provider event without blocking cancellation cleanup.
func SendEvent(ctx context.Context, events chan<- zeroruntime.StreamEvent, event zeroruntime.StreamEvent) {
select {
case <-ctx.Done():
if event.Type == zeroruntime.StreamEventError {
select {
case events <- event:
default:
}
}
case events <- event:
}
}
// ScanSSEData parses Server-Sent Event data fields from a streaming response.
func ScanSSEData(reader io.Reader, handle func(data string) bool) error {
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 4096), maxSSELineBytes)
dataLines := []string{}
flush := func() bool {
if len(dataLines) == 0 {
return true
}
data := strings.TrimSpace(strings.Join(dataLines, "\n"))
dataLines = dataLines[:0]
if data == "" || data == "[DONE]" {
return true
}
return handle(data)
}
for scanner.Scan() {
line := strings.TrimRight(scanner.Text(), "\r")
if line == "" {
if !flush() {
return nil
}
continue
}
if strings.HasPrefix(line, ":") {
continue
}
if strings.HasPrefix(line, "data:") {
dataLines = append(dataLines, strings.TrimLeft(strings.TrimPrefix(line, "data:"), " \t"))
}
}
if err := scanner.Err(); err != nil {
return err
}
flush()
return nil
}
// ClassifiedError normalizes provider HTTP/stream errors and redacts secrets.
func ClassifiedError(statusCode int, message string, apiKey string) string {
prefix := "provider error: "
switch statusCode {
case http.StatusUnauthorized, http.StatusForbidden:
prefix = "auth error: "
case http.StatusTooManyRequests, http.StatusServiceUnavailable, 529:
prefix = "rate limit error: "
default:
if statusCode >= http.StatusBadRequest && statusCode < http.StatusInternalServerError {
prefix = "provider request error: "
}
}
return Redact(prefix+message, apiKey)
}
// Redact removes known API-key and bearer-token forms from provider messages.
func Redact(message string, apiKey string) string {
if apiKey != "" {
message = strings.ReplaceAll(message, apiKey, "[REDACTED]")
}
words := strings.Fields(message)
for index := 0; index < len(words)-1; index++ {
if strings.EqualFold(strings.TrimRight(words[index], ":"), "Bearer") {
words[index] = "authorization"
words[index+1] = "[REDACTED]"
}
}
return strings.Join(words, " ")
}
// PositiveOrDefault validates optional max token settings.
func PositiveOrDefault(value int, fallback int, label string) (int, error) {
if value == 0 {
return fallback, nil
}
if value < 0 {
return 0, fmt.Errorf("%s must be a positive integer", label)
}
return value, nil
}
-373
View File
@@ -1,373 +0,0 @@
package redaction
import (
"errors"
"fmt"
"net/url"
"reflect"
"regexp"
"strings"
"unicode"
)
const (
RedactedSecret = "[REDACTED]"
CircularReference = "[Circular]"
maxDepthDefault = 16
)
type Options struct {
Replacement string
ExtraSensitiveKeys []string
ExtraSecretValues []string
MaxDepth int
}
type RedactedError struct {
Name string `json:"name"`
Message string `json:"message"`
Stack string `json:"stack,omitempty"`
Fields map[string]any `json:"fields,omitempty"`
}
var sensitiveKeys = map[string]struct{}{
"access_token": {},
"anthropic_api_key": {},
"api_key": {},
"apikey": {},
"auth_token": {},
"authorization": {},
"aws_secret_access_key": {},
"aws_session_token": {},
"bearer": {},
"bearer_token": {},
"client_secret": {},
"cookie": {},
"credential": {},
"credentials": {},
"gemini_api_key": {},
"github_token": {},
"gitlab_token": {},
"google_api_key": {},
"id_token": {},
"jwt": {},
"npm_token": {},
"oauth_token": {},
"openai_api_key": {},
"passphrase": {},
"password": {},
"private_key": {},
"proxy_authorization": {},
"refresh_token": {},
"secret": {},
"session_token": {},
"set_cookie": {},
"token": {},
"x_api_key": {},
"zero_api_key": {},
}
var textSecretPatterns = []*regexp.Regexp{
regexp.MustCompile(`\bsk-(?:proj-)?[A-Za-z0-9._-]{12,}\b`),
regexp.MustCompile(`\bsk-ant-api\d{2}-[A-Za-z0-9._-]{12,}\b`),
regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{12,}\b`),
regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9_]{12,}\b`),
regexp.MustCompile(`\bglpat-[A-Za-z0-9_-]{12,}\b`),
regexp.MustCompile(`\bAIza[0-9A-Za-z_-]{12,}\b`),
regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{12,}\b`),
regexp.MustCompile(`\b(?:AKIA|ASIA)[A-Z0-9]{16}\b`),
regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b`),
}
var (
privateKeyPattern = regexp.MustCompile(`(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----`)
jsonStringPattern = regexp.MustCompile(`("([^"\\]*(?:\\.[^"\\]*)*)"\s*:\s*)"([^"\\]*(?:\\.[^"\\]*)*)"`)
assignPattern = regexp.MustCompile(`\b([A-Za-z_][A-Za-z0-9_.-]*)(\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s&]+))`)
headerPattern = regexp.MustCompile(`(?i)\b(authorization|proxy-authorization)\s*:\s*(bearer|basic)\s+([^\s,;]+)`)
secretHeader = regexp.MustCompile(`(?i)\b(x-api-key|api-key|cookie|set-cookie)\s*:\s*([^\r\n]+)`)
queryPattern = regexp.MustCompile(`([?&])([^=&#\s]+)=([^&#\s]+)`)
)
func IsSensitiveKey(key string, options Options) bool {
normalized := normalizeKey(key)
if normalized == "" {
return false
}
if _, ok := sensitiveKeys[normalized]; ok {
return true
}
for _, extra := range options.ExtraSensitiveKeys {
if normalizeKey(extra) == normalized {
return true
}
}
return false
}
func RedactString(value string, options Options) string {
replacement := replacement(options)
redacted := value
for _, secret := range options.ExtraSecretValues {
if strings.TrimSpace(secret) != "" {
redacted = strings.ReplaceAll(redacted, secret, replacement)
}
}
redacted = privateKeyPattern.ReplaceAllString(redacted, replacement)
redacted = jsonStringPattern.ReplaceAllStringFunc(redacted, func(match string) string {
parts := jsonStringPattern.FindStringSubmatch(match)
if len(parts) < 3 || !IsSensitiveKey(parts[2], options) {
return match
}
return parts[1] + `"` + replacement + `"`
})
redacted = assignPattern.ReplaceAllStringFunc(redacted, func(match string) string {
parts := assignPattern.FindStringSubmatch(match)
if len(parts) < 6 || !IsSensitiveKey(parts[1], options) {
return match
}
if parts[3] != "" {
return parts[1] + parts[2] + `"` + replacement + `"`
}
if parts[4] != "" {
return parts[1] + parts[2] + `'` + replacement + `'`
}
return parts[1] + parts[2] + replacement
})
redacted = headerPattern.ReplaceAllString(redacted, "$1: $2 "+replacement)
redacted = secretHeader.ReplaceAllString(redacted, "$1: "+replacement)
redacted = redactURLPasswords(redacted, replacement)
redacted = queryPattern.ReplaceAllStringFunc(redacted, func(match string) string {
parts := queryPattern.FindStringSubmatch(match)
if len(parts) < 4 || !IsSensitiveKey(parts[2], options) {
return match
}
return parts[1] + parts[2] + "=" + replacement
})
for _, pattern := range textSecretPatterns {
redacted = pattern.ReplaceAllString(redacted, replacement)
}
return redacted
}
func RedactValue(value any, options Options) any {
return redactReflect(reflect.ValueOf(value), redactionContext{
options: options,
replacement: replacement(options),
maxDepth: maxDepth(options),
seen: map[uintptr]struct{}{},
}, 0)
}
func RedactError(err error, options Options) RedactedError {
if err == nil {
return RedactedError{Name: "Error", Message: ""}
}
redacted := RedactedError{
Name: errorName(err),
Message: RedactString(err.Error(), options),
}
fields := exportedFields(reflect.ValueOf(err), options)
if len(fields) > 0 {
redacted.Fields = fields
}
var stackTracer interface{ StackTrace() fmt.Stringer }
if errors.As(err, &stackTracer) {
redacted.Stack = RedactString(stackTracer.StackTrace().String(), options)
}
return redacted
}
func ErrorMessage(err error, options Options) string {
return RedactError(err, options).Message
}
type redactionContext struct {
options Options
replacement string
maxDepth int
seen map[uintptr]struct{}
}
func redactReflect(value reflect.Value, context redactionContext, depth int) any {
if !value.IsValid() {
return nil
}
for value.Kind() == reflect.Interface {
if value.IsNil() {
return nil
}
value = value.Elem()
}
if depth >= context.maxDepth {
return "[MaxDepth]"
}
switch value.Kind() {
case reflect.String:
return RedactString(value.String(), context.options)
case reflect.Bool:
return value.Bool()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return value.Int()
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return value.Uint()
case reflect.Float32, reflect.Float64:
return value.Float()
case reflect.Pointer:
if value.IsNil() {
return nil
}
ptr := value.Pointer()
if _, ok := context.seen[ptr]; ok {
return CircularReference
}
context.seen[ptr] = struct{}{}
return redactReflect(value.Elem(), context, depth+1)
case reflect.Map:
if value.IsNil() {
return nil
}
ptr := value.Pointer()
if _, ok := context.seen[ptr]; ok {
return CircularReference
}
context.seen[ptr] = struct{}{}
out := make(map[string]any, value.Len())
iter := value.MapRange()
for iter.Next() {
key := fmt.Sprint(redactReflect(iter.Key(), context, depth+1))
if IsSensitiveKey(key, context.options) {
out[key] = context.replacement
continue
}
out[key] = redactReflect(iter.Value(), context, depth+1)
}
return out
case reflect.Slice, reflect.Array:
out := make([]any, value.Len())
for index := 0; index < value.Len(); index++ {
out[index] = redactReflect(value.Index(index), context, depth+1)
}
return out
case reflect.Struct:
if value.CanInterface() {
if err, ok := value.Interface().(error); ok {
return RedactError(err, context.options)
}
}
out := make(map[string]any, value.NumField())
valueType := value.Type()
for index := 0; index < value.NumField(); index++ {
field := valueType.Field(index)
if field.PkgPath != "" {
continue
}
name := field.Name
if tag := field.Tag.Get("json"); tag != "" {
name = strings.Split(tag, ",")[0]
if name == "-" {
continue
}
}
if IsSensitiveKey(name, context.options) {
out[name] = context.replacement
continue
}
out[name] = redactReflect(value.Field(index), context, depth+1)
}
return out
default:
if value.CanInterface() {
return value.Interface()
}
return fmt.Sprint(value)
}
}
func exportedFields(value reflect.Value, options Options) map[string]any {
for value.Kind() == reflect.Pointer || value.Kind() == reflect.Interface {
if value.IsNil() {
return nil
}
value = value.Elem()
}
if value.Kind() != reflect.Struct {
return nil
}
fields := map[string]any{}
context := redactionContext{
options: options,
replacement: replacement(options),
maxDepth: maxDepth(options),
seen: map[uintptr]struct{}{},
}
valueType := value.Type()
for index := 0; index < value.NumField(); index++ {
field := valueType.Field(index)
if field.PkgPath != "" {
continue
}
name := field.Name
if IsSensitiveKey(name, options) {
fields[name] = context.replacement
} else {
fields[name] = redactReflect(value.Field(index), context, 1)
}
}
return fields
}
func errorName(err error) string {
name := reflect.TypeOf(err).String()
if name == "" {
return "Error"
}
return name
}
func replacement(options Options) string {
if options.Replacement != "" {
return options.Replacement
}
return RedactedSecret
}
func maxDepth(options Options) int {
if options.MaxDepth > 0 {
return options.MaxDepth
}
return maxDepthDefault
}
func normalizeKey(key string) string {
key = strings.TrimSpace(key)
var builder strings.Builder
var lastUnderscore bool
for _, r := range key {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
builder.WriteRune(unicode.ToLower(r))
lastUnderscore = false
continue
}
if !lastUnderscore {
builder.WriteByte('_')
lastUnderscore = true
}
}
return strings.Trim(builder.String(), "_")
}
func redactURLPasswords(value string, replacement string) string {
return regexp.MustCompile(`\b(?:https?|wss?|ftp)://[^\s]+`).ReplaceAllStringFunc(value, func(candidate string) string {
parsed, err := url.Parse(candidate)
if err != nil || parsed.User == nil {
return candidate
}
if _, hasPassword := parsed.User.Password(); !hasPassword {
return candidate
}
parsed.User = url.UserPassword(parsed.User.Username(), replacement)
return parsed.String()
})
}
-85
View File
@@ -1,85 +0,0 @@
package redaction
import (
"errors"
"strings"
"testing"
)
func TestRedactStringCoversCommonSecretShapes(t *testing.T) {
input := strings.Join([]string{
`{"apiKey":"sk-proj-abcdefghijklmnopqrstuvwxyz"}`,
"authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz123456",
"https://zero:[email protected]/path?token=glpat-abcdefghijklmnopqrstuvwxyz",
"-----BEGIN PRIVATE KEY-----\nabc123\n-----END PRIVATE KEY-----",
}, "\n")
got := RedactString(input, Options{ExtraSecretValues: []string{"super-secret"}})
for _, leaked := range []string{
"sk-proj-abcdefghijklmnopqrstuvwxyz",
"ghp_abcdefghijklmnopqrstuvwxyz123456",
"super-secret",
"glpat-abcdefghijklmnopqrstuvwxyz",
"abc123",
} {
if strings.Contains(got, leaked) {
t.Fatalf("redacted string leaked %q in %q", leaked, got)
}
}
if count := strings.Count(got, RedactedSecret); count < 5 {
t.Fatalf("expected multiple redaction markers, got %d in %q", count, got)
}
}
func TestRedactValueHandlesSensitiveKeysAndCycles(t *testing.T) {
type node struct {
Name string
Password string
Next *node
}
root := &node{Name: "root", Password: "open-sesame"}
root.Next = root
got := RedactValue(root, Options{})
asMap, ok := got.(map[string]any)
if !ok {
t.Fatalf("expected map output, got %T", got)
}
if asMap["Password"] != RedactedSecret {
t.Fatalf("expected sensitive key redacted, got %#v", asMap["Password"])
}
if asMap["Next"] != CircularReference {
t.Fatalf("expected circular reference marker, got %#v", asMap["Next"])
}
}
func TestRedactErrorRedactsMessageStackAndFields(t *testing.T) {
err := withFieldsError{
err: errors.New("request failed with api_key=sk-test-secret1234567890"),
Token: "ghp_abcdefghijklmnopqrstuvwxyz123456",
Detail: "safe",
}
got := RedactError(err, Options{})
if strings.Contains(got.Message, "sk-test-secret") {
t.Fatalf("message leaked secret: %#v", got)
}
if got.Fields["Token"] != RedactedSecret {
t.Fatalf("token field was not redacted: %#v", got.Fields)
}
if got.Fields["Detail"] != "safe" {
t.Fatalf("non-sensitive field changed: %#v", got.Fields)
}
}
type withFieldsError struct {
err error
Token string
Detail string
}
func (err withFieldsError) Error() string {
return err.err.Error()
}
-416
View File
@@ -1,416 +0,0 @@
package search
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sessions"
)
const (
IndexFileName = "search-index.json"
indexSchema = 1
defaultLimit = 20
defaultContext = 80
)
type Options struct {
Store *sessions.Store
RootDir string
Limit int
ContextChars int
SessionID string
Type sessions.EventType
Reindex bool
Now func() time.Time
}
type EventSummary struct {
ID string `json:"id"`
Sequence int `json:"sequence"`
Type sessions.EventType `json:"type"`
CreatedAt string `json:"createdAt"`
}
type Hit struct {
Session sessions.Metadata `json:"session"`
Event EventSummary `json:"event"`
Context string `json:"context"`
Match Match `json:"match"`
}
type Match struct {
Start int `json:"start"`
End int `json:"end"`
}
type Result struct {
Query string `json:"query"`
NormalizedQuery string `json:"normalizedQuery"`
RootDir string `json:"rootDir"`
SearchedSessions int `json:"searchedSessions"`
TotalHits int `json:"totalHits"`
Hits []Hit `json:"hits"`
}
type Index struct {
SchemaVersion int `json:"schemaVersion"`
SessionID string `json:"sessionId"`
SessionUpdatedAt string `json:"sessionUpdatedAt"`
SessionEventCount int `json:"sessionEventCount"`
GeneratedAt string `json:"generatedAt"`
Entries []IndexEntry `json:"entries"`
}
type IndexEntry struct {
SessionID string `json:"sessionId"`
EventID string `json:"eventId"`
Sequence int `json:"sequence"`
Type sessions.EventType `json:"type"`
CreatedAt string `json:"createdAt"`
Text string `json:"text"`
}
func Sessions(query string, options Options) (Result, error) {
normalized := NormalizeQuery(query)
store := options.Store
if store == nil {
store = sessions.NewStore(sessions.StoreOptions{RootDir: options.RootDir})
}
limit := normalizeLimit(options.Limit)
contextChars := normalizeContext(options.ContextChars)
if normalized == "" || limit == 0 {
return Result{Query: query, NormalizedQuery: normalized, RootDir: store.RootDir, Hits: []Hit{}}, nil
}
sessionList, err := resolveSessions(store, strings.TrimSpace(options.SessionID))
if err != nil {
return Result{}, err
}
terms := splitTerms(normalized)
now := options.Now
if now == nil {
now = time.Now
}
result := Result{
Query: query,
NormalizedQuery: normalized,
RootDir: store.RootDir,
SearchedSessions: len(sessionList),
Hits: []Hit{},
}
for _, session := range sessionList {
index, err := LoadIndex(store, session, LoadOptions{Reindex: options.Reindex, Now: now})
if err != nil {
return Result{}, err
}
for _, entry := range index.Entries {
if options.Type != "" && entry.Type != options.Type {
continue
}
match, ok := findMatch(entry.Text, normalized, terms)
if !ok {
continue
}
result.Hits = append(result.Hits, Hit{
Session: session,
Event: EventSummary{
ID: entry.EventID,
Sequence: entry.Sequence,
Type: entry.Type,
CreatedAt: entry.CreatedAt,
},
Context: buildContext(entry.Text, match.Start, match.End, contextChars),
Match: match,
})
if len(result.Hits) >= limit {
result.TotalHits = len(result.Hits)
return result, nil
}
}
}
result.TotalHits = len(result.Hits)
return result, nil
}
type LoadOptions struct {
Reindex bool
Now func() time.Time
}
func LoadIndex(store *sessions.Store, session sessions.Metadata, options LoadOptions) (Index, error) {
if !options.Reindex {
index, ok := readIndex(store, session)
if ok && current(index, session) {
return index, nil
}
}
return RebuildIndex(store, session, options.Now)
}
func RebuildIndex(store *sessions.Store, session sessions.Metadata, now func() time.Time) (Index, error) {
if now == nil {
now = time.Now
}
events, err := store.ReadEvents(session.SessionID)
if err != nil {
return Index{}, err
}
index := Index{
SchemaVersion: indexSchema,
SessionID: session.SessionID,
SessionUpdatedAt: session.UpdatedAt,
SessionEventCount: session.EventCount,
GeneratedAt: now().UTC().Format(time.RFC3339),
Entries: make([]IndexEntry, 0, len(events)),
}
for _, event := range events {
index.Entries = append(index.Entries, IndexEntry{
SessionID: session.SessionID,
EventID: event.ID,
Sequence: event.Sequence,
Type: event.Type,
CreatedAt: event.CreatedAt,
Text: ExtractText(redactedPayload(event.Payload)),
})
}
data, err := json.MarshalIndent(index, "", " ")
if err != nil {
return Index{}, fmt.Errorf("encode Zero search index: %w", err)
}
if err := os.WriteFile(indexPath(store, session.SessionID), append(data, '\n'), 0o600); err != nil {
return Index{}, fmt.Errorf("write Zero search index: %w", err)
}
return index, nil
}
func FormatResult(result Result) string {
query := redaction.RedactString(strings.TrimSpace(result.Query), redaction.Options{})
if len(result.Hits) == 0 {
return fmt.Sprintf("No local session events matched %q. Searched %s.", query, count(result.SearchedSessions, "session"))
}
lines := []string{fmt.Sprintf("Found %s for %q:", count(result.TotalHits, "local session event"), query)}
for index, hit := range result.Hits {
title := ""
if hit.Session.Title != "" {
title = " - " + redaction.RedactString(hit.Session.Title, redaction.Options{})
}
lines = append(lines, fmt.Sprintf("%d. %s #%d %s%s", index+1, redaction.RedactString(hit.Session.SessionID, redaction.Options{}), hit.Event.Sequence, redaction.RedactString(string(hit.Event.Type), redaction.Options{}), title))
context := strings.Join(strings.Fields(redaction.RedactString(hit.Context, redaction.Options{})), " ")
if context != "" {
lines = append(lines, " "+context)
}
details := []string{}
if hit.Session.Cwd != "" {
details = append(details, "cwd: "+redaction.RedactString(hit.Session.Cwd, redaction.Options{}))
}
if hit.Session.ModelID != "" {
details = append(details, "model: "+redaction.RedactString(hit.Session.ModelID, redaction.Options{}))
}
if hit.Session.Provider != "" {
details = append(details, "provider: "+redaction.RedactString(hit.Session.Provider, redaction.Options{}))
}
details = append(details, "updated: "+redaction.RedactString(hit.Session.UpdatedAt, redaction.Options{}))
lines = append(lines, " "+strings.Join(details, " | "))
}
return strings.Join(lines, "\n")
}
func RedactResult(result Result) Result {
options := redaction.Options{}
redacted := result
redacted.Query = redaction.RedactString(redacted.Query, options)
redacted.NormalizedQuery = redaction.RedactString(redacted.NormalizedQuery, options)
redacted.RootDir = redaction.RedactString(redacted.RootDir, options)
redacted.Hits = make([]Hit, len(result.Hits))
for index, hit := range result.Hits {
redacted.Hits[index] = Hit{
Session: redactMetadata(hit.Session, options),
Event: redactEventSummary(hit.Event, options),
Context: redaction.RedactString(hit.Context, options),
Match: hit.Match,
}
}
return redacted
}
func redactMetadata(session sessions.Metadata, options redaction.Options) sessions.Metadata {
session.SessionID = redaction.RedactString(session.SessionID, options)
session.Title = redaction.RedactString(session.Title, options)
session.Cwd = redaction.RedactString(session.Cwd, options)
session.ModelID = redaction.RedactString(session.ModelID, options)
session.Provider = redaction.RedactString(session.Provider, options)
session.ParentSessionID = redaction.RedactString(session.ParentSessionID, options)
session.ForkedFromEventID = redaction.RedactString(session.ForkedFromEventID, options)
session.CreatedAt = redaction.RedactString(session.CreatedAt, options)
session.UpdatedAt = redaction.RedactString(session.UpdatedAt, options)
session.LastEventType = sessions.EventType(redaction.RedactString(string(session.LastEventType), options))
return session
}
func redactEventSummary(event EventSummary, options redaction.Options) EventSummary {
event.ID = redaction.RedactString(event.ID, options)
event.Type = sessions.EventType(redaction.RedactString(string(event.Type), options))
event.CreatedAt = redaction.RedactString(event.CreatedAt, options)
return event
}
func NormalizeQuery(query string) string {
return strings.ToLower(strings.Join(strings.Fields(query), " "))
}
func ExtractText(value any) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return typed
case float64, float32, int, int64, bool:
return fmt.Sprint(typed)
case []any:
parts := []string{}
for _, item := range typed {
if text := ExtractText(item); text != "" {
parts = append(parts, text)
}
}
return strings.Join(parts, " ")
case map[string]any:
parts := []string{}
keys := make([]string, 0, len(typed))
for key := range typed {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
parts = append(parts, key)
item := typed[key]
if text := ExtractText(item); text != "" {
parts = append(parts, text)
}
}
return strings.Join(parts, " ")
default:
return fmt.Sprint(typed)
}
}
func resolveSessions(store *sessions.Store, sessionID string) ([]sessions.Metadata, error) {
if sessionID == "" {
return store.List()
}
session, err := store.Get(sessionID)
if err != nil || session == nil {
return []sessions.Metadata{}, err
}
return []sessions.Metadata{*session}, nil
}
func readIndex(store *sessions.Store, session sessions.Metadata) (Index, bool) {
data, err := os.ReadFile(indexPath(store, session.SessionID))
if err != nil {
return Index{}, false
}
var index Index
if err := json.Unmarshal(data, &index); err != nil {
return Index{}, false
}
return index, true
}
func current(index Index, session sessions.Metadata) bool {
return index.SchemaVersion == indexSchema &&
index.SessionID == session.SessionID &&
index.SessionUpdatedAt == session.UpdatedAt &&
index.SessionEventCount == session.EventCount &&
len(index.Entries) == session.EventCount
}
func redactedPayload(raw json.RawMessage) any {
if len(raw) == 0 {
return nil
}
var payload any
if err := json.Unmarshal(raw, &payload); err != nil {
return redaction.RedactString(string(raw), redaction.Options{})
}
return redaction.RedactValue(payload, redaction.Options{})
}
func findMatch(text string, query string, terms []string) (Match, bool) {
normalizedText := strings.ToLower(text)
if index := strings.Index(normalizedText, query); index >= 0 {
return Match{Start: index, End: index + len(query)}, true
}
first := -1
last := -1
for _, term := range terms {
index := strings.Index(normalizedText, term)
if index < 0 {
return Match{}, false
}
if first < 0 || index < first {
first = index
}
if end := index + len(term); end > last {
last = end
}
}
return Match{Start: first, End: last}, first >= 0
}
func buildContext(text string, start int, end int, contextChars int) string {
left := start - contextChars
if left < 0 {
left = 0
}
right := end + contextChars
if right > len(text) {
right = len(text)
}
return strings.TrimSpace(text[left:right])
}
func splitTerms(query string) []string {
if query == "" {
return nil
}
return strings.Fields(query)
}
func normalizeLimit(limit int) int {
if limit == 0 {
return defaultLimit
}
if limit < 0 {
return 0
}
return limit
}
func normalizeContext(contextChars int) int {
if contextChars == 0 {
return defaultContext
}
if contextChars < 0 {
return 0
}
return contextChars
}
func indexPath(store *sessions.Store, sessionID string) string {
return filepath.Join(store.RootDir, sessionID, IndexFileName)
}
func count(value int, label string) string {
suffix := "s"
if value == 1 {
suffix = ""
}
return fmt.Sprintf("%d %s%s", value, label, suffix)
}
-109
View File
@@ -1,109 +0,0 @@
package search
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/sessions"
)
func TestSearchSessionsFindsRedactedEventContextAndCachesIndex(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedSearchClock("2026-06-04T14:00:00Z")})
session, err := store.Create(sessions.CreateInput{SessionID: "searchable", Title: "Search", Cwd: "/repo", ModelID: "gpt-4.1", Provider: "openai"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]any{"content": "please rotate apiKey=sk-secret1234567890 before deploy"}}); err != nil {
t.Fatalf("AppendEvent returned error: %v", err)
}
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{Type: sessions.EventToolResult, Payload: map[string]any{"output": "deployment finished"}}); err != nil {
t.Fatalf("AppendEvent returned error: %v", err)
}
result, err := Sessions("rotate deploy", Options{Store: store, Limit: 5, ContextChars: 120})
if err != nil {
t.Fatalf("Sessions returned error: %v", err)
}
if result.TotalHits != 1 || result.SearchedSessions != 1 {
t.Fatalf("unexpected result counts: %#v", result)
}
if hit := result.Hits[0]; hit.Session.SessionID != "searchable" || hit.Event.Sequence != 1 {
t.Fatalf("unexpected hit: %#v", hit)
}
if strings.Contains(result.Hits[0].Context, "sk-secret") {
t.Fatalf("search context leaked secret: %#v", result.Hits[0])
}
indexPath := filepath.Join(store.RootDir, "searchable", IndexFileName)
if _, err := os.Stat(indexPath); err != nil {
t.Fatalf("expected search index to be written: %v", err)
}
formatted := FormatResult(result)
if !strings.Contains(formatted, "Found 1 local session event") || strings.Contains(formatted, "sk-secret") {
t.Fatalf("unexpected formatted result: %q", formatted)
}
}
func TestSearchSessionsSupportsFiltersAndEmptyQuery(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedSearchClock("2026-06-04T14:30:00Z")})
one, err := store.Create(sessions.CreateInput{SessionID: "one"})
if err != nil {
t.Fatalf("Create one returned error: %v", err)
}
two, err := store.Create(sessions.CreateInput{SessionID: "two"})
if err != nil {
t.Fatalf("Create two returned error: %v", err)
}
if _, err := store.AppendEvent(one.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: "needle in message"}); err != nil {
t.Fatalf("AppendEvent one returned error: %v", err)
}
if _, err := store.AppendEvent(two.SessionID, sessions.AppendEventInput{Type: sessions.EventToolResult, Payload: "needle in tool result"}); err != nil {
t.Fatalf("AppendEvent two returned error: %v", err)
}
result, err := Sessions("needle", Options{Store: store, SessionID: "two", Type: sessions.EventToolResult})
if err != nil {
t.Fatalf("Sessions returned error: %v", err)
}
if result.TotalHits != 1 || result.Hits[0].Session.SessionID != "two" {
t.Fatalf("unexpected filtered result: %#v", result)
}
empty, err := Sessions(" ", Options{Store: store})
if err != nil {
t.Fatalf("empty Sessions returned error: %v", err)
}
if empty.TotalHits != 0 || empty.SearchedSessions != 0 {
t.Fatalf("empty query should not search sessions: %#v", empty)
}
}
func TestSearchSessionsMatchesMapKeys(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir(), Now: fixedSearchClock("2026-06-04T14:45:00Z")})
session, err := store.Create(sessions.CreateInput{SessionID: "map_keys"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{Type: sessions.EventError, Payload: map[string]any{"error": "", "status": "success"}}); err != nil {
t.Fatalf("AppendEvent returned error: %v", err)
}
result, err := Sessions("error", Options{Store: store})
if err != nil {
t.Fatalf("Sessions returned error: %v", err)
}
if result.TotalHits != 1 || result.Hits[0].Event.Type != sessions.EventError {
t.Fatalf("expected map key search hit, got %#v", result)
}
}
func fixedSearchClock(value string) func() time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return func() time.Time { return parsed }
}
-463
View File
@@ -1,463 +0,0 @@
package sessions
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
const (
MetadataFile = "metadata.json"
EventsFile = "events.jsonl"
)
type EventType string
const (
EventMessage EventType = "message"
EventToolCall EventType = "tool_call"
EventToolResult EventType = "tool_result"
EventProviderUsage EventType = "provider_usage"
EventError EventType = "error"
EventSessionFork EventType = "session_fork"
)
type Metadata struct {
SessionID string `json:"sessionId"`
Title string `json:"title,omitempty"`
Cwd string `json:"cwd,omitempty"`
ModelID string `json:"modelId,omitempty"`
Provider string `json:"provider,omitempty"`
ParentSessionID string `json:"parentSessionId,omitempty"`
ForkedFromEventID string `json:"forkedFromEventId,omitempty"`
ForkedFromSequence int `json:"forkedFromSequence,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
EventCount int `json:"eventCount"`
LastEventType EventType `json:"lastEventType,omitempty"`
}
type CreateInput struct {
SessionID string
Title string
Cwd string
ModelID string
Provider string
ParentSessionID string
ForkedFromEventID string
ForkedFromSequence int
}
type ForkInput struct {
SessionID string
Title string
Cwd string
ModelID string
Provider string
}
type AppendEventInput struct {
Type EventType
Payload any
}
type Event struct {
ID string `json:"id"`
SessionID string `json:"sessionId"`
Sequence int `json:"sequence"`
Type EventType `json:"type"`
CreatedAt string `json:"createdAt"`
Payload json.RawMessage `json:"payload,omitempty"`
}
type StoreOptions struct {
RootDir string
Now func() time.Time
Env map[string]string
}
type Store struct {
RootDir string
now func() time.Time
locksMu sync.Mutex
sessionLocks map[string]*sync.Mutex
idCounter atomic.Uint64
}
var sessionIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`)
func NewStore(options StoreOptions) *Store {
rootDir := strings.TrimSpace(options.RootDir)
if rootDir == "" {
rootDir = DefaultRoot(options.Env)
}
now := options.Now
if now == nil {
now = time.Now
}
return &Store{RootDir: rootDir, now: now, sessionLocks: map[string]*sync.Mutex{}}
}
func DefaultRoot(env map[string]string) string {
dataHome := strings.TrimSpace(envValue(env, "XDG_DATA_HOME"))
home := strings.TrimSpace(envValue(env, "HOME"))
if home == "" {
if userHome, err := os.UserHomeDir(); err == nil {
home = userHome
}
}
base := dataHome
if base == "" {
base = filepath.Join(home, ".local", "share")
}
return filepath.Join(base, "zero", "sessions")
}
func ValidSessionID(sessionID string) bool {
return sessionIDPattern.MatchString(strings.TrimSpace(sessionID))
}
func (store *Store) Create(input CreateInput) (Metadata, error) {
sessionID := strings.TrimSpace(input.SessionID)
if sessionID == "" {
sessionID = store.createID()
}
if !ValidSessionID(sessionID) {
return Metadata{}, fmt.Errorf("invalid Zero session id %q", input.SessionID)
}
timestamp := store.timestamp()
session := Metadata{
SessionID: sessionID,
Title: strings.TrimSpace(input.Title),
Cwd: strings.TrimSpace(input.Cwd),
ModelID: strings.TrimSpace(input.ModelID),
Provider: strings.TrimSpace(input.Provider),
ParentSessionID: strings.TrimSpace(input.ParentSessionID),
ForkedFromEventID: strings.TrimSpace(input.ForkedFromEventID),
ForkedFromSequence: input.ForkedFromSequence,
CreatedAt: timestamp,
UpdatedAt: timestamp,
EventCount: 0,
}
if err := os.MkdirAll(store.RootDir, 0o700); err != nil {
return Metadata{}, fmt.Errorf("create Zero session root: %w", err)
}
if err := os.Mkdir(store.sessionPath(sessionID), 0o700); err != nil {
if errors.Is(err, os.ErrExist) {
return Metadata{}, fmt.Errorf("zero session already exists: %s", sessionID)
}
return Metadata{}, fmt.Errorf("create Zero session directory: %w", err)
}
if err := store.writeMetadata(session); err != nil {
return Metadata{}, err
}
file, err := os.OpenFile(store.eventsPath(sessionID), os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return Metadata{}, fmt.Errorf("create Zero session events file: %w", err)
}
if err := file.Close(); err != nil {
return Metadata{}, fmt.Errorf("close Zero session events file: %w", err)
}
return session, nil
}
func (store *Store) Get(sessionID string) (*Metadata, error) {
if !ValidSessionID(sessionID) {
return nil, fmt.Errorf("invalid Zero session id %q", sessionID)
}
session, err := store.readMetadata(sessionID)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
return &session, nil
}
func (store *Store) List() ([]Metadata, error) {
if err := os.MkdirAll(store.RootDir, 0o700); err != nil {
return nil, fmt.Errorf("create Zero session root: %w", err)
}
entries, err := os.ReadDir(store.RootDir)
if err != nil {
return nil, fmt.Errorf("read Zero session root: %w", err)
}
sessions := []Metadata{}
for _, entry := range entries {
if !entry.IsDir() || !ValidSessionID(entry.Name()) {
continue
}
session, err := store.Get(entry.Name())
if err != nil || session == nil {
continue
}
sessions = append(sessions, *session)
}
sort.SliceStable(sessions, func(left int, right int) bool {
if sessions[left].UpdatedAt == sessions[right].UpdatedAt {
return sessions[left].SessionID < sessions[right].SessionID
}
return sessions[left].UpdatedAt > sessions[right].UpdatedAt
})
return sessions, nil
}
func (store *Store) Latest() (*Metadata, error) {
sessions, err := store.List()
if err != nil || len(sessions) == 0 {
return nil, err
}
return &sessions[0], nil
}
func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, error) {
if !ValidSessionID(parentSessionID) {
return Metadata{}, fmt.Errorf("invalid Zero session id %q", parentSessionID)
}
parent, err := store.Get(parentSessionID)
if err != nil {
return Metadata{}, err
}
if parent == nil {
return Metadata{}, fmt.Errorf("zero session not found: %s", parentSessionID)
}
events, err := store.ReadEvents(parentSessionID)
if err != nil {
return Metadata{}, err
}
var last Event
if len(events) > 0 {
last = events[len(events)-1]
}
title := strings.TrimSpace(input.Title)
if title == "" && parent.Title != "" {
title = parent.Title + " (fork)"
}
fork, err := store.Create(CreateInput{
SessionID: input.SessionID,
Title: title,
Cwd: firstNonEmpty(input.Cwd, parent.Cwd),
ModelID: firstNonEmpty(input.ModelID, parent.ModelID),
Provider: firstNonEmpty(input.Provider, parent.Provider),
ParentSessionID: parent.SessionID,
ForkedFromEventID: last.ID,
ForkedFromSequence: last.Sequence,
})
if err != nil {
return Metadata{}, err
}
for _, event := range events {
if _, err := store.AppendEvent(fork.SessionID, AppendEventInput{Type: event.Type, Payload: event.Payload}); err != nil {
return Metadata{}, err
}
}
if _, err := store.AppendEvent(fork.SessionID, AppendEventInput{
Type: EventSessionFork,
Payload: map[string]any{
"parentSessionId": parent.SessionID,
"parentEventCount": parent.EventCount,
"copiedEventCount": len(events),
"forkedFromEventId": last.ID,
"forkedFromSequence": last.Sequence,
},
}); err != nil {
return Metadata{}, err
}
loaded, err := store.readMetadata(fork.SessionID)
if err != nil {
return Metadata{}, err
}
return loaded, nil
}
func (store *Store) AppendEvent(sessionID string, input AppendEventInput) (Event, error) {
if !ValidSessionID(sessionID) {
return Event{}, fmt.Errorf("invalid Zero session id %q", sessionID)
}
if strings.TrimSpace(string(input.Type)) == "" {
return Event{}, fmt.Errorf("zero session event type is required")
}
lock := store.sessionLock(sessionID)
lock.Lock()
defer lock.Unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Event{}, err
}
payload, err := rawPayload(input.Payload)
if err != nil {
return Event{}, err
}
sequence := session.EventCount + 1
timestamp := store.timestamp()
event := Event{
ID: fmt.Sprintf("%s:%d", sessionID, sequence),
SessionID: sessionID,
Sequence: sequence,
Type: input.Type,
CreatedAt: timestamp,
Payload: payload,
}
data, err := json.Marshal(event)
if err != nil {
return Event{}, fmt.Errorf("encode Zero session event: %w", err)
}
file, err := os.OpenFile(store.eventsPath(sessionID), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)
if err != nil {
return Event{}, fmt.Errorf("append Zero session event: %w", err)
}
if _, err := file.Write(append(data, '\n')); err != nil {
_ = file.Close()
return Event{}, fmt.Errorf("append Zero session event: %w", err)
}
if err := file.Close(); err != nil {
return Event{}, fmt.Errorf("close Zero session event file: %w", err)
}
session.UpdatedAt = timestamp
session.EventCount = sequence
session.LastEventType = input.Type
if err := store.writeMetadata(session); err != nil {
return Event{}, err
}
return event, nil
}
func (store *Store) ReadEvents(sessionID string) ([]Event, error) {
if !ValidSessionID(sessionID) {
return nil, fmt.Errorf("invalid Zero session id %q", sessionID)
}
data, err := os.ReadFile(store.eventsPath(sessionID))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []Event{}, nil
}
return nil, fmt.Errorf("read Zero session events: %w", err)
}
events := []Event{}
for index, line := range bytes.Split(data, []byte{'\n'}) {
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var event Event
if err := json.Unmarshal(line, &event); err != nil {
return nil, fmt.Errorf("invalid JSON in Zero session %s %s at line %d: %w", sessionID, EventsFile, index+1, err)
}
events = append(events, event)
}
return events, nil
}
func (store *Store) timestamp() string {
return store.now().UTC().Format(time.RFC3339)
}
func (store *Store) createID() string {
timestamp := store.now().UTC()
return fmt.Sprintf("zero_%s_%d_%d", timestamp.Format("20060102150405"), timestamp.UnixNano(), store.idCounter.Add(1))
}
func (store *Store) sessionLock(sessionID string) *sync.Mutex {
store.locksMu.Lock()
defer store.locksMu.Unlock()
if store.sessionLocks == nil {
store.sessionLocks = map[string]*sync.Mutex{}
}
lock := store.sessionLocks[sessionID]
if lock == nil {
lock = &sync.Mutex{}
store.sessionLocks[sessionID] = lock
}
return lock
}
func (store *Store) sessionPath(sessionID string) string {
return filepath.Join(store.RootDir, sessionID)
}
func (store *Store) metadataPath(sessionID string) string {
return filepath.Join(store.sessionPath(sessionID), MetadataFile)
}
func (store *Store) eventsPath(sessionID string) string {
return filepath.Join(store.sessionPath(sessionID), EventsFile)
}
func (store *Store) readMetadata(sessionID string) (Metadata, error) {
data, err := os.ReadFile(store.metadataPath(sessionID))
if err != nil {
return Metadata{}, err
}
var session Metadata
if err := json.Unmarshal(data, &session); err != nil {
return Metadata{}, fmt.Errorf("invalid Zero session metadata %s: %w", sessionID, err)
}
return session, nil
}
func (store *Store) writeMetadata(session Metadata) error {
data, err := json.MarshalIndent(session, "", " ")
if err != nil {
return fmt.Errorf("encode Zero session metadata: %w", err)
}
path := store.metadataPath(session.SessionID)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil {
return fmt.Errorf("write Zero session metadata: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("replace Zero session metadata: %w", err)
}
return nil
}
func rawPayload(payload any) (json.RawMessage, error) {
if payload == nil {
return nil, nil
}
if raw, ok := payload.(json.RawMessage); ok {
copied := append(json.RawMessage{}, bytes.TrimSpace(raw)...)
if len(copied) == 0 {
return nil, nil
}
if !json.Valid(copied) {
return nil, fmt.Errorf("invalid raw JSON payload")
}
return copied, nil
}
data, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("encode Zero session payload: %w", err)
}
return data, nil
}
func envValue(env map[string]string, key string) string {
if env != nil {
return env[key]
}
return os.Getenv(key)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
-223
View File
@@ -1,223 +0,0 @@
package sessions
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func TestStoreCreatesAppendsListsAndReadsEvents(t *testing.T) {
now := fixedClock("2026-06-04T10:00:00Z")
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: now})
session, err := store.Create(CreateInput{
SessionID: "zero_test_1",
Title: "First run",
Cwd: "/repo",
ModelID: "gpt-4.1",
Provider: "openai",
})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
if session.EventCount != 0 || session.CreatedAt != "2026-06-04T10:00:00Z" {
t.Fatalf("unexpected session metadata: %#v", session)
}
event, err := store.AppendEvent(session.SessionID, AppendEventInput{
Type: EventMessage,
Payload: map[string]any{
"role": "user",
"content": "searchable hello",
},
})
if err != nil {
t.Fatalf("AppendEvent returned error: %v", err)
}
if event.ID != "zero_test_1:1" || event.Sequence != 1 {
t.Fatalf("unexpected event identity: %#v", event)
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if loaded == nil || loaded.EventCount != 1 || loaded.LastEventType != EventMessage {
t.Fatalf("metadata was not updated after append: %#v", loaded)
}
events, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if len(events) != 1 {
t.Fatalf("expected one event, got %#v", events)
}
payload := map[string]any{}
if err := json.Unmarshal(events[0].Payload, &payload); err != nil {
t.Fatalf("payload is not valid JSON: %v", err)
}
if payload["content"] != "searchable hello" {
t.Fatalf("unexpected payload: %#v", payload)
}
sessions, err := store.List()
if err != nil {
t.Fatalf("List returned error: %v", err)
}
if len(sessions) != 1 || sessions[0].SessionID != session.SessionID {
t.Fatalf("unexpected session list: %#v", sessions)
}
}
func TestStoreForkCopiesEventsAndLineage(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T11:00:00Z")})
parent, err := store.Create(CreateInput{SessionID: "parent", Title: "Parent", Cwd: "/repo", ModelID: "gpt-4.1", Provider: "openai"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
for _, content := range []string{"first", "second"} {
if _, err := store.AppendEvent(parent.SessionID, AppendEventInput{Type: EventMessage, Payload: map[string]string{"content": content}}); err != nil {
t.Fatalf("AppendEvent returned error: %v", err)
}
}
fork, err := store.Fork(parent.SessionID, ForkInput{SessionID: "fork"})
if err != nil {
t.Fatalf("Fork returned error: %v", err)
}
if fork.ParentSessionID != parent.SessionID || fork.ForkedFromEventID != "parent:2" || fork.ForkedFromSequence != 2 {
t.Fatalf("fork lineage not recorded: %#v", fork)
}
if fork.EventCount != 3 || fork.LastEventType != EventSessionFork {
t.Fatalf("fork event count/type wrong: %#v", fork)
}
events, err := store.ReadEvents(fork.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if len(events) != 3 || events[0].ID != "fork:1" || events[2].Type != EventSessionFork {
t.Fatalf("fork events not copied/remapped: %#v", events)
}
}
func TestDefaultRootHonorsXDGDataHome(t *testing.T) {
got := DefaultRoot(map[string]string{
"XDG_DATA_HOME": "/tmp/zero-data",
"HOME": "/tmp/home",
})
want := filepath.Join("/tmp/zero-data", "zero", "sessions")
if got != want {
t.Fatalf("DefaultRoot = %q, want %q", got, want)
}
}
func TestStoreRejectsUnsafeSessionIDsAndBadJSONL(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T12:00:00Z")})
if _, err := store.Create(CreateInput{SessionID: "../escape"}); err == nil {
t.Fatal("expected unsafe session id to be rejected")
}
if err := os.MkdirAll(filepath.Join(store.RootDir, "bad"), 0o700); err != nil {
t.Fatalf("mkdir failed: %v", err)
}
if err := os.WriteFile(filepath.Join(store.RootDir, "bad", "metadata.json"), []byte(`{"sessionId":"bad","createdAt":"x","updatedAt":"x","eventCount":1}`), 0o600); err != nil {
t.Fatalf("metadata write failed: %v", err)
}
if err := os.WriteFile(filepath.Join(store.RootDir, "bad", "events.jsonl"), []byte("{not-json}\n"), 0o600); err != nil {
t.Fatalf("events write failed: %v", err)
}
_, err := store.ReadEvents("bad")
if err == nil || !strings.Contains(err.Error(), "events.jsonl at line 1") {
t.Fatalf("expected JSONL line error, got %v", err)
}
}
func TestStoreGeneratesUniqueIDsWithFixedClock(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T12:30:00Z")})
first, err := store.Create(CreateInput{})
if err != nil {
t.Fatalf("Create first returned error: %v", err)
}
second, err := store.Create(CreateInput{})
if err != nil {
t.Fatalf("Create second returned error: %v", err)
}
if first.SessionID == second.SessionID {
t.Fatalf("generated session ids collided: %q", first.SessionID)
}
}
func TestStoreAppendEventSerializesConcurrentWriters(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T13:00:00Z")})
session, err := store.Create(CreateInput{SessionID: "concurrent"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
const total = 24
var wg sync.WaitGroup
errs := make(chan error, total)
for index := 0; index < total; index++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
_, err := store.AppendEvent(session.SessionID, AppendEventInput{
Type: EventMessage,
Payload: map[string]int{"index": index},
})
errs <- err
}(index)
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("AppendEvent returned error: %v", err)
}
}
events, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if len(events) != total {
t.Fatalf("expected %d events, got %d: %#v", total, len(events), events)
}
seen := map[int]bool{}
for _, event := range events {
if seen[event.Sequence] {
t.Fatalf("duplicate event sequence %d in %#v", event.Sequence, events)
}
seen[event.Sequence] = true
if event.ID != fmt.Sprintf("%s:%d", session.SessionID, event.Sequence) {
t.Fatalf("event id/sequence mismatch: %#v", event)
}
}
for sequence := 1; sequence <= total; sequence++ {
if !seen[sequence] {
t.Fatalf("missing event sequence %d in %#v", sequence, events)
}
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if loaded == nil || loaded.EventCount != total || loaded.LastEventType != EventMessage {
t.Fatalf("metadata not updated after concurrent append: %#v", loaded)
}
}
func fixedClock(value string) func() time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return func() time.Time { return parsed }
}
-144
View File
@@ -1,144 +0,0 @@
package tools
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
type applyPatchTool struct {
baseTool
workspaceRoot string
}
func NewApplyPatchTool(workspaceRoot string) Tool {
return applyPatchTool{
baseTool: baseTool{
name: "apply_patch",
description: "Apply a unified diff patch inside the workspace.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"patch": {Type: "string", Description: "Unified diff patch to apply."},
"cwd": {Type: "string", Description: "Directory where the patch should be applied. Defaults to workspace root.", Default: "."},
},
Required: []string{"patch"},
AdditionalProperties: false,
},
safety: promptSafety(SideEffectWrite, "Applies patch hunks that can create, edit, or delete files."),
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
}
}
func (tool applyPatchTool) Run(ctx context.Context, args map[string]any) Result {
patch, err := stringArg(args, "patch", "", true)
if err != nil {
return errorResult("Error: Invalid arguments for apply_patch: " + err.Error())
}
cwd, err := stringArg(args, "cwd", ".", false)
if err != nil {
return errorResult("Error: Invalid arguments for apply_patch: " + err.Error())
}
applyRoot, relativeRoot, err := resolveWorkspacePath(tool.workspaceRoot, cwd)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
if err := validatePatchPaths(applyRoot, patch); err != nil {
return errorResult("Error applying patch: " + err.Error())
}
tempFile, err := os.CreateTemp("", "zero-patch-*.patch")
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
patchPath := tempFile.Name()
defer func() {
_ = os.Remove(patchPath)
}()
if _, err := tempFile.WriteString(patch); err != nil {
_ = tempFile.Close()
return errorResult("Error applying patch: " + err.Error())
}
if err := tempFile.Close(); err != nil {
return errorResult("Error applying patch: " + err.Error())
}
if err := recheckPatchWriteTargets(applyRoot, patch); err != nil {
return errorResult("Error applying patch: " + err.Error())
}
command := exec.CommandContext(ctx, "git", "apply", "--whitespace=nowarn", patchPath)
command.Dir = applyRoot
output, err := command.CombinedOutput()
if err != nil {
message := strings.TrimSpace(string(output))
if message == "" {
message = err.Error()
}
return errorResult("Error applying patch: " + message)
}
if relativeRoot == "." {
return okResult("Patch applied successfully.")
}
return okResult("Patch applied successfully in " + relativeRoot + ".")
}
func validatePatchPaths(root string, patch string) error {
for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") {
for _, path := range patchPathsFromLine(line) {
if path == "" || path == "/dev/null" {
continue
}
if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(path, "../") {
return fmt.Errorf("patch path %q must stay inside the workspace", path)
}
if _, _, err := resolveWorkspaceTargetPath(root, path); err != nil {
return err
}
}
}
return nil
}
func recheckPatchWriteTargets(root string, patch string) error {
for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") {
for _, path := range patchPathsFromLine(line) {
if path == "" || path == "/dev/null" {
continue
}
if err := recheckWorkspaceWriteTarget(root, path); err != nil {
return err
}
}
}
return nil
}
func patchPathsFromLine(line string) []string {
if strings.HasPrefix(line, "diff --git ") {
fields := strings.Fields(line)
if len(fields) >= 4 {
return []string{stripPatchPrefix(fields[2]), stripPatchPrefix(fields[3])}
}
}
if strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") {
fields := strings.Fields(line)
if len(fields) >= 2 {
return []string{stripPatchPrefix(fields[1])}
}
}
return nil
}
func stripPatchPrefix(path string) string {
path = strings.TrimSpace(path)
path = strings.TrimPrefix(path, "a/")
path = strings.TrimPrefix(path, "b/")
return filepath.ToSlash(path)
}
-78
View File
@@ -1,78 +0,0 @@
package tools
import (
"fmt"
"math"
)
func stringArg(args map[string]any, key string, fallback string, required bool) (string, error) {
return stringArgWithEmpty(args, key, fallback, required, false)
}
func stringArgWithEmpty(args map[string]any, key string, fallback string, required bool, allowEmpty bool) (string, error) {
value, ok := args[key]
if !ok || value == nil {
if required {
return "", fmt.Errorf("%s is required", key)
}
return fallback, nil
}
text, ok := value.(string)
if !ok {
return "", fmt.Errorf("%s must be a string", key)
}
if !allowEmpty && text == "" {
return "", fmt.Errorf("%s must be a non-empty string", key)
}
return text, nil
}
func boolArg(args map[string]any, key string, fallback bool) (bool, error) {
value, ok := args[key]
if !ok || value == nil {
return fallback, nil
}
boolean, ok := value.(bool)
if !ok {
return false, fmt.Errorf("%s must be a boolean", key)
}
return boolean, nil
}
func intArg(args map[string]any, key string, fallback int, min int, max int) (int, error) {
value, ok := args[key]
if !ok || value == nil {
return fallback, nil
}
var number int
switch typed := value.(type) {
case int:
number = typed
case int32:
number = int(typed)
case int64:
number = int(typed)
case float64:
if math.Trunc(typed) != typed {
return 0, fmt.Errorf("%s must be an integer", key)
}
number = int(typed)
default:
return 0, fmt.Errorf("%s must be an integer", key)
}
if number < min {
return 0, fmt.Errorf("%s must be at least %d", key, min)
}
if max > 0 && number > max {
return 0, fmt.Errorf("%s must be at most %d", key, max)
}
return number, nil
}
func intPtr(value int) *int {
return &value
}
-153
View File
@@ -1,153 +0,0 @@
package tools
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
)
const defaultBashTimeoutMS = 120000
const maxBashTimeoutMS = 600000
type bashTool struct {
baseTool
workspaceRoot string
}
func NewBashTool(workspaceRoot string) Tool {
return bashTool{
baseTool: baseTool{
name: "bash",
description: "Execute a shell command inside the workspace after permission is granted.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"command": {Type: "string", Description: "Shell command to execute."},
"cwd": {Type: "string", Description: "Workspace directory to run the command in. Defaults to workspace root.", Default: "."},
"timeout_ms": {Type: "integer", Description: "Command timeout in milliseconds.", Default: defaultBashTimeoutMS, Minimum: intPtr(1), Maximum: intPtr(maxBashTimeoutMS)},
},
Required: []string{"command"},
AdditionalProperties: false,
},
safety: promptSafety(SideEffectShell, "Shell commands can read, write, or execute programs."),
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
}
}
func (tool bashTool) Run(ctx context.Context, args map[string]any) Result {
commandText, err := stringArg(args, "command", "", true)
if err != nil {
return errorResult("Error: Invalid arguments for bash: " + err.Error())
}
cwd, err := stringArg(args, "cwd", ".", false)
if err != nil {
return errorResult("Error: Invalid arguments for bash: " + err.Error())
}
timeoutMS, err := intArg(args, "timeout_ms", defaultBashTimeoutMS, 1, maxBashTimeoutMS)
if err != nil {
return errorResult("Error: Invalid arguments for bash: " + err.Error())
}
absoluteCwd, relativeCwd, err := resolveWorkspacePath(tool.workspaceRoot, cwd)
if err != nil {
return errorResult("Error running bash: " + err.Error())
}
commandCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutMS)*time.Millisecond)
defer cancel()
command := exec.CommandContext(commandCtx, shellExecutable(), shellArguments(commandText)...)
command.Dir = absoluteCwd
var stdout bytes.Buffer
var stderr bytes.Buffer
command.Stdout = &stdout
command.Stderr = &stderr
err = command.Run()
exitCode := commandExitCode(err)
meta := map[string]string{
"cwd": relativeCwd,
"exit_code": strconv.Itoa(exitCode),
"timeout_ms": strconv.Itoa(timeoutMS),
}
if errors.Is(commandCtx.Err(), context.DeadlineExceeded) {
return Result{
Status: StatusError,
Output: fmt.Sprintf("Error: Command timed out after %dms.", timeoutMS),
Meta: meta,
}
}
if err != nil {
if exitCode < 0 {
return Result{
Status: StatusError,
Output: "Error executing command: " + err.Error(),
Meta: meta,
}
}
return Result{
Status: StatusError,
Output: formatBashOutput(stdout.String(), stderr.String(), exitCode),
Meta: meta,
}
}
return Result{
Status: StatusOK,
Output: formatBashOutput(stdout.String(), stderr.String(), exitCode),
Meta: meta,
}
}
func shellExecutable() string {
if runtime.GOOS == "windows" {
return "cmd.exe"
}
return "/bin/sh"
}
func shellArguments(command string) []string {
if runtime.GOOS == "windows" {
return []string{"/d", "/s", "/c", command}
}
return []string{"-c", command}
}
func commandExitCode(err error) int {
if err == nil {
return 0
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return exitError.ExitCode()
}
return -1
}
func formatBashOutput(stdout string, stderr string, exitCode int) string {
parts := []string{}
stdout = strings.TrimRight(stdout, "\r\n")
stderr = strings.TrimRight(stderr, "\r\n")
if stdout != "" {
parts = append(parts, "stdout:\n"+stdout)
}
if stderr != "" {
parts = append(parts, "stderr:\n"+stderr)
}
if exitCode != 0 {
parts = append(parts, fmt.Sprintf("exit_code: %d", exitCode))
}
if len(parts) == 0 {
return "Command completed with no output."
}
return strings.Join(parts, "\n")
}
-190
View File
@@ -1,190 +0,0 @@
package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
func TestMain(m *testing.M) {
if len(os.Args) >= 3 && os.Args[1] == "--zero-bash-helper" {
runBashToolHelper(os.Args[2])
return
}
os.Exit(m.Run())
}
func runBashToolHelper(command string) {
switch command {
case "success":
fmt.Println("hello from bash")
case "stderr":
fmt.Fprintln(os.Stderr, "warning from bash")
case "fail":
fmt.Println("before failure")
fmt.Fprintln(os.Stderr, "failure details")
os.Exit(7)
case "pwd":
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(cwd)
case "sleep":
time.Sleep(250 * time.Millisecond)
fmt.Println("woke up")
default:
fmt.Fprintln(os.Stderr, "unknown helper command")
os.Exit(2)
}
}
func TestCoreToolsExposeBashTool(t *testing.T) {
toolset := CoreTools(t.TempDir())
byName := make(map[string]Tool, len(toolset))
for _, tool := range toolset {
byName[tool.Name()] = tool
}
tool, ok := byName["bash"]
if !ok {
t.Fatalf("expected core tools to include bash")
}
if tool.Safety().SideEffect != SideEffectShell {
t.Fatalf("bash side effect = %s, want shell", tool.Safety().SideEffect)
}
if tool.Safety().Permission != PermissionPrompt {
t.Fatalf("bash permission = %s, want prompt", tool.Safety().Permission)
}
}
func TestRegistryBlocksBashWithoutGrant(t *testing.T) {
registry := NewRegistry()
registry.Register(NewBashTool(t.TempDir()))
result := registry.Run(context.Background(), "bash", map[string]any{
"command": helperCommand("success"),
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "Permission required for bash") {
t.Fatalf("expected permission error, got %q", result.Output)
}
}
func TestBashToolRunsCommandInWorkspace(t *testing.T) {
root := t.TempDir()
result := NewBashTool(root).Run(context.Background(), map[string]any{
"command": helperCommand("success"),
})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
if !strings.Contains(result.Output, "stdout:\nhello from bash") {
t.Fatalf("expected stdout in output, got %q", result.Output)
}
if result.Meta["exit_code"] != "0" {
t.Fatalf("expected exit_code metadata 0, got %q", result.Meta["exit_code"])
}
if result.Meta["cwd"] != "." {
t.Fatalf("expected cwd metadata ., got %q", result.Meta["cwd"])
}
}
func TestBashToolUsesRequestedCwd(t *testing.T) {
root := t.TempDir()
nested := filepath.Join(root, "nested")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatal(err)
}
result := NewBashTool(root).Run(context.Background(), map[string]any{
"command": helperCommand("pwd"),
"cwd": "nested",
})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
normalizedOutput := filepath.ToSlash(strings.TrimSpace(result.Output))
if !strings.Contains(normalizedOutput, "stdout:\n") || !strings.HasSuffix(normalizedOutput, "/nested") {
t.Fatalf("expected command to run in nested cwd, got %q", result.Output)
}
if result.Meta["cwd"] != "nested" {
t.Fatalf("expected cwd metadata nested, got %q", result.Meta["cwd"])
}
}
func TestBashToolRejectsCwdOutsideWorkspace(t *testing.T) {
outside := t.TempDir()
result := NewBashTool(t.TempDir()).Run(context.Background(), map[string]any{
"command": helperCommand("success"),
"cwd": outside,
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "must stay inside the workspace") {
t.Fatalf("expected workspace error, got %q", result.Output)
}
}
func TestBashToolReturnsNonzeroExitAsError(t *testing.T) {
result := NewBashTool(t.TempDir()).Run(context.Background(), map[string]any{
"command": helperCommand("fail"),
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
for _, want := range []string{"stdout:\nbefore failure", "stderr:\nfailure details", "exit_code: 7"} {
if !strings.Contains(result.Output, want) {
t.Fatalf("expected output to contain %q, got %q", want, result.Output)
}
}
if result.Meta["exit_code"] != "7" {
t.Fatalf("expected exit_code metadata 7, got %q", result.Meta["exit_code"])
}
}
func TestBashToolTimesOut(t *testing.T) {
result := NewBashTool(t.TempDir()).Run(context.Background(), map[string]any{
"command": helperCommand("sleep"),
"timeout_ms": 20,
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "timed out after 20ms") {
t.Fatalf("expected timeout error, got %q", result.Output)
}
if result.Meta["timeout_ms"] != "20" {
t.Fatalf("expected timeout_ms metadata 20, got %q", result.Meta["timeout_ms"])
}
}
func helperCommand(name string) string {
executable := shellQuote(os.Args[0])
return executable + " --zero-bash-helper " + name
}
func shellQuote(value string) string {
if runtime.GOOS == "windows" {
return value
}
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
}
-94
View File
@@ -1,94 +0,0 @@
package tools
import (
"context"
"fmt"
"os"
"strings"
)
type editFileTool struct {
baseTool
workspaceRoot string
}
func NewEditFileTool(workspaceRoot string) Tool {
return editFileTool{
baseTool: baseTool{
name: "edit_file",
description: "Replace an exact string in an existing file with uniqueness protection by default.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"path": {Type: "string", Description: "Path of the file to edit."},
"old_string": {Type: "string", Description: "Exact string to replace. Must match byte-for-byte."},
"new_string": {Type: "string", Description: "Replacement string. May be empty."},
"replace_all": {Type: "boolean", Description: "Replace every occurrence instead of requiring uniqueness.", Default: false},
},
Required: []string{"path", "old_string", "new_string"},
AdditionalProperties: false,
},
safety: promptSafety(SideEffectWrite, "Edits files in place."),
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
}
}
func (tool editFileTool) Run(_ context.Context, args map[string]any) Result {
requestedPath, err := stringArg(args, "path", "", true)
if err != nil {
return errorResult("Error: Invalid arguments for edit_file: " + err.Error())
}
oldString, err := stringArg(args, "old_string", "", true)
if err != nil {
return errorResult("Error: Invalid arguments for edit_file: " + err.Error())
}
newString, err := stringArgWithEmpty(args, "new_string", "", true, true)
if err != nil {
return errorResult("Error: Invalid arguments for edit_file: " + err.Error())
}
replaceAll, err := boolArg(args, "replace_all", false)
if err != nil {
return errorResult("Error: Invalid arguments for edit_file: " + err.Error())
}
absolutePath, relativePath, err := resolveWorkspacePath(tool.workspaceRoot, requestedPath)
if err != nil {
return errorResult("Error reading " + requestedPath + ": " + err.Error())
}
contentBytes, err := os.ReadFile(absolutePath)
if err != nil {
return errorResult("Error reading " + relativePath + ": " + err.Error())
}
content := string(contentBytes)
occurrences := strings.Count(content, oldString)
if occurrences == 0 {
return errorResult("Error: Could not find the exact string to replace in " + relativePath + ". The old_string must match the file byte-for-byte.")
}
if !replaceAll && occurrences > 1 {
return errorResult(fmt.Sprintf("Error: old_string matches %d locations in %s. Either make old_string more specific, or pass replace_all: true to replace every occurrence.", occurrences, relativePath))
}
updated := strings.Replace(content, oldString, newString, 1)
replacedCount := 1
if replaceAll {
updated = strings.ReplaceAll(content, oldString, newString)
replacedCount = occurrences
}
if updated == content {
return okResult("No changes: new_string is identical to old_string.")
}
if err := recheckWorkspaceWriteTarget(tool.workspaceRoot, requestedPath); err != nil {
return errorResult("Error writing " + relativePath + ": " + err.Error())
}
if err := os.WriteFile(absolutePath, []byte(updated), 0o644); err != nil {
return errorResult("Error writing " + relativePath + ": " + err.Error())
}
suffix := ""
if replacedCount != 1 {
suffix = "s"
}
return okResult(fmt.Sprintf("Successfully edited %s (replaced %d occurrence%s).", relativePath, replacedCount, suffix))
}
-184
View File
@@ -1,184 +0,0 @@
package tools
import (
"context"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
func TestReadFileToolReadsLineRanges(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\ngamma\ndelta")
result := NewReadFileTool(root).Run(context.Background(), map[string]any{
"path": "notes.txt",
"start_line": 2,
"max_lines": 2,
})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
for _, want := range []string{
"File: notes.txt (lines 2-3 of 4)",
"2 | beta",
"3 | gamma",
} {
if !strings.Contains(result.Output, want) {
t.Fatalf("expected output to contain %q, got %q", want, result.Output)
}
}
if strings.Contains(result.Output, "alpha") || strings.Contains(result.Output, "delta") {
t.Fatalf("line range leaked outside requested slice: %q", result.Output)
}
}
func TestReadFileToolRejectsOutsideWorkspace(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "secret.txt")
writeTestFile(t, outside, "secret")
result := NewReadFileTool(root).Run(context.Background(), map[string]any{
"path": outside,
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "must stay inside the workspace") {
t.Fatalf("expected workspace error, got %q", result.Output)
}
}
func TestListDirectoryToolListsRecursivelyAndIgnoresJunk(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "src", "main.go"), "package main")
writeTestFile(t, filepath.Join(root, "node_modules", "leftpad", "index.js"), "module.exports = 1")
writeTestFile(t, filepath.Join(root, "README.md"), "# Zero")
result := NewListDirectoryTool(root).Run(context.Background(), map[string]any{
"path": ".",
"recursive": true,
"max_depth": 2,
})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
if !strings.Contains(result.Output, "src/") || !strings.Contains(result.Output, "main.go") {
t.Fatalf("expected recursive source listing, got %q", result.Output)
}
if !strings.Contains(result.Output, "README.md") {
t.Fatalf("expected README.md, got %q", result.Output)
}
if strings.Contains(result.Output, "node_modules") || strings.Contains(result.Output, "leftpad") {
t.Fatalf("expected ignored junk directory, got %q", result.Output)
}
}
func TestGlobToolFindsMatchesWithLimit(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "a.go"), "package zero")
writeTestFile(t, filepath.Join(root, "nested", "b.go"), "package nested")
writeTestFile(t, filepath.Join(root, "nested", "c.txt"), "text")
result := NewGlobTool(root).Run(context.Background(), map[string]any{
"pattern": "**/*.go",
"limit": 1,
})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
if result.Truncated != true {
t.Fatalf("expected truncated result")
}
matchedPaths := regexp.MustCompile(`(?m)^[^\n]*\.go\b`).FindAllString(result.Output, -1)
if got := len(matchedPaths); got != 1 {
t.Fatalf("expected exactly one go match, got %d in %q", got, result.Output)
}
}
func TestGlobToolCanIncludeDirectoryMatches(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil {
t.Fatal(err)
}
result := NewGlobTool(root).Run(context.Background(), map[string]any{
"pattern": "src",
"include_dirs": true,
})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
if strings.TrimSpace(result.Output) != "src" {
t.Fatalf("expected src directory match, got %q", result.Output)
}
}
func TestGrepToolSearchesContent(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "cmd", "main.go"), "package main\nfunc main() {}\n")
writeTestFile(t, filepath.Join(root, "README.md"), "main docs\n")
result := NewGrepTool(root).Run(context.Background(), map[string]any{
"pattern": "func main",
"path": ".",
"glob": "**/*.go",
"head_limit": 5,
})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
if !strings.Contains(result.Output, "cmd/main.go:2: func main() {}") {
t.Fatalf("expected formatted grep result, got %q", result.Output)
}
if strings.Contains(result.Output, "README.md") {
t.Fatalf("glob filter leaked README match: %q", result.Output)
}
}
func TestGrepToolSupportsFilesAndCountModes(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "a.txt"), "needle\nneedle\n")
writeTestFile(t, filepath.Join(root, "b.txt"), "needle\n")
files := NewGrepTool(root).Run(context.Background(), map[string]any{
"pattern": "needle",
"output_mode": "files_with_matches",
})
if files.Status != StatusOK {
t.Fatalf("expected files result, got %s: %s", files.Status, files.Output)
}
if !strings.Contains(files.Output, "a.txt") || !strings.Contains(files.Output, "b.txt") {
t.Fatalf("expected both files, got %q", files.Output)
}
count := NewGrepTool(root).Run(context.Background(), map[string]any{
"pattern": "needle",
"output_mode": "count",
})
if count.Status != StatusOK {
t.Fatalf("expected count result, got %s: %s", count.Status, count.Output)
}
if count.Output != "3 matches found" {
t.Fatalf("expected count output, got %q", count.Output)
}
}
func writeTestFile(t *testing.T, path string, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
-151
View File
@@ -1,151 +0,0 @@
package tools
import (
"context"
"fmt"
"io/fs"
"path/filepath"
"regexp"
"sort"
"strings"
)
type globTool struct {
baseTool
workspaceRoot string
}
func NewGlobTool(workspaceRoot string) Tool {
return globTool{
baseTool: baseTool{
name: "glob",
description: "Find files by glob pattern inside the workspace.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"pattern": {Type: "string", Description: `Glob pattern to match, for example "**/*.go".`},
"cwd": {Type: "string", Description: "Directory to scan. Defaults to workspace root.", Default: "."},
"limit": {Type: "integer", Description: "Maximum matches to return.", Default: 100, Minimum: intPtr(1), Maximum: intPtr(1000)},
"include_dirs": {Type: "boolean", Description: "Whether directory matches should be included.", Default: false},
},
Required: []string{"pattern"},
AdditionalProperties: false,
},
safety: readOnlySafety("Finds matching paths without reading contents or modifying files."),
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
}
}
func (tool globTool) Run(_ context.Context, args map[string]any) Result {
pattern, err := stringArg(args, "pattern", "", true)
if err != nil {
return errorResult("Error: Invalid arguments for glob: " + err.Error())
}
cwd, err := stringArg(args, "cwd", ".", false)
if err != nil {
return errorResult("Error: Invalid arguments for glob: " + err.Error())
}
limit, err := intArg(args, "limit", 100, 1, 1000)
if err != nil {
return errorResult("Error: Invalid arguments for glob: " + err.Error())
}
includeDirs, err := boolArg(args, "include_dirs", false)
if err != nil {
return errorResult("Error: Invalid arguments for glob: " + err.Error())
}
root, _, err := resolveWorkspacePath(tool.workspaceRoot, cwd)
if err != nil {
return errorResult("Error running glob " + fmt.Sprintf("%q", pattern) + ": " + err.Error())
}
matcher, err := compileGlob(pattern)
if err != nil {
return errorResult("Error running glob " + fmt.Sprintf("%q", pattern) + ": " + err.Error())
}
matches, err := scanGlob(root, matcher, includeDirs)
if err != nil {
return errorResult("Error running glob " + fmt.Sprintf("%q", pattern) + ": " + err.Error())
}
if len(matches) == 0 {
return okResult("No matches found for " + pattern)
}
sort.Strings(matches)
truncated := len(matches) > limit
if truncated {
matches = matches[:limit]
}
return Result{
Status: StatusOK,
Output: strings.Join(matches, "\n"),
Truncated: truncated,
Meta: map[string]string{
"pattern": pattern,
},
}
}
func scanGlob(root string, matcher *regexp.Regexp, includeDirs bool) ([]string, error) {
matches := []string{}
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if path == root {
return nil
}
if entry.IsDir() && shouldSkipDirectory(entry.Name()) {
return filepath.SkipDir
}
if entry.IsDir() && !includeDirs {
return nil
}
relative, err := filepath.Rel(root, path)
if err != nil {
return err
}
normalized := filepath.ToSlash(relative)
if matcher.MatchString(normalized) {
matches = append(matches, normalized)
}
return nil
})
return matches, err
}
func compileGlob(pattern string) (*regexp.Regexp, error) {
if pattern == "" {
return nil, fmt.Errorf("pattern is required")
}
return regexp.Compile("^" + globToRegexp(filepath.ToSlash(pattern)) + "$")
}
func globToRegexp(pattern string) string {
var builder strings.Builder
for index := 0; index < len(pattern); index++ {
char := pattern[index]
switch char {
case '*':
if index+1 < len(pattern) && pattern[index+1] == '*' {
index++
if index+1 < len(pattern) && pattern[index+1] == '/' {
index++
builder.WriteString("(?:.*/)?")
} else {
builder.WriteString(".*")
}
} else {
builder.WriteString("[^/]*")
}
case '?':
builder.WriteString("[^/]")
default:
builder.WriteString(regexp.QuoteMeta(string(char)))
}
}
return builder.String()
}
-223
View File
@@ -1,223 +0,0 @@
package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
type grepTool struct {
baseTool
workspaceRoot string
}
type grepMatch struct {
file string
line int
text string
hits int
}
func NewGrepTool(workspaceRoot string) Tool {
return grepTool{
baseTool: baseTool{
name: "grep",
description: "Search file contents with a regular expression inside the workspace.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"pattern": {Type: "string", Description: "Regular expression pattern to search for."},
"path": {Type: "string", Description: "Directory or file to search. Defaults to workspace root.", Default: "."},
"glob": {Type: "string", Description: `Optional glob filter, for example "**/*.go".`},
"output_mode": {Type: "string", Description: "Output mode.", Enum: []string{"content", "files_with_matches", "count"}, Default: "content"},
"-i": {Type: "boolean", Description: "Case insensitive search.", Default: false},
"case_insensitive": {Type: "boolean", Description: "Case insensitive search.", Default: false},
"head_limit": {Type: "integer", Description: "Maximum content results to return.", Default: 50, Minimum: intPtr(1)},
},
Required: []string{"pattern"},
AdditionalProperties: false,
},
safety: readOnlySafety("Searches file paths and matching lines without modifying files."),
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
}
}
func (tool grepTool) Run(_ context.Context, args map[string]any) Result {
pattern, err := stringArg(args, "pattern", "", true)
if err != nil {
return errorResult("Error: Invalid arguments for grep: " + err.Error())
}
targetPath, err := stringArg(args, "path", ".", false)
if err != nil {
return errorResult("Error: Invalid arguments for grep: " + err.Error())
}
globPattern, err := stringArg(args, "glob", "", false)
if err != nil {
return errorResult("Error: Invalid arguments for grep: " + err.Error())
}
outputMode, err := stringArg(args, "output_mode", "content", false)
if err != nil {
return errorResult("Error: Invalid arguments for grep: " + err.Error())
}
if outputMode != "content" && outputMode != "files_with_matches" && outputMode != "count" {
return errorResult("Error: Invalid arguments for grep: output_mode must be content, files_with_matches, or count")
}
caseInsensitive, err := boolArg(args, "case_insensitive", false)
if err != nil {
return errorResult("Error: Invalid arguments for grep: " + err.Error())
}
shortInsensitive, err := boolArg(args, "-i", false)
if err != nil {
return errorResult("Error: Invalid arguments for grep: " + err.Error())
}
headLimit, err := intArg(args, "head_limit", 50, 1, 0)
if err != nil {
return errorResult("Error: Invalid arguments for grep: " + err.Error())
}
if caseInsensitive || shortInsensitive {
pattern = "(?i)" + pattern
}
compiled, err := regexp.Compile(pattern)
if err != nil {
return errorResult("Error running grep: " + err.Error())
}
target, _, err := resolveWorkspacePath(tool.workspaceRoot, targetPath)
if err != nil {
return errorResult("Error running grep: " + err.Error())
}
var globMatcher *regexp.Regexp
if globPattern != "" {
globMatcher, err = compileGlob(globPattern)
if err != nil {
return errorResult("Error running grep: " + err.Error())
}
}
files, err := grepFiles(tool.workspaceRoot, target, globMatcher)
if err != nil {
return errorResult("Error running grep: " + err.Error())
}
matches := collectGrepMatches(tool.workspaceRoot, files, compiled)
if len(matches) == 0 {
if outputMode == "count" {
return okResult("0 matches found")
}
return okResult("No matches found.")
}
switch outputMode {
case "count":
total := 0
for _, match := range matches {
total += match.hits
}
return okResult(fmt.Sprintf("%d matches found", total))
case "files_with_matches":
seen := map[string]bool{}
files := []string{}
for _, match := range matches {
if !seen[match.file] {
seen[match.file] = true
files = append(files, match.file)
}
}
sort.Strings(files)
return okResult(strings.Join(files, "\n"))
default:
lines := make([]string, 0, len(matches))
for _, match := range matches {
if len(lines) >= headLimit {
break
}
lines = append(lines, fmt.Sprintf("%s:%d: %s", match.file, match.line, match.text))
}
return Result{
Status: StatusOK,
Output: strings.Join(lines, "\n"),
Truncated: len(matches) > headLimit,
}
}
}
func grepFiles(workspaceRoot string, target string, globMatcher *regexp.Regexp) ([]string, error) {
info, err := os.Stat(target)
if err != nil {
return nil, err
}
if !info.IsDir() {
relative, err := filepath.Rel(workspaceRoot, target)
if err != nil {
return nil, err
}
if globMatcher == nil || globMatcher.MatchString(filepath.ToSlash(relative)) {
return []string{target}, nil
}
return []string{}, nil
}
files := []string{}
err = filepath.WalkDir(target, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if path == target {
return nil
}
if entry.IsDir() && shouldSkipDirectory(entry.Name()) {
return filepath.SkipDir
}
if entry.IsDir() {
return nil
}
relative, err := filepath.Rel(workspaceRoot, path)
if err != nil {
return err
}
if globMatcher == nil || globMatcher.MatchString(filepath.ToSlash(relative)) {
files = append(files, path)
}
return nil
})
if err != nil {
return nil, err
}
sort.Strings(files)
return files, nil
}
func collectGrepMatches(workspaceRoot string, files []string, compiled *regexp.Regexp) []grepMatch {
matches := []grepMatch{}
for _, file := range files {
content, err := os.ReadFile(file)
if err != nil {
continue
}
relative, err := filepath.Rel(workspaceRoot, file)
if err != nil {
continue
}
for index, line := range strings.Split(strings.ReplaceAll(string(content), "\r\n", "\n"), "\n") {
lineMatches := compiled.FindAllStringIndex(line, -1)
if len(lineMatches) == 0 {
continue
}
matches = append(matches, grepMatch{
file: filepath.ToSlash(relative),
line: index + 1,
text: strings.TrimRight(line, "\r"),
hits: len(lineMatches),
})
}
}
return matches
}
-103
View File
@@ -1,103 +0,0 @@
package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
type listDirectoryTool struct {
baseTool
workspaceRoot string
}
func NewListDirectoryTool(workspaceRoot string) Tool {
return listDirectoryTool{
baseTool: baseTool{
name: "list_directory",
description: "List files and directories in a workspace path with optional recursion.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"path": {Type: "string", Description: "Directory to list. Defaults to workspace root.", Default: "."},
"recursive": {Type: "boolean", Description: "Whether to list recursively.", Default: false},
"max_depth": {Type: "integer", Description: "Maximum recursion depth when recursive is true.", Default: 2, Minimum: intPtr(1), Maximum: intPtr(5)},
},
AdditionalProperties: false,
},
safety: readOnlySafety("Lists directory entries without modifying files."),
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
}
}
func (tool listDirectoryTool) Run(_ context.Context, args map[string]any) Result {
requestedPath, err := stringArg(args, "path", ".", false)
if err != nil {
return errorResult("Error: Invalid arguments for list_directory: " + err.Error())
}
recursive, err := boolArg(args, "recursive", false)
if err != nil {
return errorResult("Error: Invalid arguments for list_directory: " + err.Error())
}
maxDepth, err := intArg(args, "max_depth", 2, 1, 5)
if err != nil {
return errorResult("Error: Invalid arguments for list_directory: " + err.Error())
}
if !recursive {
maxDepth = 0
}
absolutePath, relativePath, err := resolveWorkspacePath(tool.workspaceRoot, requestedPath)
if err != nil {
return errorResult("Error listing directory " + requestedPath + ": " + err.Error())
}
entries, err := listDirectoryEntries(absolutePath, 0, maxDepth)
if err != nil {
return errorResult("Error listing directory " + relativePath + ": " + err.Error())
}
if len(entries) == 0 {
return okResult("Directory is empty: " + relativePath)
}
return okResult("Contents of " + relativePath + ":\n\n" + strings.Join(entries, "\n"))
}
func listDirectoryEntries(path string, depth int, maxDepth int) ([]string, error) {
dirEntries, err := os.ReadDir(path)
if err != nil {
return nil, err
}
sort.Slice(dirEntries, func(left int, right int) bool {
if dirEntries[left].IsDir() != dirEntries[right].IsDir() {
return dirEntries[left].IsDir()
}
return dirEntries[left].Name() < dirEntries[right].Name()
})
results := make([]string, 0, len(dirEntries))
for _, entry := range dirEntries {
if entry.IsDir() && shouldSkipDirectory(entry.Name()) {
continue
}
indent := strings.Repeat(" ", depth)
if entry.IsDir() {
results = append(results, indent+entry.Name()+"/")
if depth < maxDepth {
children, err := listDirectoryEntries(filepath.Join(path, entry.Name()), depth+1, maxDepth)
if err == nil {
results = append(results, children...)
}
}
continue
}
results = append(results, fmt.Sprintf("%s%s", indent, entry.Name()))
}
return results, nil
}
-84
View File
@@ -1,84 +0,0 @@
package tools
import (
"context"
"strings"
"testing"
)
func TestUpdatePlanToolStoresAndFormatsPlan(t *testing.T) {
tool := NewUpdatePlanTool()
result := tool.Run(context.Background(), map[string]any{
"plan": []any{
map[string]any{"id": "1", "content": "First step", "status": "completed"},
map[string]any{"id": "2", "content": "Second step", "status": "in_progress", "notes": "halfway"},
map[string]any{"id": "3", "content": "Third step", "status": "pending"},
},
})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
for _, want := range []string{
"Current Plan:",
"1. [completed] First step",
"2. [in_progress] Second step",
"Notes: halfway",
"3. [pending] Third step",
} {
if !strings.Contains(result.Output, want) {
t.Fatalf("expected output to contain %q, got %q", want, result.Output)
}
}
plan := tool.CurrentPlan()
if len(plan) != 3 {
t.Fatalf("expected 3 plan items, got %d", len(plan))
}
plan[0].Content = "mutated"
if tool.CurrentPlan()[0].Content != "First step" {
t.Fatalf("CurrentPlan returned mutable internal state")
}
}
func TestUpdatePlanToolRejectsInvalidStatus(t *testing.T) {
result := NewUpdatePlanTool().Run(context.Background(), map[string]any{
"plan": []any{
map[string]any{"id": "1", "content": "Bad step", "status": "nope"},
},
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "status must be pending, in_progress, completed, or failed") {
t.Fatalf("unexpected output: %q", result.Output)
}
}
func TestUpdatePlanToolClearPlanResetsState(t *testing.T) {
tool := NewUpdatePlanTool()
result := tool.Run(context.Background(), map[string]any{
"plan": []any{
map[string]any{"id": "1", "content": "First", "status": "pending"},
map[string]any{"id": "2", "content": "Second", "status": "in_progress"},
},
})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
if got := tool.CurrentPlan(); len(got) == 0 {
t.Fatalf("expected stored plan before ClearPlan")
}
tool.ClearPlan()
if got := tool.CurrentPlan(); len(got) != 0 {
t.Fatalf("expected empty plan after ClearPlan, got %d items", len(got))
}
if got := formatPlan(tool.CurrentPlan()); got != "Plan is currently empty." {
t.Fatalf("expected empty plan formatting after ClearPlan, got %q", got)
}
}
-106
View File
@@ -1,106 +0,0 @@
package tools
import (
"context"
"fmt"
"os"
"strconv"
"strings"
)
type readFileTool struct {
baseTool
workspaceRoot string
}
func NewReadFileTool(workspaceRoot string) Tool {
return readFileTool{
baseTool: baseTool{
name: "read_file",
description: "Read a file with optional 1-based inclusive line range and max line cap.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"path": {Type: "string", Description: "Path of the file to read."},
"start_line": {Type: "integer", Description: "1-based inclusive line number to start reading from.", Minimum: intPtr(1)},
"end_line": {Type: "integer", Description: "1-based inclusive line number to stop reading at.", Minimum: intPtr(1)},
"max_lines": {Type: "integer", Description: "Maximum number of lines to return.", Minimum: intPtr(1)},
},
Required: []string{"path"},
AdditionalProperties: false,
},
safety: readOnlySafety("Reads file contents without modifying files."),
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
}
}
func (tool readFileTool) Run(_ context.Context, args map[string]any) Result {
requestedPath, err := stringArg(args, "path", "", true)
if err != nil {
return errorResult("Error: Invalid arguments for read_file: " + err.Error())
}
startLine, err := intArg(args, "start_line", 1, 1, 0)
if err != nil {
return errorResult("Error: Invalid arguments for read_file: " + err.Error())
}
endLine, err := intArg(args, "end_line", 0, 1, 0)
if err != nil {
return errorResult("Error: Invalid arguments for read_file: " + err.Error())
}
maxLines, err := intArg(args, "max_lines", 0, 1, 0)
if err != nil {
return errorResult("Error: Invalid arguments for read_file: " + err.Error())
}
absolutePath, relativePath, err := resolveWorkspacePath(tool.workspaceRoot, requestedPath)
if err != nil {
return errorResult("Error reading file " + requestedPath + ": " + err.Error())
}
content, err := os.ReadFile(absolutePath)
if err != nil {
return errorResult("Error reading file " + relativePath + ": " + err.Error())
}
normalizedContent := strings.ReplaceAll(string(content), "\r\n", "\n")
normalizedContent = strings.TrimSuffix(normalizedContent, "\n")
lines := strings.Split(normalizedContent, "\n")
total := len(lines)
if startLine > total {
return okResult(fmt.Sprintf("File: %s\n(start_line %d is past the end of the file, which has %d lines)", relativePath, startLine, total))
}
if endLine == 0 || endLine > total {
endLine = total
}
if endLine < startLine {
return errorResult("Error: Invalid arguments for read_file: end_line must be greater than or equal to start_line")
}
selected := lines[startLine-1 : endLine]
truncated := false
if maxLines > 0 && len(selected) > maxLines {
selected = selected[:maxLines]
truncated = true
}
lastLine := startLine + len(selected) - 1
width := len(strconv.Itoa(lastLine))
numbered := make([]string, 0, len(selected))
for index, line := range selected {
lineNumber := strconv.Itoa(startLine + index)
numbered = append(numbered, strings.Repeat(" ", width-len(lineNumber))+lineNumber+" | "+line)
}
header := fmt.Sprintf("File: %s (%d lines)", relativePath, total)
if startLine != 1 || endLine != total || maxLines > 0 {
header = fmt.Sprintf("File: %s (lines %d-%d of %d)", relativePath, startLine, lastLine, total)
}
return Result{
Status: StatusOK,
Output: header + "\n\n" + strings.Join(numbered, "\n"),
Truncated: truncated,
}
}
-86
View File
@@ -1,86 +0,0 @@
package tools
import "context"
type Registry struct {
tools map[string]Tool
}
type RunOptions struct {
PermissionGranted bool
}
func NewRegistry() *Registry {
return &Registry{tools: make(map[string]Tool)}
}
func (registry *Registry) Register(tool Tool) {
registry.tools[tool.Name()] = tool
}
func (registry *Registry) Get(name string) (Tool, bool) {
tool, ok := registry.tools[name]
return tool, ok
}
func (registry *Registry) All() []Tool {
tools := make([]Tool, 0, len(registry.tools))
for _, tool := range registry.tools {
tools = append(tools, tool)
}
return tools
}
func (registry *Registry) Run(ctx context.Context, name string, args map[string]any) Result {
return registry.RunWithOptions(ctx, name, args, RunOptions{})
}
func (registry *Registry) RunWithOptions(ctx context.Context, name string, args map[string]any, options RunOptions) Result {
tool, ok := registry.Get(name)
if !ok {
return errorResult(`Error: Unknown tool "` + name + `".`)
}
switch tool.Safety().Permission {
case PermissionAllow:
case PermissionPrompt:
if !options.PermissionGranted {
return errorResult("Error: Permission required for " + name + ": " + tool.Safety().Reason + ` The tool is marked "prompt" and was not executed.`)
}
default:
return errorResult("Error: Permission denied for " + name + ": " + tool.Safety().Reason)
}
return tool.Run(ctx, args)
}
func CoreReadOnlyTools(workspaceRoot string) []Tool {
return []Tool{
NewReadFileTool(workspaceRoot),
NewListDirectoryTool(workspaceRoot),
NewGlobTool(workspaceRoot),
NewGrepTool(workspaceRoot),
}
}
func CoreWriteTools(workspaceRoot string) []Tool {
return []Tool{
NewWriteFileTool(workspaceRoot),
NewEditFileTool(workspaceRoot),
NewApplyPatchTool(workspaceRoot),
NewUpdatePlanTool(),
}
}
func CoreShellTools(workspaceRoot string) []Tool {
return []Tool{
NewBashTool(workspaceRoot),
}
}
func CoreTools(workspaceRoot string) []Tool {
tools := append([]Tool{}, CoreReadOnlyTools(workspaceRoot)...)
tools = append(tools, CoreWriteTools(workspaceRoot)...)
tools = append(tools, CoreShellTools(workspaceRoot)...)
return tools
}
-69
View File
@@ -1,69 +0,0 @@
package tools
import (
"context"
"testing"
)
func TestCoreReadOnlyToolsExposeSafeMetadata(t *testing.T) {
toolset := CoreReadOnlyTools(t.TempDir())
if len(toolset) != 4 {
t.Fatalf("expected 4 core read-only tools, got %d", len(toolset))
}
for _, tool := range toolset {
if tool.Name() == "" {
t.Fatalf("tool has empty name")
}
if tool.Description() == "" {
t.Fatalf("%s has empty description", tool.Name())
}
if tool.Safety().SideEffect != SideEffectRead {
t.Fatalf("%s side effect = %s, want read", tool.Name(), tool.Safety().SideEffect)
}
if tool.Safety().Permission != PermissionAllow {
t.Fatalf("%s permission = %s, want allow", tool.Name(), tool.Safety().Permission)
}
if tool.Safety().Reason == "" {
t.Fatalf("%s has empty safety reason", tool.Name())
}
schema := tool.Parameters()
if schema.Type != "object" {
t.Fatalf("%s schema type = %s, want object", tool.Name(), schema.Type)
}
if schema.Properties == nil {
t.Fatalf("%s schema properties are nil", tool.Name())
}
if schema.AdditionalProperties {
t.Fatalf("%s schema should disallow additional properties", tool.Name())
}
}
}
func TestRegistryRunsToolsThroughSafePath(t *testing.T) {
registry := NewRegistry()
registry.Register(NewReadFileTool(t.TempDir()))
result := registry.Run(context.Background(), "read_file", map[string]any{
"path": "missing.txt",
})
if result.Status != StatusError {
t.Fatalf("expected read error status, got %s", result.Status)
}
if result.Output == "" {
t.Fatalf("expected an error output")
}
}
func TestRegistryReportsUnknownTools(t *testing.T) {
result := NewRegistry().Run(context.Background(), "missing", map[string]any{})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if result.Output != `Error: Unknown tool "missing".` {
t.Fatalf("unexpected output: %q", result.Output)
}
}
-110
View File
@@ -1,110 +0,0 @@
package tools
import "context"
type SideEffect string
type Permission string
type Status string
const (
SideEffectRead SideEffect = "read"
SideEffectWrite SideEffect = "write"
SideEffectShell SideEffect = "shell"
SideEffectNetwork SideEffect = "network"
SideEffectOutOfWorkspace SideEffect = "out_of_workspace"
)
const (
PermissionAllow Permission = "allow"
PermissionPrompt Permission = "prompt"
PermissionDeny Permission = "deny"
)
const (
StatusOK Status = "ok"
StatusError Status = "error"
)
type Safety struct {
SideEffect SideEffect
Permission Permission
Reason string
}
type Schema struct {
Type string `json:"type"`
Properties map[string]PropertySchema `json:"properties,omitempty"`
Required []string `json:"required,omitempty"`
AdditionalProperties bool `json:"additionalProperties"`
}
type PropertySchema struct {
Type string `json:"type"`
Description string `json:"description,omitempty"`
Enum []string `json:"enum,omitempty"`
Default any `json:"default,omitempty"`
Minimum *int `json:"minimum,omitempty"`
Maximum *int `json:"maximum,omitempty"`
}
type Result struct {
Status Status
Output string
Truncated bool
Meta map[string]string
}
type Tool interface {
Name() string
Description() string
Parameters() Schema
Safety() Safety
Run(ctx context.Context, args map[string]any) Result
}
type baseTool struct {
name string
description string
parameters Schema
safety Safety
}
func (tool baseTool) Name() string {
return tool.name
}
func (tool baseTool) Description() string {
return tool.description
}
func (tool baseTool) Parameters() Schema {
return tool.parameters
}
func (tool baseTool) Safety() Safety {
return tool.safety
}
func okResult(output string) Result {
return Result{Status: StatusOK, Output: output}
}
func errorResult(output string) Result {
return Result{Status: StatusError, Output: output}
}
func readOnlySafety(reason string) Safety {
return Safety{
SideEffect: SideEffectRead,
Permission: PermissionAllow,
Reason: reason,
}
}
func promptSafety(sideEffect SideEffect, reason string) Safety {
return Safety{
SideEffect: sideEffect,
Permission: PermissionPrompt,
Reason: reason,
}
}
-117
View File
@@ -1,117 +0,0 @@
package tools
import (
"context"
"fmt"
"strings"
)
type PlanItem struct {
ID string
Content string
Status string
Notes string
}
type updatePlanTool struct {
baseTool
currentPlan []PlanItem
}
func NewUpdatePlanTool() *updatePlanTool {
return &updatePlanTool{
baseTool: baseTool{
name: "update_plan",
description: "Create or update the in-memory plan for the current task.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"plan": {Type: "array", Description: "Ordered list of plan items."},
},
Required: []string{"plan"},
AdditionalProperties: false,
},
safety: readOnlySafety("Updates in-memory planning state only."),
},
}
}
func (tool *updatePlanTool) Run(_ context.Context, args map[string]any) Result {
plan, err := parsePlanItems(args["plan"])
if err != nil {
return errorResult("Error: Invalid arguments for update_plan: " + err.Error())
}
tool.currentPlan = plan
return okResult(formatPlan(plan))
}
func (tool *updatePlanTool) CurrentPlan() []PlanItem {
return append([]PlanItem{}, tool.currentPlan...)
}
func (tool *updatePlanTool) ClearPlan() {
tool.currentPlan = nil
}
func parsePlanItems(value any) ([]PlanItem, error) {
items, ok := value.([]any)
if !ok {
return nil, fmt.Errorf("plan must be an array")
}
plan := make([]PlanItem, 0, len(items))
for index, item := range items {
object, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("plan item %d must be an object", index+1)
}
id, err := stringArg(object, "id", "", true)
if err != nil {
return nil, fmt.Errorf("plan item %d %s", index+1, err.Error())
}
content, err := stringArg(object, "content", "", true)
if err != nil {
return nil, fmt.Errorf("plan item %d %s", index+1, err.Error())
}
status, err := stringArg(object, "status", "", true)
if err != nil {
return nil, fmt.Errorf("plan item %d %s", index+1, err.Error())
}
if !isPlanStatus(status) {
return nil, fmt.Errorf("plan item %d status must be pending, in_progress, completed, or failed", index+1)
}
notes, err := stringArgWithEmpty(object, "notes", "", false, true)
if err != nil {
return nil, fmt.Errorf("plan item %d %s", index+1, err.Error())
}
plan = append(plan, PlanItem{
ID: id,
Content: content,
Status: status,
Notes: notes,
})
}
return plan, nil
}
func isPlanStatus(status string) bool {
return status == "pending" || status == "in_progress" || status == "completed" || status == "failed"
}
func formatPlan(plan []PlanItem) string {
if len(plan) == 0 {
return "Plan is currently empty."
}
lines := make([]string, 0, len(plan))
for index, item := range plan {
line := fmt.Sprintf("%d. [%s] %s", index+1, item.Status, item.Content)
if item.Notes != "" {
line += "\n Notes: " + item.Notes
}
lines = append(lines, line)
}
return "Current Plan:\n" + strings.Join(lines, "\n")
}
-199
View File
@@ -1,199 +0,0 @@
package tools
import (
"fmt"
"os"
"path/filepath"
"strings"
)
var ignoredDirectories = map[string]bool{
".git": true,
"node_modules": true,
"dist": true,
"build": true,
".next": true,
".turbo": true,
"coverage": true,
".cache": true,
"tmp": true,
"temp": true,
}
func normalizeWorkspaceRoot(workspaceRoot string) string {
root, err := filepath.Abs(workspaceRoot)
if err != nil {
return workspaceRoot
}
return root
}
func resolveWorkspacePath(workspaceRoot string, requestedPath string) (string, string, error) {
if requestedPath == "" {
requestedPath = "."
}
root, err := filepath.Abs(workspaceRoot)
if err != nil {
return "", "", err
}
root, err = filepath.EvalSymlinks(root)
if err != nil {
return "", "", err
}
target := requestedPath
if !filepath.IsAbs(target) {
target = filepath.Join(root, target)
}
target, err = filepath.Abs(target)
if err != nil {
return "", "", err
}
target, err = filepath.EvalSymlinks(target)
if err != nil {
return "", "", err
}
relative, err := filepath.Rel(root, target)
if err != nil {
return "", "", err
}
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
return "", "", fmt.Errorf("%s must stay inside the workspace", requestedPath)
}
if relative == "." {
return target, ".", nil
}
return target, filepath.ToSlash(relative), nil
}
func resolveWorkspaceTargetPath(workspaceRoot string, requestedPath string) (string, string, error) {
if requestedPath == "" {
requestedPath = "."
}
root, err := filepath.Abs(workspaceRoot)
if err != nil {
return "", "", err
}
root, err = filepath.EvalSymlinks(root)
if err != nil {
return "", "", err
}
target := requestedPath
if !filepath.IsAbs(target) {
target = filepath.Join(root, target)
}
target, err = filepath.Abs(target)
if err != nil {
return "", "", err
}
if err := recheckWorkspaceWriteTarget(root, target); err != nil {
return "", "", err
}
existing := target
missingSegments := []string{}
for {
if _, err := os.Lstat(existing); err == nil {
break
} else if os.IsNotExist(err) {
parent := filepath.Dir(existing)
if parent == existing {
return "", "", err
}
missingSegments = append([]string{filepath.Base(existing)}, missingSegments...)
existing = parent
continue
} else {
return "", "", err
}
}
resolved, err := filepath.EvalSymlinks(existing)
if err != nil {
return "", "", err
}
for _, segment := range missingSegments {
resolved = filepath.Join(resolved, segment)
}
relative, err := filepath.Rel(root, resolved)
if err != nil {
return "", "", err
}
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
return "", "", fmt.Errorf("%s must stay inside the workspace", requestedPath)
}
if relative == "." {
return resolved, ".", nil
}
return resolved, filepath.ToSlash(relative), nil
}
func recheckWorkspaceWriteTarget(workspaceRoot string, requestedPath string) error {
if requestedPath == "" {
requestedPath = "."
}
root, err := filepath.Abs(workspaceRoot)
if err != nil {
return err
}
root, err = filepath.EvalSymlinks(root)
if err != nil {
return err
}
target := requestedPath
if !filepath.IsAbs(target) {
target = filepath.Join(root, target)
}
target, err = filepath.Abs(target)
if err != nil {
return err
}
relative, err := filepath.Rel(root, target)
if err != nil {
return err
}
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
return fmt.Errorf("%s must stay inside the workspace", requestedPath)
}
if relative == "." {
return nil
}
current := root
for _, segment := range strings.Split(filepath.Clean(relative), string(filepath.Separator)) {
if segment == "." || segment == "" {
continue
}
current = filepath.Join(current, segment)
info, err := os.Lstat(current)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.Mode()&os.ModeSymlink != 0 {
symlinkRelative, err := filepath.Rel(root, current)
if err != nil {
symlinkRelative = current
}
return fmt.Errorf("%s must not traverse symlink %s", requestedPath, filepath.ToSlash(symlinkRelative))
}
}
return nil
}
func shouldSkipDirectory(name string) bool {
return ignoredDirectories[name]
}
-79
View File
@@ -1,79 +0,0 @@
package tools
import (
"context"
"fmt"
"os"
"path/filepath"
)
type writeFileTool struct {
baseTool
workspaceRoot string
}
func NewWriteFileTool(workspaceRoot string) Tool {
return writeFileTool{
baseTool: baseTool{
name: "write_file",
description: "Create a new file, refusing to overwrite existing files unless overwrite is true.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"path": {Type: "string", Description: "Absolute or relative path of the file to write."},
"content": {Type: "string", Description: "Full file contents to write."},
"overwrite": {Type: "boolean", Description: "Whether to allow overwriting an existing file.", Default: false},
},
Required: []string{"path", "content"},
AdditionalProperties: false,
},
safety: promptSafety(SideEffectWrite, "Creates or overwrites files."),
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
}
}
func (tool writeFileTool) Run(_ context.Context, args map[string]any) Result {
requestedPath, err := stringArg(args, "path", "", true)
if err != nil {
return errorResult("Error: Invalid arguments for write_file: " + err.Error())
}
content, err := stringArgWithEmpty(args, "content", "", true, true)
if err != nil {
return errorResult("Error: Invalid arguments for write_file: " + err.Error())
}
overwrite, err := boolArg(args, "overwrite", false)
if err != nil {
return errorResult("Error: Invalid arguments for write_file: " + err.Error())
}
absolutePath, relativePath, err := resolveWorkspaceTargetPath(tool.workspaceRoot, requestedPath)
if err != nil {
return errorResult("Error writing file " + requestedPath + ": " + err.Error())
}
existed := false
if _, err := os.Stat(absolutePath); err == nil {
existed = true
if !overwrite {
return errorResult("Error: " + relativePath + " already exists. Pass overwrite: true to replace it.")
}
} else if !os.IsNotExist(err) {
return errorResult("Error writing file " + relativePath + ": " + err.Error())
}
if err := os.MkdirAll(filepath.Dir(absolutePath), 0o755); err != nil {
return errorResult("Error writing file " + relativePath + ": " + err.Error())
}
if err := recheckWorkspaceWriteTarget(tool.workspaceRoot, requestedPath); err != nil {
return errorResult("Error writing file " + relativePath + ": " + err.Error())
}
if err := os.WriteFile(absolutePath, []byte(content), 0o644); err != nil {
return errorResult("Error writing file " + relativePath + ": " + err.Error())
}
if existed {
return okResult(fmt.Sprintf("Overwrote %s (%d bytes).", relativePath, len([]byte(content))))
}
return okResult(fmt.Sprintf("Created %s (%d bytes).", relativePath, len([]byte(content))))
}
-386
View File
@@ -1,386 +0,0 @@
package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCoreToolsExposeWriteAndPlanTools(t *testing.T) {
toolset := CoreTools(t.TempDir())
byName := make(map[string]Tool, len(toolset))
for _, tool := range toolset {
byName[tool.Name()] = tool
}
for _, name := range []string{"write_file", "edit_file", "apply_patch"} {
tool, ok := byName[name]
if !ok {
t.Fatalf("expected core tools to include %s", name)
}
if tool.Safety().SideEffect != SideEffectWrite {
t.Fatalf("%s side effect = %s, want write", name, tool.Safety().SideEffect)
}
if tool.Safety().Permission != PermissionPrompt {
t.Fatalf("%s permission = %s, want prompt", name, tool.Safety().Permission)
}
}
planTool, ok := byName["update_plan"]
if !ok {
t.Fatalf("expected core tools to include update_plan")
}
if planTool.Safety().Permission != PermissionAllow {
t.Fatalf("update_plan permission = %s, want allow", planTool.Safety().Permission)
}
}
func TestRegistryBlocksPromptToolsWithoutGrant(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "blocked.txt")
registry := NewRegistry()
registry.Register(NewWriteFileTool(root))
result := registry.Run(context.Background(), "write_file", map[string]any{
"path": "blocked.txt",
"content": "nope",
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "Permission required for write_file") {
t.Fatalf("expected permission error, got %q", result.Output)
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatalf("expected file to remain absent, stat err=%v", err)
}
}
func TestRegistryRunsPromptToolsWithGrant(t *testing.T) {
root := t.TempDir()
registry := NewRegistry()
registry.Register(NewWriteFileTool(root))
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "allowed.txt",
"content": "hello",
}, RunOptions{PermissionGranted: true})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "allowed.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "hello" {
t.Fatalf("expected written content, got %q", string(content))
}
}
func TestWriteFileToolCreatesAndProtectsExistingFiles(t *testing.T) {
root := t.TempDir()
tool := NewWriteFileTool(root)
created := tool.Run(context.Background(), map[string]any{
"path": "nested/file.txt",
"content": "first",
})
if created.Status != StatusOK {
t.Fatalf("expected create ok, got %s: %s", created.Status, created.Output)
}
if !strings.Contains(created.Output, "Created nested/file.txt") {
t.Fatalf("unexpected create output: %q", created.Output)
}
refused := tool.Run(context.Background(), map[string]any{
"path": "nested/file.txt",
"content": "second",
})
if refused.Status != StatusError {
t.Fatalf("expected overwrite refusal, got %s", refused.Status)
}
content, err := os.ReadFile(filepath.Join(root, "nested", "file.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "first" {
t.Fatalf("expected original content, got %q", string(content))
}
overwrote := tool.Run(context.Background(), map[string]any{
"path": "nested/file.txt",
"content": "second",
"overwrite": true,
})
if overwrote.Status != StatusOK {
t.Fatalf("expected overwrite ok, got %s: %s", overwrote.Status, overwrote.Output)
}
content, err = os.ReadFile(filepath.Join(root, "nested", "file.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "second" {
t.Fatalf("expected overwritten content, got %q", string(content))
}
}
func TestWriteFileToolAllowsEmptyContent(t *testing.T) {
root := t.TempDir()
result := NewWriteFileTool(root).Run(context.Background(), map[string]any{
"path": "empty.txt",
"content": "",
})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "empty.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "" {
t.Fatalf("expected empty file, got %q", string(content))
}
}
func TestWriteFileToolReportsTypeErrorsForEmptyAllowedStrings(t *testing.T) {
result := NewWriteFileTool(t.TempDir()).Run(context.Background(), map[string]any{
"path": "bad.txt",
"content": 42,
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "content must be a string") {
t.Fatalf("expected string type error, got %q", result.Output)
}
}
func TestWriteFileToolRejectsOutsideWorkspace(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "outside.txt")
result := NewWriteFileTool(root).Run(context.Background(), map[string]any{
"path": outside,
"content": "secret",
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "must stay inside the workspace") {
t.Fatalf("expected workspace error, got %q", result.Output)
}
if _, err := os.Stat(outside); !os.IsNotExist(err) {
t.Fatalf("expected outside file to remain absent, stat err=%v", err)
}
}
func TestWriteFileToolRejectsSymlinkParent(t *testing.T) {
root := t.TempDir()
realDirectory := filepath.Join(root, "real")
if err := os.MkdirAll(realDirectory, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(realDirectory, filepath.Join(root, "link")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
result := NewWriteFileTool(root).Run(context.Background(), map[string]any{
"path": "link/escape.txt",
"content": "secret",
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "must not traverse symlink") {
t.Fatalf("expected symlink error, got %q", result.Output)
}
if _, err := os.Stat(filepath.Join(realDirectory, "escape.txt")); !os.IsNotExist(err) {
t.Fatalf("expected symlink target file to remain absent, stat err=%v", err)
}
}
func TestEditFileToolReplacesExactStrings(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "code.go")
writeTestFile(t, path, "const a = 1\nconst b = 2\n")
result := NewEditFileTool(root).Run(context.Background(), map[string]any{
"path": "code.go",
"old_string": "const a = 1",
"new_string": "const a = 42",
})
if result.Status != StatusOK {
t.Fatalf("expected edit ok, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(content) != "const a = 42\nconst b = 2\n" {
t.Fatalf("unexpected edited content: %q", string(content))
}
}
func TestEditFileToolAllowsDeletingRegions(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "notes.txt")
writeTestFile(t, path, "keep\nremove\nkeep\n")
result := NewEditFileTool(root).Run(context.Background(), map[string]any{
"path": "notes.txt",
"old_string": "remove\n",
"new_string": "",
})
if result.Status != StatusOK {
t.Fatalf("expected edit ok, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(content) != "keep\nkeep\n" {
t.Fatalf("unexpected edited content: %q", string(content))
}
}
func TestEditFileToolRejectsMissingAndAmbiguousMatches(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "dup.txt")
writeTestFile(t, path, "x\nx\n")
tool := NewEditFileTool(root)
missing := tool.Run(context.Background(), map[string]any{
"path": "dup.txt",
"old_string": "missing",
"new_string": "y",
})
if missing.Status != StatusError || !strings.Contains(missing.Output, "Could not find") {
t.Fatalf("expected missing error, got %s: %s", missing.Status, missing.Output)
}
ambiguous := tool.Run(context.Background(), map[string]any{
"path": "dup.txt",
"old_string": "x",
"new_string": "y",
})
if ambiguous.Status != StatusError || !strings.Contains(ambiguous.Output, "matches 2 locations") {
t.Fatalf("expected ambiguity error, got %s: %s", ambiguous.Status, ambiguous.Output)
}
all := tool.Run(context.Background(), map[string]any{
"path": "dup.txt",
"old_string": "x",
"new_string": "y",
"replace_all": true,
})
if all.Status != StatusOK {
t.Fatalf("expected replace_all ok, got %s: %s", all.Status, all.Output)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(content) != "y\ny\n" {
t.Fatalf("expected all replacements, got %q", string(content))
}
}
func TestApplyPatchToolAppliesUnifiedDiff(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "hello.txt"), "hello\nold\n")
patch := strings.Join([]string{
"diff --git a/hello.txt b/hello.txt",
"--- a/hello.txt",
"+++ b/hello.txt",
"@@ -1,2 +1,2 @@",
" hello",
"-old",
"+new",
"",
}, "\n")
result := NewApplyPatchTool(root).Run(context.Background(), map[string]any{
"patch": patch,
})
if result.Status != StatusOK {
t.Fatalf("expected patch ok, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatal(err)
}
if strings.ReplaceAll(string(content), "\r\n", "\n") != "hello\nnew\n" {
t.Fatalf("unexpected patched content: %q", string(content))
}
}
func TestApplyPatchToolRejectsSymlinkPath(t *testing.T) {
root := t.TempDir()
realDirectory := filepath.Join(root, "real")
if err := os.MkdirAll(realDirectory, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(realDirectory, filepath.Join(root, "link")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
patch := strings.Join([]string{
"diff --git a/link/new.txt b/link/new.txt",
"new file mode 100644",
"index 0000000..e965047",
"--- /dev/null",
"+++ b/link/new.txt",
"@@ -0,0 +1 @@",
"+hello",
"",
}, "\n")
result := NewApplyPatchTool(root).Run(context.Background(), map[string]any{
"patch": patch,
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "must not traverse symlink") {
t.Fatalf("expected symlink error, got %q", result.Output)
}
if _, err := os.Stat(filepath.Join(realDirectory, "new.txt")); !os.IsNotExist(err) {
t.Fatalf("expected symlink target file to remain absent, stat err=%v", err)
}
}
func TestApplyPatchToolRejectsOutsideWorkspace(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
result := NewApplyPatchTool(root).Run(context.Background(), map[string]any{
"cwd": outside,
"patch": strings.Join([]string{
"diff --git a/nope.txt b/nope.txt",
"--- a/nope.txt",
"+++ b/nope.txt",
"@@ -0,0 +1 @@",
"+nope",
"",
}, "\n"),
})
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
if !strings.Contains(result.Output, "must stay inside the workspace") {
t.Fatalf("expected workspace error, got %q", result.Output)
}
}
-226
View File
@@ -1,226 +0,0 @@
package tui
import "strings"
type commandKind int
const (
commandEmpty commandKind = iota
commandPrompt
commandHelp
commandClear
commandExit
commandTools
commandPermissions
commandProvider
commandModel
commandContext
commandConfig
commandDebug
commandDoctor
commandPlan
commandSearch
commandTheme
commandInputStyle
commandUnknown
)
type commandGroup string
const (
commandGroupSession commandGroup = "session"
commandGroupModel commandGroup = "model"
commandGroupRuntime commandGroup = "runtime"
commandGroupTools commandGroup = "tools"
commandGroupMeta commandGroup = "meta"
)
type commandDefinition struct {
name string
aliases []string
usage string
group commandGroup
description string
kind commandKind
}
type parsedCommand struct {
kind commandKind
text string
name string
}
var commandDefinitions = []commandDefinition{
{
name: "/provider",
usage: "/provider",
group: commandGroupModel,
description: "Show the active provider.",
kind: commandProvider,
},
{
name: "/model",
usage: "/model [list|id]",
group: commandGroupModel,
description: "Show the active model and model-shell status.",
kind: commandModel,
},
{
name: "/plan",
usage: "/plan",
group: commandGroupSession,
description: "Show planning mode status.",
kind: commandPlan,
},
{
name: "/permissions",
usage: "/permissions",
group: commandGroupRuntime,
description: "Show the active permission mode.",
kind: commandPermissions,
},
{
name: "/tools",
usage: "/tools",
group: commandGroupTools,
description: "List registered tools.",
kind: commandTools,
},
{
name: "/context",
usage: "/context",
group: commandGroupSession,
description: "Show current workspace and runtime context.",
kind: commandContext,
},
{
name: "/clear",
usage: "/clear",
group: commandGroupMeta,
description: "Clear the visible transcript.",
kind: commandClear,
},
{
name: "/search",
usage: "/search",
group: commandGroupSession,
description: "Show code search shell status.",
kind: commandSearch,
},
{
name: "/doctor",
usage: "/doctor",
group: commandGroupRuntime,
description: "Show local diagnostics shell status.",
kind: commandDoctor,
},
{
name: "/config",
usage: "/config",
group: commandGroupRuntime,
description: "Show active configuration summary.",
kind: commandConfig,
},
{
name: "/debug",
aliases: []string{"/debug-mode"},
usage: "/debug",
group: commandGroupRuntime,
description: "Show debug mode status.",
kind: commandDebug,
},
{
name: "/theme",
usage: "/theme",
group: commandGroupSession,
description: "Show theme shell status.",
kind: commandTheme,
},
{
name: "/input-style",
usage: "/input-style",
group: commandGroupSession,
description: "Show input style shell status.",
kind: commandInputStyle,
},
{
name: "/help",
usage: "/help",
group: commandGroupMeta,
description: "Show available commands.",
kind: commandHelp,
},
{
name: "/exit",
aliases: []string{"/quit"},
usage: "/exit",
group: commandGroupMeta,
description: "Exit Zero.",
kind: commandExit,
},
}
func parseCommand(input string) parsedCommand {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return parsedCommand{kind: commandEmpty}
}
if strings.HasPrefix(trimmed, "/") {
name, args := splitCommand(trimmed)
command, ok := resolveCommand(name)
if ok {
return parsedCommand{kind: command.kind, name: command.name, text: args}
}
return parsedCommand{kind: commandUnknown, text: trimmed}
}
return parsedCommand{kind: commandPrompt, text: trimmed}
}
func splitCommand(input string) (string, string) {
parts := strings.Fields(input)
if len(parts) == 0 {
return "", ""
}
name := parts[0]
args := strings.TrimSpace(input[len(name):])
return strings.ToLower(name), args
}
func resolveCommand(name string) (commandDefinition, bool) {
normalized := strings.ToLower(strings.TrimSpace(name))
for _, command := range commandDefinitions {
if normalized == command.name {
return command, true
}
for _, alias := range command.aliases {
if normalized == alias {
return command, true
}
}
}
return commandDefinition{}, false
}
func listCommandNames() []string {
names := make([]string, 0, len(commandDefinitions))
for _, command := range commandDefinitions {
names = append(names, command.name)
names = append(names, command.aliases...)
}
return names
}
func formatCommandHelpLines() []string {
lines := make([]string, 0, len(commandDefinitions))
for _, command := range commandDefinitions {
label := command.usage
if len(command.aliases) > 0 {
label += " (" + strings.Join(command.aliases, ", ") + ")"
}
lines = append(lines, label+" - "+command.description)
}
return lines
}
-453
View File
@@ -1,453 +0,0 @@
package tui
import (
"context"
"fmt"
"os"
"sort"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
var (
headerStyle = lipgloss.NewStyle().Bold(true)
footerStyle = lipgloss.NewStyle().Faint(true)
)
type model struct {
ctx context.Context
cwd string
providerName string
modelName string
provider zeroruntime.Provider
registry *tools.Registry
agentOptions agent.Options
permissionMode agent.PermissionMode
transcript []transcriptRow
input textinput.Model
pending bool
exiting bool
runCancel context.CancelFunc
runID int
activeRunID int
}
type agentResponseMsg struct {
runID int
rows []transcriptRow
err error
}
func newModel(ctx context.Context, options Options) model {
if ctx == nil {
ctx = context.Background()
}
cwd := options.Cwd
if cwd == "" {
if current, err := os.Getwd(); err == nil {
cwd = current
}
}
registry := options.Registry
if registry == nil {
registry = options.AgentOptions.Registry
}
if registry == nil {
registry = tools.NewRegistry()
}
permissionMode := options.PermissionMode
if permissionMode == "" {
permissionMode = options.AgentOptions.PermissionMode
}
if permissionMode == "" {
permissionMode = agent.PermissionModeAuto
}
input := textinput.New()
input.Prompt = "zero > "
input.Placeholder = "type a prompt or /help"
input.Focus()
return model{
ctx: ctx,
cwd: cwd,
providerName: options.ProviderName,
modelName: options.ModelName,
provider: options.Provider,
registry: registry,
agentOptions: options.AgentOptions,
permissionMode: permissionMode,
transcript: initialTranscript(),
input: input,
}
}
func (m model) Init() tea.Cmd {
return textinput.Blink
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyCtrlC:
m.cancelRun()
m.exiting = true
return m, tea.Quit
case tea.KeyEsc:
m.input.SetValue("")
if m.pending {
m.cancelRun()
}
return m, nil
case tea.KeyEnter:
return m.handleSubmit()
}
case agentResponseMsg:
if msg.runID != m.activeRunID {
return m, nil
}
m.pending = false
m.runCancel = nil
m.activeRunID = 0
for _, row := range msg.rows {
m.transcript = appendRow(m.transcript, row.kind, row.text)
}
if msg.err != nil {
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: msg.err.Error(),
})
}
return m, nil
}
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
func (m model) View() string {
var builder strings.Builder
builder.WriteString(headerStyle.Render(fmt.Sprintf("ZERO %s %s", m.cwd, m.providerStatus())))
builder.WriteString("\n\n")
for _, row := range m.transcript {
builder.WriteString(renderRow(row))
builder.WriteString("\n")
}
if m.pending {
builder.WriteString("assistant: working...\n")
}
builder.WriteString("\n")
builder.WriteString(m.input.View())
builder.WriteString("\n\n")
builder.WriteString(footerStyle.Render(commandFooterText()))
return builder.String()
}
func (m model) handleSubmit() (tea.Model, tea.Cmd) {
command := parseCommand(m.input.Value())
if command.kind == commandPrompt && m.pending {
return m, nil
}
m.input.SetValue("")
switch command.kind {
case commandEmpty:
return m, nil
case commandHelp:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: helpText()})
return m, nil
case commandClear:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionClear})
return m, nil
case commandExit:
m.exiting = true
return m, tea.Quit
case commandTools:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.toolsText()})
return m, nil
case commandPermissions:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.permissionsText()})
return m, nil
case commandProvider:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.providerText()})
return m, nil
case commandModel:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.modelText(command.text)})
return m, nil
case commandContext:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.contextText()})
return m, nil
case commandConfig:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.configText()})
return m, nil
case commandDebug:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.debugText()})
return m, nil
case commandPlan:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.planText()})
return m, nil
case commandDoctor, commandSearch, commandTheme, commandInputStyle:
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendSystem,
text: shellOnlyCommandText(command.name),
})
return m, nil
case commandUnknown:
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "unknown command: " + command.text,
})
return m, nil
case commandPrompt:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: command.text})
if m.provider == nil {
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendAssistant,
text: "No provider configured.",
})
return m, nil
}
runCtx, cancel := context.WithCancel(m.ctx)
m.runID++
m.activeRunID = m.runID
m.runCancel = cancel
m.pending = true
return m, m.runAgent(m.activeRunID, runCtx, command.text)
default:
return m, nil
}
}
func (m *model) cancelRun() {
if m.runCancel != nil {
m.runCancel()
}
m.pending = false
m.runCancel = nil
m.activeRunID = 0
}
func (m model) runAgent(runID int, runCtx context.Context, prompt string) tea.Cmd {
return func() tea.Msg {
rows := []transcriptRow{}
options := m.agentOptions
options.Registry = m.registry
options.PermissionMode = m.permissionMode
onToolCall := options.OnToolCall
options.OnToolCall = func(call agent.ToolCall) {
rows = append(rows, transcriptRow{kind: rowToolCall, text: "tool call: " + call.Name})
if onToolCall != nil {
onToolCall(call)
}
}
onToolResult := options.OnToolResult
options.OnToolResult = func(result agent.ToolResult) {
rows = append(rows, transcriptRow{
kind: rowToolResult,
text: toolResultRowText(result),
})
if onToolResult != nil {
onToolResult(result)
}
}
result, err := agent.Run(runCtx, prompt, m.provider, options)
if err != nil {
return agentResponseMsg{runID: runID, rows: rows, err: err}
}
rows = append(rows, transcriptRow{kind: rowAssistant, text: result.FinalAnswer})
return agentResponseMsg{runID: runID, rows: rows}
}
}
func toolResultRowText(result agent.ToolResult) string {
status := result.Status
if status == "" {
status = tools.StatusOK
}
return fmt.Sprintf("tool result: %s %s %s", result.Name, status, result.Output)
}
func (m model) providerStatus() string {
provider := m.providerName
if provider == "" {
provider = "provider:none"
}
if m.modelName == "" {
return provider
}
return provider + "/" + m.modelName
}
func (m model) toolsText() string {
registered := m.registry.All()
if len(registered) == 0 {
return "No tools registered."
}
names := make([]string, 0, len(registered))
for _, tool := range registered {
names = append(names, tool.Name())
}
sort.Strings(names)
return "Tools: " + strings.Join(names, ", ")
}
func (m model) permissionsText() string {
return "Permission mode: " + string(m.permissionMode)
}
func (m model) providerText() string {
return strings.Join([]string{
"Provider",
"provider: " + displayValue(m.providerName, "none"),
"model: " + displayValue(m.modelName, "none"),
}, "\n")
}
func (m model) modelText(args string) string {
switch strings.ToLower(strings.TrimSpace(args)) {
case "list", "ls":
return m.modelListText()
}
lines := []string{
"Model",
"Active model: " + displayValue(m.modelName, "none"),
"provider: " + displayValue(m.providerName, "none"),
}
if args != "" {
lines = append(lines, "Model switching is not wired in Go TUI yet.")
} else {
lines = append(lines, "Use /model list to inspect the current model shell.")
}
return strings.Join(lines, "\n")
}
func (m model) contextText() string {
return strings.Join([]string{
"Context",
"cwd: " + displayValue(m.cwd, "unknown"),
"provider: " + displayValue(m.providerName, "none"),
"model: " + displayValue(m.modelName, "none"),
"permission mode: " + string(m.permissionMode),
fmt.Sprintf("tools: %d", len(m.registry.All())),
}, "\n")
}
func (m model) configText() string {
return strings.Join([]string{
"Config",
"provider: " + displayValue(m.providerName, "none"),
"model: " + displayValue(m.modelName, "none"),
"permission mode: " + string(m.permissionMode),
}, "\n")
}
func (m model) debugText() string {
state := "idle"
if m.pending {
state = "running"
}
return strings.Join([]string{
"Debug",
"run state: " + state,
"active run: " + fmt.Sprint(m.activeRunID),
"Debug mode is not wired in Go TUI yet.",
}, "\n")
}
func displayValue(value string, fallback string) string {
if value == "" {
return fallback
}
return value
}
func shellOnlyCommandText(name string) string {
return fmt.Sprintf("%s is registered in the Go TUI shell but is not wired yet.", name)
}
func helpText() string {
return "Commands:\n" + strings.Join(formatCommandHelpLines(), "\n") + "\nSubmit text to ask the assistant."
}
const defaultCommandFooterText = "/help /model /provider /context /tools /permissions /clear /exit Esc clear Ctrl+C quit"
func commandFooterText() string {
return formatCommandFooterText(commandDefinitions)
}
func formatCommandFooterText(commands []commandDefinition) string {
if len(commands) == 0 {
return defaultCommandFooterText
}
namesByKind := make(map[commandKind]string, len(commands))
for _, command := range commands {
namesByKind[command.kind] = command.name
}
featured := []commandKind{
commandHelp,
commandModel,
commandProvider,
commandContext,
commandTools,
commandPermissions,
commandClear,
commandExit,
}
parts := make([]string, 0, len(featured)+2)
for _, kind := range featured {
name := namesByKind[kind]
if name != "" {
parts = append(parts, name)
}
}
if len(parts) == 0 {
return defaultCommandFooterText
}
parts = append(parts, "Esc clear", "Ctrl+C quit")
return strings.Join(parts, " ")
}
func renderRow(row transcriptRow) string {
switch row.kind {
case rowWelcome:
return row.text
case rowUser:
return "user: " + row.text
case rowAssistant:
return "assistant: " + row.text
case rowToolCall:
return row.text
case rowToolResult:
return row.text
case rowError:
return "error: " + row.text
default:
return row.text
}
}
-50
View File
@@ -1,50 +0,0 @@
package tui
import (
"fmt"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/modelregistry"
)
func (m model) modelListText() string {
registry, err := modelregistry.DefaultRegistry()
if err != nil {
return "Models\nFailed to load model catalog: " + err.Error()
}
activeID := activeModelID(registry, m.modelName)
lines := []string{
"Models",
"Active model: " + displayValue(m.modelName, "none"),
"provider: " + displayValue(m.providerName, "none"),
"Available models:",
}
models := registry.List(modelregistry.ListOptions{})
sort.SliceStable(models, func(i, j int) bool {
if models[i].Provider == models[j].Provider {
return models[i].ID < models[j].ID
}
return models[i].Provider < models[j].Provider
})
for _, model := range models {
marker := " "
if activeID != "" && model.ID == activeID {
marker = "*"
}
lines = append(lines, fmt.Sprintf("%s %s (%s) - %s", marker, model.ID, model.Provider, model.DisplayName))
}
return strings.Join(lines, "\n")
}
func activeModelID(registry modelregistry.Registry, modelName string) string {
modelName = strings.TrimSpace(modelName)
if modelName == "" {
return ""
}
if model, ok := registry.Get(modelName); ok {
return model.ID
}
return strings.ToLower(modelName)
}
-459
View File
@@ -1,459 +0,0 @@
package tui
import (
"context"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type fakeProvider struct {
events []zeroruntime.StreamEvent
}
func (provider *fakeProvider) StreamCompletion(
ctx context.Context,
request zeroruntime.CompletionRequest,
) (<-chan zeroruntime.StreamEvent, error) {
ch := make(chan zeroruntime.StreamEvent, len(provider.events))
for _, event := range provider.events {
ch <- event
}
close(ch)
return ch, nil
}
func TestParseCommand(t *testing.T) {
cases := []struct {
input string
kind commandKind
text string
}{
{input: "", kind: commandEmpty},
{input: " ", kind: commandEmpty},
{input: "/help", kind: commandHelp},
{input: "/clear", kind: commandClear},
{input: "/exit", kind: commandExit},
{input: "/quit", kind: commandExit},
{input: "/tools", kind: commandTools},
{input: "/permissions", kind: commandPermissions},
{input: "/context", kind: commandContext},
{input: "/model", kind: commandModel},
{input: "/model list", kind: commandModel, text: "list"},
{input: "/debug-mode", kind: commandDebug},
{input: "hello zero", kind: commandPrompt, text: "hello zero"},
}
for _, tc := range cases {
t.Run(tc.input, func(t *testing.T) {
command := parseCommand(tc.input)
if command.kind != tc.kind || command.text != tc.text {
t.Fatalf("expected kind=%v text=%q, got kind=%v text=%q", tc.kind, tc.text, command.kind, command.text)
}
})
}
}
func TestCommandRegistryResolvesAliasesAndFormatsHelp(t *testing.T) {
names := listCommandNames()
for _, name := range []string{"/help", "/model", "/provider", "/context", "/debug-mode", "/quit"} {
if !stringSliceContains(names, name) {
t.Fatalf("expected command names to contain %s, got %#v", name, names)
}
}
resolved, ok := resolveCommand("/quit")
if !ok || resolved.kind != commandExit {
t.Fatalf("expected /quit to resolve to exit, got ok=%v command=%#v", ok, resolved)
}
help := strings.Join(formatCommandHelpLines(), "\n")
for _, want := range []string{"/model", "/context", "/debug", "/permissions", "model"} {
assertContains(t, help, want)
}
}
func TestTranscriptReducer(t *testing.T) {
transcript := initialTranscript()
transcript = reduceTranscript(transcript, transcriptAction{kind: actionAppendUser, text: "hello"})
transcript = reduceTranscript(transcript, transcriptAction{kind: actionAppendAssistant, text: "hi"})
transcript = reduceTranscript(transcript, transcriptAction{kind: actionAppendToolCall, name: "read_file"})
transcript = reduceTranscript(transcript, transcriptAction{kind: actionAppendToolResult, name: "read_file", text: "ok"})
if len(transcript) != 5 {
t.Fatalf("expected welcome plus four rows, got %#v", transcript)
}
if transcript[1].kind != rowUser || transcript[1].text != "hello" {
t.Fatalf("expected user row, got %#v", transcript[1])
}
if transcript[3].kind != rowToolCall || !strings.Contains(transcript[3].text, "read_file") {
t.Fatalf("expected tool-call placeholder, got %#v", transcript[3])
}
cleared := reduceTranscript(transcript, transcriptAction{kind: actionClear})
if len(cleared) != 1 || cleared[0].kind != rowWelcome {
t.Fatalf("expected clear to reset to welcome row, got %#v", cleared)
}
}
func TestInitialRenderContainsHeaderInputAndFooter(t *testing.T) {
model := newModel(context.Background(), Options{
Cwd: `D:\codings\Opensource\Zero`,
ProviderName: "fake",
ModelName: "m-test",
})
view := model.View()
assertContains(t, view, "ZERO")
assertContains(t, view, `D:\codings\Opensource\Zero`)
assertContains(t, view, "fake/m-test")
assertContains(t, view, "zero >")
assertContains(t, view, "/help")
assertContains(t, view, "/clear")
assertContains(t, view, "/exit")
assertContains(t, view, "Ctrl+C")
}
func TestCommandFooterTextUsesRegistryEntries(t *testing.T) {
footer := commandFooterText()
for _, command := range []string{"/help", "/model", "/provider", "/context", "/tools", "/permissions", "/clear", "/exit"} {
assertContains(t, footer, command)
}
assertContains(t, footer, "Esc clear")
assertContains(t, footer, "Ctrl+C quit")
}
func TestCommandFooterTextFallsBackWhenRegistryIsEmpty(t *testing.T) {
footer := formatCommandFooterText(nil)
for _, command := range []string{"/help", "/model", "/provider", "/context", "/tools", "/permissions", "/clear", "/exit"} {
assertContains(t, footer, command)
}
assertContains(t, footer, "Esc clear")
assertContains(t, footer, "Ctrl+C quit")
}
func TestHelpCommandAppendsHelpRow(t *testing.T) {
m := newModel(context.Background(), Options{})
m.input.SetValue("/help")
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if !transcriptContains(next.transcript, "/tools") {
t.Fatalf("expected help transcript to mention /tools, got %#v", next.transcript)
}
if !transcriptContains(next.transcript, "/model") || !transcriptContains(next.transcript, "/context") {
t.Fatalf("expected help transcript to mention model and context commands, got %#v", next.transcript)
}
}
func TestClearCommandResetsTranscript(t *testing.T) {
m := newModel(context.Background(), Options{})
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: "hello"})
m.input.SetValue("/clear")
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if len(next.transcript) != 1 || next.transcript[0].kind != rowWelcome {
t.Fatalf("expected clear to reset transcript, got %#v", next.transcript)
}
}
func TestToolsCommandListsRegisteredTools(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool("."))
m := newModel(context.Background(), Options{Registry: registry})
m.input.SetValue("/tools")
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if !transcriptContains(next.transcript, "read_file") {
t.Fatalf("expected tools transcript to list read_file, got %#v", next.transcript)
}
}
func TestPlanCommandShowsCurrentPlan(t *testing.T) {
registry := tools.NewRegistry()
planTool := tools.NewUpdatePlanTool()
result := planTool.Run(context.Background(), map[string]any{
"plan": []any{
map[string]any{
"id": "one",
"content": "Wire model catalog",
"status": "completed",
},
map[string]any{
"id": "two",
"content": "Add max turns",
"status": "in_progress",
"notes": "Go exec parity",
},
},
})
if result.Status != tools.StatusOK {
t.Fatalf("update_plan setup failed: %#v", result)
}
registry.Register(planTool)
m := newModel(context.Background(), Options{Registry: registry})
m.input.SetValue("/plan")
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
t.Fatal("expected /plan to be handled without starting an agent run")
}
for _, want := range []string{"Current Plan", "Wire model catalog", "Add max turns", "in_progress", "Go exec parity"} {
if !transcriptContains(next.transcript, want) {
t.Fatalf("expected plan transcript to contain %q, got %#v", want, next.transcript)
}
}
}
func TestPlanCommandHandlesMissingPlanTool(t *testing.T) {
m := newModel(context.Background(), Options{Registry: tools.NewRegistry()})
m.input.SetValue("/plan")
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if !transcriptContains(next.transcript, "No plan is active") {
t.Fatalf("expected missing plan message, got %#v", next.transcript)
}
}
func TestContextCommandShowsSessionState(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool("."))
m := newModel(context.Background(), Options{
Cwd: `D:\codings\Opensource\Zero`,
ProviderName: "openai",
ModelName: "gpt-4.1",
Registry: registry,
PermissionMode: agent.PermissionModeAsk,
})
m.input.SetValue("/context")
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
t.Fatal("expected /context to be handled without starting an agent run")
}
for _, want := range []string{
`D:\codings\Opensource\Zero`,
"provider: openai",
"model: gpt-4.1",
"permission mode: ask",
"tools: 1",
} {
if !transcriptContains(next.transcript, want) {
t.Fatalf("expected context transcript to contain %q, got %#v", want, next.transcript)
}
}
}
func TestModelCommandShowsActiveModelWithoutRunningAgent(t *testing.T) {
m := newModel(context.Background(), Options{
ProviderName: "openai",
ModelName: "gpt-4.1",
Provider: &fakeProvider{},
})
m.input.SetValue("/model list")
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
t.Fatal("expected /model to be handled without starting an agent run")
}
for _, want := range []string{"Active model: gpt-4.1", "provider: openai", "Available models", "* gpt-4.1"} {
if !transcriptContains(next.transcript, want) {
t.Fatalf("expected model transcript to contain %q, got %#v", want, next.transcript)
}
}
if !transcriptHasMarkedModelEntry(next.transcript) {
t.Fatalf("expected model transcript to contain a marked model entry, got %#v", next.transcript)
}
if transcriptContains(next.transcript, "Model switching") {
t.Fatalf("expected /model list to show catalog, got switching placeholder: %#v", next.transcript)
}
}
func TestModelCommandKeepsSwitchingStatusExplicit(t *testing.T) {
m := newModel(context.Background(), Options{
ProviderName: "openai",
ModelName: "gpt-4.1",
Provider: &fakeProvider{},
})
m.input.SetValue("/model gpt-4.1-mini")
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
t.Fatal("expected /model to be handled without starting an agent run")
}
for _, want := range []string{"Active model: gpt-4.1", "provider: openai", "Model switching is not wired"} {
if !transcriptContains(next.transcript, want) {
t.Fatalf("expected model transcript to contain %q, got %#v", want, next.transcript)
}
}
}
func TestPromptSubmitAppendsUserAndAssistantRows(t *testing.T) {
provider := &fakeProvider{events: []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventText, Content: "hello"},
{Type: zeroruntime.StreamEventText, Content: " back"},
{Type: zeroruntime.StreamEventDone},
}}
m := newModel(context.Background(), Options{
Provider: provider,
Registry: tools.NewRegistry(),
})
m.input.SetValue("say hi")
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if !transcriptContains(next.transcript, "say hi") {
t.Fatalf("expected user row after submit, got %#v", next.transcript)
}
if cmd == nil {
t.Fatal("expected submit to return agent command")
}
msg := cmd()
updated, _ = next.Update(msg)
next = updated.(model)
if !transcriptContains(next.transcript, "hello back") {
t.Fatalf("expected assistant row after agent response, got %#v", next.transcript)
}
}
func TestPromptSubmitDoesNotStartAnotherRunWhilePending(t *testing.T) {
m := newModel(context.Background(), Options{
Provider: &fakeProvider{},
Registry: tools.NewRegistry(),
})
m.pending = true
m.input.SetValue("second prompt")
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
t.Fatal("expected no command while another run is pending")
}
if transcriptContains(next.transcript, "second prompt") {
t.Fatalf("pending prompt should not be appended, got %#v", next.transcript)
}
if !next.pending {
t.Fatal("expected existing pending run to remain pending")
}
}
func TestEscCancelsPendingRun(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
next := updated.(model)
if !cancelled {
t.Fatal("expected Esc to cancel pending run")
}
if next.pending {
t.Fatal("expected Esc to clear pending state")
}
if next.activeRunID != 0 || next.runCancel != nil {
t.Fatalf("expected active run state to clear, got id=%d cancel=%v", next.activeRunID, next.runCancel)
}
}
func TestStaleAgentResponseAfterCancelIsIgnored(t *testing.T) {
m := newModel(context.Background(), Options{})
m.pending = false
m.activeRunID = 0
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: "new prompt"})
updated, _ := m.Update(agentResponseMsg{
runID: 1,
rows: []transcriptRow{{kind: rowAssistant, text: "stale response"}},
})
next := updated.(model)
if transcriptContains(next.transcript, "stale response") {
t.Fatalf("stale response should be ignored, got %#v", next.transcript)
}
}
func TestToolResultRowDefaultsEmptyStatusToOK(t *testing.T) {
text := toolResultRowText(agent.ToolResult{Name: "read_file", Output: "done"})
if !strings.Contains(text, "read_file ok done") {
t.Fatalf("expected empty status to render as ok, got %q", text)
}
}
func TestCtrlCExits(t *testing.T) {
m := newModel(context.Background(), Options{})
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
next := updated.(model)
if !next.exiting {
t.Fatal("expected Ctrl+C to mark model exiting")
}
if cmd == nil {
t.Fatal("expected Ctrl+C to return quit command")
}
}
func assertContains(t *testing.T, text string, want string) {
t.Helper()
if !strings.Contains(text, want) {
t.Fatalf("expected %q to contain %q", text, want)
}
}
func transcriptContains(rows []transcriptRow, want string) bool {
for _, row := range rows {
if strings.Contains(row.text, want) {
return true
}
}
return false
}
func transcriptHasMarkedModelEntry(rows []transcriptRow) bool {
for _, row := range rows {
for _, line := range strings.Split(row.text, "\n") {
if strings.HasPrefix(line, "* ") && strings.Contains(line, " (") {
return true
}
}
}
return false
}
func stringSliceContains(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
-19
View File
@@ -1,19 +0,0 @@
package tui
import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Options configures the reusable Zero terminal UI shell.
type Options struct {
Cwd string
ProviderName string
ModelName string
Provider zeroruntime.Provider
Registry *tools.Registry
AgentOptions agent.Options
PermissionMode agent.PermissionMode
}
-40
View File
@@ -1,40 +0,0 @@
package tui
import (
"fmt"
"strings"
"github.com/Gitlawb/zero/internal/tools"
)
type currentPlanReader interface {
CurrentPlan() []tools.PlanItem
}
func (m model) planText() string {
tool, ok := m.registry.Get("update_plan")
if !ok {
return "No plan is active."
}
reader, ok := tool.(currentPlanReader)
if !ok {
return "No plan is active."
}
plan := reader.CurrentPlan()
if len(plan) == 0 {
return "No plan is active."
}
lines := make([]string, 0, len(plan)+1)
lines = append(lines, "Current Plan")
for index, item := range plan {
line := fmt.Sprintf("%d. [%s] %s", index+1, item.Status, item.Content)
if item.Notes != "" {
line += "\n Notes: " + item.Notes
}
lines = append(lines, line)
}
return strings.Join(lines, "\n")
}
-23
View File
@@ -1,23 +0,0 @@
package tui
import (
"context"
"os"
tea "github.com/charmbracelet/bubbletea"
)
// Run starts the Zero Bubble Tea shell and returns a process-style exit code.
func Run(ctx context.Context, options Options) int {
program := tea.NewProgram(
newModel(ctx, options),
tea.WithContext(ctx),
tea.WithInput(os.Stdin),
tea.WithOutput(os.Stdout),
)
if _, err := program.Run(); err != nil {
return 1
}
return 0
}
-81
View File
@@ -1,81 +0,0 @@
package tui
import (
"fmt"
"github.com/Gitlawb/zero/internal/tools"
)
type rowKind int
const (
rowWelcome rowKind = iota
rowUser
rowAssistant
rowToolCall
rowToolResult
rowSystem
rowError
)
type transcriptRow struct {
kind rowKind
text string
}
type transcriptActionKind int
const (
actionAppendUser transcriptActionKind = iota
actionAppendAssistant
actionAppendToolCall
actionAppendToolResult
actionAppendSystem
actionAppendError
actionClear
)
type transcriptAction struct {
kind transcriptActionKind
text string
name string
status tools.Status
}
func initialTranscript() []transcriptRow {
return []transcriptRow{{
kind: rowWelcome,
text: "Welcome to Zero. Type /help for commands.",
}}
}
func reduceTranscript(rows []transcriptRow, action transcriptAction) []transcriptRow {
switch action.kind {
case actionClear:
return initialTranscript()
case actionAppendUser:
return appendRow(rows, rowUser, action.text)
case actionAppendAssistant:
return appendRow(rows, rowAssistant, action.text)
case actionAppendToolCall:
return appendRow(rows, rowToolCall, fmt.Sprintf("tool call: %s", action.name))
case actionAppendToolResult:
status := action.status
if status == "" {
status = tools.StatusOK
}
return appendRow(rows, rowToolResult, fmt.Sprintf("tool result: %s %s %s", action.name, status, action.text))
case actionAppendSystem:
return appendRow(rows, rowSystem, action.text)
case actionAppendError:
return appendRow(rows, rowError, action.text)
default:
return rows
}
}
func appendRow(rows []transcriptRow, kind rowKind, text string) []transcriptRow {
next := append([]transcriptRow{}, rows...)
next = append(next, transcriptRow{kind: kind, text: text})
return next
}
-284
View File
@@ -1,284 +0,0 @@
package usage
import (
"fmt"
"time"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type Normalized struct {
InputTokens int
CachedInputTokens int
OutputTokens int
ReasoningTokens int
TotalTokens int
}
type RecordInput struct {
ModelID string
Usage zeroruntime.Usage
Source string
}
type Record struct {
ID string
Sequence int
ModelID string
Provider modelregistry.ProviderKind
Source string
CreatedAt string
Usage Normalized
Cost modelregistry.CostBreakdown
}
type ModelSummary struct {
ModelID string
Provider modelregistry.ProviderKind
RecordCount int
InputTokens int
CachedInputTokens int
OutputTokens int
ReasoningTokens int
TotalTokens int
InputCost float64
CachedInputCost float64
OutputCost float64
TotalCost float64
FormattedTotalCost string
}
type Summary struct {
RecordCount int
Currency string
InputTokens int
CachedInputTokens int
OutputTokens int
ReasoningTokens int
TotalTokens int
InputCost float64
CachedInputCost float64
OutputCost float64
TotalCost float64
FormattedTotalCost string
ByModel []ModelSummary
LastRecord *Record
}
type TrackerOptions struct {
Now func() time.Time
Registry *modelregistry.Registry
}
type Tracker struct {
now func() time.Time
registry modelregistry.Registry
records []Record
nextSeq int
}
func NewTracker(options TrackerOptions) *Tracker {
registry := options.Registry
if registry == nil {
defaultRegistry, err := modelregistry.DefaultRegistry()
if err != nil {
panic(err)
}
registry = &defaultRegistry
}
now := options.Now
if now == nil {
now = time.Now
}
return &Tracker{now: now, registry: *registry, nextSeq: 1}
}
func (tracker *Tracker) Record(input RecordInput) (Record, error) {
model, err := tracker.registry.Require(input.ModelID)
if err != nil {
return Record{}, err
}
normalized, runtimeUsage, err := Normalize(input.Usage)
if err != nil {
return Record{}, err
}
cost, err := modelregistry.CalculateCost(model, runtimeUsage)
if err != nil {
return Record{}, err
}
sequence := tracker.nextSeq
tracker.nextSeq++
record := Record{
ID: fmt.Sprintf("zero_usage_%d", sequence),
Sequence: sequence,
ModelID: model.ID,
Provider: model.Provider,
Source: input.Source,
CreatedAt: tracker.now().UTC().Format(time.RFC3339),
Usage: normalized,
Cost: cost,
}
tracker.records = append(tracker.records, record)
return record, nil
}
func (tracker *Tracker) Records() []Record {
return append([]Record{}, tracker.records...)
}
func (tracker *Tracker) Summary() Summary {
summary := Summary{Currency: "USD"}
models := map[string]int{}
for _, record := range tracker.records {
summary.RecordCount++
addUsageToSummary(&summary, record.Usage)
addCostToSummary(&summary, record.Cost)
index, ok := models[record.ModelID]
if !ok {
summary.ByModel = append(summary.ByModel, ModelSummary{
ModelID: record.ModelID,
Provider: record.Provider,
})
index = len(summary.ByModel) - 1
models[record.ModelID] = index
}
addUsageToModel(&summary.ByModel[index], record.Usage)
addCostToModel(&summary.ByModel[index], record.Cost)
recordCopy := record
summary.LastRecord = &recordCopy
}
summary.FormattedTotalCost = formatCost(summary.TotalCost)
for index := range summary.ByModel {
summary.ByModel[index].FormattedTotalCost = formatCost(summary.ByModel[index].TotalCost)
}
return summary
}
func (tracker *Tracker) Reset() {
tracker.records = nil
tracker.nextSeq = 1
}
func Normalize(usage zeroruntime.Usage) (Normalized, zeroruntime.Usage, error) {
inputTokens, err := nonNegative(firstNonZero(usage.InputTokens, usage.PromptTokens), "inputTokens")
if err != nil {
return Normalized{}, zeroruntime.Usage{}, err
}
outputTokens, err := nonNegative(firstNonZero(usage.OutputTokens, usage.CompletionTokens), "outputTokens")
if err != nil {
return Normalized{}, zeroruntime.Usage{}, err
}
cachedInputTokens, err := nonNegative(usage.CachedInputTokens, "cachedInputTokens")
if err != nil {
return Normalized{}, zeroruntime.Usage{}, err
}
if cachedInputTokens > inputTokens {
cachedInputTokens = inputTokens
}
reasoningTokens, err := nonNegative(usage.ReasoningTokens, "reasoningTokens")
if err != nil {
return Normalized{}, zeroruntime.Usage{}, err
}
normalized := Normalized{
InputTokens: inputTokens,
CachedInputTokens: cachedInputTokens,
OutputTokens: outputTokens,
ReasoningTokens: reasoningTokens,
TotalTokens: inputTokens + outputTokens + reasoningTokens,
}
return normalized, zeroruntime.Usage{
InputTokens: inputTokens,
PromptTokens: inputTokens,
CachedInputTokens: cachedInputTokens,
OutputTokens: outputTokens,
CompletionTokens: outputTokens,
ReasoningTokens: reasoningTokens,
}, nil
}
func FormatSummary(summary Summary) string {
requestLabel := "requests"
if summary.RecordCount == 1 {
requestLabel = "request"
}
return fmt.Sprintf("%s %s, %s tokens, %s", comma(summary.RecordCount), requestLabel, comma(summary.TotalTokens), summary.FormattedTotalCost)
}
func addUsageToSummary(summary *Summary, usage Normalized) {
summary.InputTokens += usage.InputTokens
summary.CachedInputTokens += usage.CachedInputTokens
summary.OutputTokens += usage.OutputTokens
summary.ReasoningTokens += usage.ReasoningTokens
summary.TotalTokens += usage.TotalTokens
}
func addUsageToModel(summary *ModelSummary, usage Normalized) {
summary.RecordCount++
summary.InputTokens += usage.InputTokens
summary.CachedInputTokens += usage.CachedInputTokens
summary.OutputTokens += usage.OutputTokens
summary.ReasoningTokens += usage.ReasoningTokens
summary.TotalTokens += usage.TotalTokens
}
func addCostToSummary(summary *Summary, cost modelregistry.CostBreakdown) {
summary.InputCost += cost.InputCost
summary.CachedInputCost += cost.CachedInputCost
summary.OutputCost += cost.OutputCost
summary.TotalCost += cost.TotalCost
}
func addCostToModel(summary *ModelSummary, cost modelregistry.CostBreakdown) {
summary.InputCost += cost.InputCost
summary.CachedInputCost += cost.CachedInputCost
summary.OutputCost += cost.OutputCost
summary.TotalCost += cost.TotalCost
}
func formatCost(value float64) string {
formatted, err := modelregistry.FormatCostUSD(value)
if err != nil {
return "$0.0000"
}
return formatted
}
func nonNegative(value int, label string) (int, error) {
if value < 0 {
return 0, fmt.Errorf("expected %s to be non-negative", label)
}
return value, nil
}
func firstNonZero(values ...int) int {
for _, value := range values {
if value != 0 {
return value
}
}
return 0
}
func comma(value int) string {
sign := ""
if value < 0 {
sign = "-"
value = -value
}
digits := fmt.Sprintf("%d", value)
if len(digits) <= 3 {
return sign + digits
}
var out []byte
prefix := len(digits) % 3
if prefix == 0 {
prefix = 3
}
out = append(out, digits[:prefix]...)
for index := prefix; index < len(digits); index += 3 {
out = append(out, ',')
out = append(out, digits[index:index+3]...)
}
return sign + string(out)
}
-71
View File
@@ -1,71 +0,0 @@
package usage
import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestTrackerNormalizesUsageAndComputesModelCost(t *testing.T) {
tracker := NewTracker(TrackerOptions{Now: fixedUsageClock("2026-06-04T13:00:00Z")})
record, err := tracker.Record(RecordInput{
ModelID: "gpt-4.1",
Source: "exec",
Usage: zeroruntime.Usage{
PromptTokens: 1_000,
CompletionTokens: 250,
CachedInputTokens: 200,
},
})
if err != nil {
t.Fatalf("Record returned error: %v", err)
}
if record.Sequence != 1 || record.ID != "zero_usage_1" || record.CreatedAt != "2026-06-04T13:00:00Z" {
t.Fatalf("unexpected record identity: %#v", record)
}
if record.Usage.InputTokens != 1_000 || record.Usage.OutputTokens != 250 || record.Usage.TotalTokens != 1_250 {
t.Fatalf("usage not normalized: %#v", record.Usage)
}
if record.Cost.TotalCost <= 0 || record.Cost.ModelID != "gpt-4.1" {
t.Fatalf("cost not computed: %#v", record.Cost)
}
summary := tracker.Summary()
if summary.RecordCount != 1 || summary.TotalTokens != 1_250 || summary.ByModel[0].ModelID != "gpt-4.1" {
t.Fatalf("unexpected summary: %#v", summary)
}
if FormatSummary(summary) != "1 request, 1,250 tokens, "+summary.FormattedTotalCost {
t.Fatalf("unexpected formatted summary: %q", FormatSummary(summary))
}
}
func TestTrackerRejectsInvalidUsageAndUnknownModels(t *testing.T) {
tracker := NewTracker(TrackerOptions{})
if _, err := tracker.Record(RecordInput{ModelID: "missing", Usage: zeroruntime.Usage{InputTokens: 1}}); err == nil {
t.Fatal("expected unknown model error")
}
if _, err := tracker.Record(RecordInput{ModelID: "gpt-4.1", Usage: zeroruntime.Usage{InputTokens: -1}}); err == nil {
t.Fatal("expected invalid usage error")
}
}
func TestTrackerResetClearsRecords(t *testing.T) {
tracker := NewTracker(TrackerOptions{})
if _, err := tracker.Record(RecordInput{ModelID: "gpt-4.1", Usage: zeroruntime.Usage{InputTokens: 1, OutputTokens: 1}}); err != nil {
t.Fatalf("Record returned error: %v", err)
}
tracker.Reset()
if summary := tracker.Summary(); summary.RecordCount != 0 || len(summary.ByModel) != 0 {
t.Fatalf("Reset did not clear tracker: %#v", summary)
}
}
func fixedUsageClock(value string) func() time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return func() time.Time { return parsed }
}
-120
View File
@@ -1,120 +0,0 @@
package zeroruntime
import "context"
// CollectedStream is the non-streaming summary of provider events.
type CollectedStream struct {
Text string
ToolCalls []ToolCall
Usage Usage
Error string
}
// CollectOptions provides callbacks for consumers that need live stream updates.
type CollectOptions struct {
OnText func(string)
OnUsage func(Usage)
}
// SeedMessages creates the initial system and user turns for a request.
func SeedMessages(systemPrompt string, userPrompt string) []Message {
return []Message{
{Role: MessageRoleSystem, Content: systemPrompt},
{Role: MessageRoleUser, Content: userPrompt},
}
}
// CollectStream drains provider events into text, tool calls, usage, and error state.
func CollectStream(ctx context.Context, events <-chan StreamEvent) CollectedStream {
return CollectStreamWithOptions(ctx, events, CollectOptions{})
}
// CollectStreamWithOptions drains provider events and emits optional live callbacks.
func CollectStreamWithOptions(ctx context.Context, events <-chan StreamEvent, options CollectOptions) CollectedStream {
collected := CollectedStream{}
pendingToolCalls := make(map[string]*ToolCall)
toolCallOrder := []string{}
for {
select {
case <-ctx.Done():
collected.Error = ctx.Err().Error()
appendOpenToolCalls(&collected, toolCallOrder, pendingToolCalls)
return collected
case event, ok := <-events:
if !ok {
appendOpenToolCalls(&collected, toolCallOrder, pendingToolCalls)
return collected
}
switch event.Type {
case StreamEventText:
collected.Text += event.Content
if options.OnText != nil {
options.OnText(event.Content)
}
case StreamEventToolCallStart:
toolCall := ensurePendingToolCall(event.ToolCallID, pendingToolCalls, &toolCallOrder)
toolCall.Name = event.ToolName
case StreamEventToolCallDelta:
toolCall := ensurePendingToolCall(event.ToolCallID, pendingToolCalls, &toolCallOrder)
toolCall.Arguments += event.ArgumentsFragment
case StreamEventToolCallEnd:
if toolCall, ok := pendingToolCalls[event.ToolCallID]; ok {
collected.ToolCalls = append(collected.ToolCalls, *toolCall)
delete(pendingToolCalls, event.ToolCallID)
}
case StreamEventUsage:
inputTokens := event.Usage.EffectiveInputTokens()
outputTokens := event.Usage.EffectiveOutputTokens()
collected.Usage.InputTokens += inputTokens
collected.Usage.OutputTokens += outputTokens
collected.Usage.PromptTokens += inputTokens
collected.Usage.CompletionTokens += outputTokens
collected.Usage.CachedInputTokens += event.Usage.CachedInputTokens
collected.Usage.ReasoningTokens += event.Usage.ReasoningTokens
if options.OnUsage != nil {
options.OnUsage(event.Usage)
}
case StreamEventError:
collected.Error = event.Error
appendOpenToolCalls(&collected, toolCallOrder, pendingToolCalls)
return collected
case StreamEventDone:
appendOpenToolCalls(&collected, toolCallOrder, pendingToolCalls)
return collected
}
}
}
}
func ensurePendingToolCall(
toolCallID string,
pendingToolCalls map[string]*ToolCall,
toolCallOrder *[]string,
) *ToolCall {
toolCall, ok := pendingToolCalls[toolCallID]
if ok {
return toolCall
}
toolCall = &ToolCall{ID: toolCallID}
pendingToolCalls[toolCallID] = toolCall
*toolCallOrder = append(*toolCallOrder, toolCallID)
return toolCall
}
func appendOpenToolCalls(
collected *CollectedStream,
toolCallOrder []string,
pendingToolCalls map[string]*ToolCall,
) {
for _, id := range toolCallOrder {
toolCall, ok := pendingToolCalls[id]
if !ok {
continue
}
collected.ToolCalls = append(collected.ToolCalls, *toolCall)
delete(pendingToolCalls, id)
}
}
-390
View File
@@ -1,390 +0,0 @@
package zeroruntime
import (
"context"
"strings"
"testing"
)
type mockProvider struct {
events []StreamEvent
}
func (provider mockProvider) StreamCompletion(
ctx context.Context,
request CompletionRequest,
) (<-chan StreamEvent, error) {
events := make(chan StreamEvent)
go func() {
defer close(events)
for _, event := range provider.events {
select {
case <-ctx.Done():
return
case events <- event:
}
}
}()
return events, nil
}
func TestSeedMessagesProducesSystemAndUserTurns(t *testing.T) {
messages := SeedMessages("you are a helper", "inspect this repo")
if len(messages) != 2 {
t.Fatalf("expected 2 messages, got %d", len(messages))
}
if messages[0].Role != MessageRoleSystem || messages[0].Content != "you are a helper" {
t.Fatalf("unexpected system message: %#v", messages[0])
}
if messages[1].Role != MessageRoleUser || messages[1].Content != "inspect this repo" {
t.Fatalf("unexpected user message: %#v", messages[1])
}
}
func TestStreamEventNamesMatchProviderContract(t *testing.T) {
cases := map[StreamEventType]string{
StreamEventText: "text",
StreamEventToolCallStart: "tool-call-start",
StreamEventToolCallDelta: "tool-call-delta",
StreamEventToolCallEnd: "tool-call-end",
StreamEventUsage: "usage",
StreamEventDone: "done",
StreamEventError: "error",
}
for eventType, want := range cases {
if string(eventType) != want {
t.Fatalf("event type %s = %q, want %q", eventType, string(eventType), want)
}
}
}
func TestAgentEventNamesMatchPRDContract(t *testing.T) {
cases := map[AgentEventType]string{
AgentEventText: "text",
AgentEventToolCall: "tool_call",
AgentEventToolResult: "tool_result",
AgentEventThinking: "thinking",
AgentEventUsage: "usage",
AgentEventPlanUpdate: "plan_update",
AgentEventError: "error",
AgentEventTurnEnd: "turn_end",
}
for eventType, want := range cases {
if string(eventType) != want {
t.Fatalf("event type %s = %q, want %q", eventType, string(eventType), want)
}
}
}
func TestNormalizeUsageMapsProviderAliasesAndReasoningTokens(t *testing.T) {
usage, err := NormalizeUsage(TokenUsage{
PromptTokens: 100,
CachedInputTokens: 25,
CompletionTokens: 40,
ReasoningTokens: 10,
})
if err != nil {
t.Fatalf("NormalizeUsage returned error: %v", err)
}
if usage.EffectiveInputTokens() != 100 {
t.Fatalf("input tokens = %d, want 100", usage.EffectiveInputTokens())
}
if usage.EffectiveOutputTokens() != 40 {
t.Fatalf("output tokens = %d, want 40", usage.EffectiveOutputTokens())
}
if usage.CachedInputTokens != 25 {
t.Fatalf("cached input tokens = %d, want 25", usage.CachedInputTokens)
}
if usage.ReasoningTokens != 10 {
t.Fatalf("reasoning tokens = %d, want 10", usage.ReasoningTokens)
}
if usage.TotalTokens() != 150 {
t.Fatalf("total tokens = %d, want 150", usage.TotalTokens())
}
if usage.BillableOutputTokens() != 50 {
t.Fatalf("billable output tokens = %d, want 50", usage.BillableOutputTokens())
}
}
func TestNormalizeUsageClampsCachedInputTokens(t *testing.T) {
usage, err := NormalizeUsage(TokenUsage{
InputTokens: 5,
CachedInputTokens: 12,
OutputTokens: 3,
})
if err != nil {
t.Fatalf("NormalizeUsage returned error: %v", err)
}
if usage.CachedInputTokens != 5 {
t.Fatalf("cached input tokens = %d, want 5", usage.CachedInputTokens)
}
}
func TestNormalizeUsageRejectsNegativeTokenCounts(t *testing.T) {
_, err := NormalizeUsage(TokenUsage{OutputTokens: -1})
if err == nil {
t.Fatal("expected negative output token validation error")
}
if !strings.Contains(err.Error(), "output tokens") {
t.Fatalf("error = %q, want output tokens", err.Error())
}
_, err = NormalizeUsage(TokenUsage{InputTokens: 1, PromptTokens: -1})
if err == nil {
t.Fatal("expected negative prompt token alias validation error")
}
if !strings.Contains(err.Error(), "input tokens alias") {
t.Fatalf("error = %q, want input tokens alias", err.Error())
}
}
func TestCollectStreamAccumulatesTextToolCallsAndUsage(t *testing.T) {
events := make(chan StreamEvent)
go func() {
defer close(events)
events <- StreamEvent{Type: StreamEventText, Content: "Hello "}
events <- StreamEvent{Type: StreamEventText, Content: "world"}
events <- StreamEvent{Type: StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "read_file"}
events <- StreamEvent{Type: StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{"pa`}
events <- StreamEvent{Type: StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `th":"README.md"}`}
events <- StreamEvent{Type: StreamEventToolCallEnd, ToolCallID: "call_1"}
events <- StreamEvent{Type: StreamEventUsage, Usage: Usage{PromptTokens: 12, CompletionTokens: 8, CachedInputTokens: 3}}
events <- StreamEvent{Type: StreamEventDone}
}()
collected := CollectStream(context.Background(), events)
if collected.Text != "Hello world" {
t.Fatalf("text = %q, want %q", collected.Text, "Hello world")
}
if len(collected.ToolCalls) != 1 {
t.Fatalf("expected one tool call, got %d", len(collected.ToolCalls))
}
toolCall := collected.ToolCalls[0]
if toolCall.ID != "call_1" || toolCall.Name != "read_file" || toolCall.Arguments != `{"path":"README.md"}` {
t.Fatalf("unexpected tool call: %#v", toolCall)
}
if collected.Usage.PromptTokens != 12 || collected.Usage.CompletionTokens != 8 || collected.Usage.CachedInputTokens != 3 {
t.Fatalf("unexpected usage: %#v", collected.Usage)
}
if collected.Usage.TotalTokens() != 20 {
t.Fatalf("total tokens = %d, want 20", collected.Usage.TotalTokens())
}
}
func TestCollectStreamAccumulatesNormalizedUsageAliases(t *testing.T) {
usage, err := NormalizeUsage(TokenUsage{InputTokens: 12, OutputTokens: 5, ReasoningTokens: 3})
if err != nil {
t.Fatalf("NormalizeUsage returned error: %v", err)
}
events := make(chan StreamEvent)
go func() {
defer close(events)
events <- StreamEvent{Type: StreamEventUsage, Usage: usage}
events <- StreamEvent{Type: StreamEventDone}
}()
collected := CollectStream(context.Background(), events)
if collected.Usage.EffectiveInputTokens() != 12 {
t.Fatalf("input tokens = %d, want 12", collected.Usage.EffectiveInputTokens())
}
if collected.Usage.EffectiveOutputTokens() != 5 {
t.Fatalf("output tokens = %d, want 5", collected.Usage.EffectiveOutputTokens())
}
if collected.Usage.ReasoningTokens != 3 {
t.Fatalf("reasoning tokens = %d, want 3", collected.Usage.ReasoningTokens)
}
if collected.Usage.TotalTokens() != 20 {
t.Fatalf("total tokens = %d, want 20", collected.Usage.TotalTokens())
}
}
func TestCollectStreamAccumulatesMixedUsageShapes(t *testing.T) {
normalizedUsage, err := NormalizeUsage(TokenUsage{InputTokens: 6, OutputTokens: 3, ReasoningTokens: 2})
if err != nil {
t.Fatalf("NormalizeUsage returned error: %v", err)
}
events := make(chan StreamEvent)
go func() {
defer close(events)
events <- StreamEvent{Type: StreamEventUsage, Usage: Usage{PromptTokens: 10, CompletionTokens: 4, CachedInputTokens: 3}}
events <- StreamEvent{Type: StreamEventUsage, Usage: normalizedUsage}
events <- StreamEvent{Type: StreamEventDone}
}()
collected := CollectStream(context.Background(), events)
if collected.Usage.EffectiveInputTokens() != 16 {
t.Fatalf("input tokens = %d, want 16", collected.Usage.EffectiveInputTokens())
}
if collected.Usage.EffectiveOutputTokens() != 7 {
t.Fatalf("output tokens = %d, want 7", collected.Usage.EffectiveOutputTokens())
}
if collected.Usage.CachedInputTokens != 3 {
t.Fatalf("cached input tokens = %d, want 3", collected.Usage.CachedInputTokens)
}
if collected.Usage.ReasoningTokens != 2 {
t.Fatalf("reasoning tokens = %d, want 2", collected.Usage.ReasoningTokens)
}
if collected.Usage.TotalTokens() != 25 {
t.Fatalf("total tokens = %d, want 25", collected.Usage.TotalTokens())
}
}
func TestCollectStreamWithOptionsEmitsTextAndUsageCallbacks(t *testing.T) {
events := make(chan StreamEvent)
go func() {
defer close(events)
events <- StreamEvent{Type: StreamEventText, Content: "Hello "}
events <- StreamEvent{Type: StreamEventUsage, Usage: Usage{PromptTokens: 12, CompletionTokens: 5, CachedInputTokens: 2}}
events <- StreamEvent{Type: StreamEventText, Content: "zero"}
events <- StreamEvent{Type: StreamEventDone}
}()
var textDeltas []string
var usageEvents []Usage
collected := CollectStreamWithOptions(context.Background(), events, CollectOptions{
OnText: func(delta string) { textDeltas = append(textDeltas, delta) },
OnUsage: func(usage Usage) { usageEvents = append(usageEvents, usage) },
})
if collected.Text != "Hello zero" {
t.Fatalf("text = %q, want Hello zero", collected.Text)
}
if len(textDeltas) != 2 || textDeltas[0] != "Hello " || textDeltas[1] != "zero" {
t.Fatalf("unexpected text callbacks: %#v", textDeltas)
}
if len(usageEvents) != 1 {
t.Fatalf("expected one usage callback, got %#v", usageEvents)
}
if usageEvents[0].PromptTokens != 12 || usageEvents[0].CompletionTokens != 5 || usageEvents[0].CachedInputTokens != 2 {
t.Fatalf("unexpected usage callback: %#v", usageEvents[0])
}
}
func TestCollectStreamKeepsArgumentDeltasBeforeToolCallStart(t *testing.T) {
events := make(chan StreamEvent)
go func() {
defer close(events)
events <- StreamEvent{Type: StreamEventToolCallDelta, ToolCallID: "call_buffered", ArgumentsFragment: `{"path":`}
events <- StreamEvent{Type: StreamEventToolCallStart, ToolCallID: "call_buffered", ToolName: "read_file"}
events <- StreamEvent{Type: StreamEventToolCallDelta, ToolCallID: "call_buffered", ArgumentsFragment: `"README.md"}`}
events <- StreamEvent{Type: StreamEventToolCallEnd, ToolCallID: "call_buffered"}
events <- StreamEvent{Type: StreamEventDone}
}()
collected := CollectStream(context.Background(), events)
if len(collected.ToolCalls) != 1 {
t.Fatalf("expected one buffered tool call, got %d", len(collected.ToolCalls))
}
toolCall := collected.ToolCalls[0]
if toolCall.ID != "call_buffered" || toolCall.Name != "read_file" || toolCall.Arguments != `{"path":"README.md"}` {
t.Fatalf("unexpected buffered tool call: %#v", toolCall)
}
}
func TestCollectStreamFlushesOpenToolCallsWhenChannelCloses(t *testing.T) {
events := make(chan StreamEvent)
go func() {
defer close(events)
events <- StreamEvent{Type: StreamEventToolCallStart, ToolCallID: "call_closed", ToolName: "grep"}
events <- StreamEvent{Type: StreamEventToolCallDelta, ToolCallID: "call_closed", ArgumentsFragment: `{"query":"`}
events <- StreamEvent{Type: StreamEventToolCallDelta, ToolCallID: "call_closed", ArgumentsFragment: `zero"}`}
}()
collected := CollectStream(context.Background(), events)
if len(collected.ToolCalls) != 1 {
t.Fatalf("expected one flushed tool call, got %d", len(collected.ToolCalls))
}
toolCall := collected.ToolCalls[0]
if toolCall.ID != "call_closed" || toolCall.Name != "grep" || toolCall.Arguments != `{"query":"zero"}` {
t.Fatalf("unexpected flushed tool call: %#v", toolCall)
}
}
func TestCollectStreamFlushesOpenToolCallsWhenContextCancels(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
events := make(chan StreamEvent)
go func() {
events <- StreamEvent{Type: StreamEventToolCallStart, ToolCallID: "call_cancelled", ToolName: "read_file"}
events <- StreamEvent{Type: StreamEventToolCallDelta, ToolCallID: "call_cancelled", ArgumentsFragment: `{"path":"README`}
cancel()
}()
collected := CollectStream(ctx, events)
if len(collected.ToolCalls) != 1 {
t.Fatalf("expected one flushed tool call, got %d", len(collected.ToolCalls))
}
toolCall := collected.ToolCalls[0]
if toolCall.ID != "call_cancelled" || toolCall.Name != "read_file" || toolCall.Arguments != `{"path":"README` {
t.Fatalf("unexpected flushed tool call after cancel: %#v", toolCall)
}
if collected.Error == "" {
t.Fatal("expected context cancellation to surface as collected error")
}
if collected.Error != context.Canceled.Error() {
t.Fatalf("error = %q, want %q", collected.Error, context.Canceled.Error())
}
}
func TestCollectStreamSurfacesStreamErrors(t *testing.T) {
events := make(chan StreamEvent)
go func() {
defer close(events)
events <- StreamEvent{Type: StreamEventToolCallStart, ToolCallID: "call_error", ToolName: "bash"}
events <- StreamEvent{Type: StreamEventToolCallDelta, ToolCallID: "call_error", ArgumentsFragment: `{"command":"go test`}
events <- StreamEvent{Type: StreamEventError, Error: "provider stream failed"}
}()
collected := CollectStream(context.Background(), events)
if collected.Error != "provider stream failed" {
t.Fatalf("error = %q, want provider stream failed", collected.Error)
}
if len(collected.ToolCalls) != 1 {
t.Fatalf("expected one flushed tool call, got %d", len(collected.ToolCalls))
}
toolCall := collected.ToolCalls[0]
if toolCall.ID != "call_error" || toolCall.Name != "bash" || toolCall.Arguments != `{"command":"go test` {
t.Fatalf("unexpected flushed tool call after error: %#v", toolCall)
}
}
func TestProviderContractCanBeImplementedByMock(t *testing.T) {
var provider Provider = mockProvider{
events: []StreamEvent{
{Type: StreamEventText, Content: "ok"},
{Type: StreamEventDone},
},
}
stream, err := provider.StreamCompletion(context.Background(), CompletionRequest{
Messages: SeedMessages("system", "user"),
Tools: []ToolDefinition{
{
Name: "read_file",
Description: "Read a file",
Parameters: map[string]any{"type": "object"},
},
},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
collected := CollectStream(context.Background(), stream)
if collected.Text != "ok" {
t.Fatalf("text = %q, want ok", collected.Text)
}
}
Loaded 100 of 305 files, more files were not shown because too many files have changed in this diff. Show more