@os-eco/overstory-cli 0.9.4 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +50 -19
  2. package/agents/builder.md +19 -9
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +204 -87
  5. package/agents/merger.md +25 -14
  6. package/agents/reviewer.md +22 -16
  7. package/agents/scout.md +17 -12
  8. package/package.json +6 -3
  9. package/src/agents/capabilities.test.ts +85 -0
  10. package/src/agents/capabilities.ts +125 -0
  11. package/src/agents/headless-mail-injector.test.ts +448 -0
  12. package/src/agents/headless-mail-injector.ts +219 -0
  13. package/src/agents/headless-prompt.test.ts +102 -0
  14. package/src/agents/headless-prompt.ts +68 -0
  15. package/src/agents/hooks-deployer.test.ts +514 -14
  16. package/src/agents/hooks-deployer.ts +141 -0
  17. package/src/agents/mail-poll-detect.test.ts +153 -0
  18. package/src/agents/mail-poll-detect.ts +73 -0
  19. package/src/agents/overlay.test.ts +60 -4
  20. package/src/agents/overlay.ts +63 -8
  21. package/src/agents/scope-detect.test.ts +190 -0
  22. package/src/agents/scope-detect.ts +146 -0
  23. package/src/agents/turn-lock.test.ts +181 -0
  24. package/src/agents/turn-lock.ts +235 -0
  25. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  26. package/src/agents/turn-runner-dispatch.ts +105 -0
  27. package/src/agents/turn-runner.test.ts +2312 -0
  28. package/src/agents/turn-runner.ts +1383 -0
  29. package/src/commands/agents.ts +9 -0
  30. package/src/commands/clean.ts +54 -0
  31. package/src/commands/coordinator.test.ts +254 -0
  32. package/src/commands/coordinator.ts +273 -8
  33. package/src/commands/dashboard.test.ts +188 -0
  34. package/src/commands/dashboard.ts +14 -4
  35. package/src/commands/doctor.ts +3 -1
  36. package/src/commands/group.test.ts +94 -0
  37. package/src/commands/group.ts +49 -20
  38. package/src/commands/init.test.ts +8 -0
  39. package/src/commands/init.ts +8 -1
  40. package/src/commands/log.test.ts +187 -11
  41. package/src/commands/log.ts +171 -71
  42. package/src/commands/mail.test.ts +162 -0
  43. package/src/commands/mail.ts +64 -9
  44. package/src/commands/merge.test.ts +230 -1
  45. package/src/commands/merge.ts +68 -12
  46. package/src/commands/nudge.test.ts +351 -4
  47. package/src/commands/nudge.ts +356 -34
  48. package/src/commands/run.test.ts +43 -7
  49. package/src/commands/serve/build.test.ts +202 -0
  50. package/src/commands/serve/build.ts +206 -0
  51. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  52. package/src/commands/serve/coordinator-actions.ts +408 -0
  53. package/src/commands/serve/dev.test.ts +168 -0
  54. package/src/commands/serve/dev.ts +117 -0
  55. package/src/commands/serve/mail-actions.test.ts +312 -0
  56. package/src/commands/serve/mail-actions.ts +167 -0
  57. package/src/commands/serve/rest.test.ts +1323 -0
  58. package/src/commands/serve/rest.ts +708 -0
  59. package/src/commands/serve/static.ts +51 -0
  60. package/src/commands/serve/ws.test.ts +361 -0
  61. package/src/commands/serve/ws.ts +332 -0
  62. package/src/commands/serve.test.ts +459 -0
  63. package/src/commands/serve.ts +565 -0
  64. package/src/commands/sling.test.ts +177 -1
  65. package/src/commands/sling.ts +243 -71
  66. package/src/commands/status.test.ts +9 -0
  67. package/src/commands/status.ts +12 -4
  68. package/src/commands/stop.test.ts +255 -1
  69. package/src/commands/stop.ts +107 -8
  70. package/src/commands/watch.test.ts +43 -0
  71. package/src/commands/watch.ts +153 -28
  72. package/src/config.ts +23 -0
  73. package/src/doctor/consistency.test.ts +106 -0
  74. package/src/doctor/consistency.ts +48 -1
  75. package/src/doctor/serve.test.ts +95 -0
  76. package/src/doctor/serve.ts +86 -0
  77. package/src/doctor/types.ts +2 -1
  78. package/src/doctor/watchdog.ts +57 -1
  79. package/src/events/tailer.test.ts +234 -1
  80. package/src/events/tailer.ts +90 -0
  81. package/src/index.ts +57 -6
  82. package/src/insights/quality-gates.test.ts +141 -0
  83. package/src/insights/quality-gates.ts +156 -0
  84. package/src/json.ts +29 -0
  85. package/src/logging/theme.ts +4 -0
  86. package/src/mail/client.ts +15 -2
  87. package/src/mail/store.test.ts +82 -0
  88. package/src/mail/store.ts +41 -4
  89. package/src/merge/lock.test.ts +149 -0
  90. package/src/merge/lock.ts +140 -0
  91. package/src/merge/predict.test.ts +387 -0
  92. package/src/merge/predict.ts +249 -0
  93. package/src/merge/resolver.ts +1 -1
  94. package/src/mulch/client.ts +3 -3
  95. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  96. package/src/runtimes/claude.test.ts +791 -1
  97. package/src/runtimes/claude.ts +323 -1
  98. package/src/runtimes/connections.test.ts +141 -1
  99. package/src/runtimes/connections.ts +73 -4
  100. package/src/runtimes/headless-connection.test.ts +264 -0
  101. package/src/runtimes/headless-connection.ts +158 -0
  102. package/src/runtimes/types.ts +10 -0
  103. package/src/schema-consistency.test.ts +1 -0
  104. package/src/sessions/store.test.ts +657 -29
  105. package/src/sessions/store.ts +286 -23
  106. package/src/test-setup.test.ts +31 -0
  107. package/src/test-setup.ts +28 -0
  108. package/src/types.ts +107 -2
  109. package/src/utils/pid.test.ts +85 -1
  110. package/src/utils/pid.ts +86 -1
  111. package/src/utils/process-scan.test.ts +53 -0
  112. package/src/utils/process-scan.ts +76 -0
  113. package/src/watchdog/daemon.test.ts +1607 -376
  114. package/src/watchdog/daemon.ts +462 -88
  115. package/src/watchdog/health.test.ts +282 -0
  116. package/src/watchdog/health.ts +126 -27
  117. package/src/worktree/manager.test.ts +218 -1
  118. package/src/worktree/manager.ts +55 -0
  119. package/src/worktree/process.test.ts +71 -0
  120. package/src/worktree/process.ts +25 -5
  121. package/src/worktree/tmux.test.ts +28 -0
  122. package/src/worktree/tmux.ts +27 -3
  123. package/templates/CLAUDE.md.tmpl +19 -8
  124. package/templates/overlay.md.tmpl +5 -2
@@ -2,10 +2,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdtemp } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { createEventStore } from "../events/store.ts";
5
6
  import { cleanupTempDir } from "../test-helpers.ts";
6
7
  import type { ResolvedModel } from "../types.ts";
7
8
  import { ClaudeRuntime } from "./claude.ts";
8
- import type { SpawnOpts } from "./types.ts";
9
+ import type { AgentEvent, DirectSpawnOpts, SpawnOpts } from "./types.ts";
9
10
 
10
11
  describe("ClaudeRuntime", () => {
11
12
  const runtime = new ClaudeRuntime();
@@ -420,6 +421,81 @@ describe("ClaudeRuntime", () => {
420
421
  // Scout is non-implementation, builder is implementation
421
422
  expect(scoutSettings).not.toBe(builderSettings);
422
423
  });
424
+
425
+ test("writes PreToolUse-only settings.local.json when isHeadless is true", async () => {
426
+ // overstory-e24b: headless Claude Code DOES dispatch settings.local.json hooks,
427
+ // so the security guards (PreToolUse) must be deployed even in headless mode.
428
+ // Non-PreToolUse events have headless equivalents (initial stdin prompt, mail
429
+ // injection loop, stream-json parser) and are stripped to avoid duplicate work.
430
+ const worktreePath = join(tempDir, "headless-wt");
431
+
432
+ await runtime.deployConfig(
433
+ worktreePath,
434
+ { content: "# Headless Overlay" },
435
+ {
436
+ agentName: "headless-builder",
437
+ capability: "builder",
438
+ worktreePath,
439
+ isHeadless: true,
440
+ },
441
+ );
442
+
443
+ // Overlay still written
444
+ const overlayPath = join(worktreePath, ".claude", "CLAUDE.md");
445
+ expect(await Bun.file(overlayPath).exists()).toBe(true);
446
+
447
+ // Hooks file IS created in headless mode (reversal of overstory-1c32 design Q6)
448
+ const settingsPath = join(worktreePath, ".claude", "settings.local.json");
449
+ expect(await Bun.file(settingsPath).exists()).toBe(true);
450
+
451
+ const parsed = JSON.parse(await Bun.file(settingsPath).text()) as {
452
+ hooks: Record<string, unknown[]>;
453
+ };
454
+
455
+ // Only PreToolUse entries — SessionStart/UserPromptSubmit/PostToolUse/Stop/PreCompact stripped
456
+ expect(Object.keys(parsed.hooks)).toEqual(["PreToolUse"]);
457
+ expect(parsed.hooks.PreToolUse?.length ?? 0).toBeGreaterThan(0);
458
+
459
+ // Sanity: the deployed PreToolUse guards include the destructive-command blocks
460
+ // that were the operational concern in overstory-e24b.
461
+ const serialized = JSON.stringify(parsed.hooks.PreToolUse);
462
+ expect(serialized).toContain("git push is blocked");
463
+ expect(serialized).toContain("git reset --hard");
464
+ expect(serialized).toContain("Path boundary violation");
465
+ });
466
+
467
+ test("still writes settings.local.json when isHeadless is false", async () => {
468
+ const worktreePath = join(tempDir, "tmux-wt");
469
+
470
+ await runtime.deployConfig(
471
+ worktreePath,
472
+ { content: "# Tmux Overlay" },
473
+ {
474
+ agentName: "tmux-builder",
475
+ capability: "builder",
476
+ worktreePath,
477
+ isHeadless: false,
478
+ },
479
+ );
480
+
481
+ const settingsPath = join(worktreePath, ".claude", "settings.local.json");
482
+ const settingsExists = await Bun.file(settingsPath).exists();
483
+ expect(settingsExists).toBe(true);
484
+ });
485
+
486
+ test("still writes settings.local.json when isHeadless is omitted (backward compat)", async () => {
487
+ const worktreePath = join(tempDir, "default-wt");
488
+
489
+ await runtime.deployConfig(worktreePath, undefined, {
490
+ agentName: "default-agent",
491
+ capability: "builder",
492
+ worktreePath,
493
+ });
494
+
495
+ const settingsPath = join(worktreePath, ".claude", "settings.local.json");
496
+ const settingsExists = await Bun.file(settingsPath).exists();
497
+ expect(settingsExists).toBe(true);
498
+ });
423
499
  });
424
500
 
425
501
  describe("parseTranscript", () => {
@@ -682,3 +758,717 @@ describe("ClaudeRuntime integration: registry resolves 'claude' as default", ()
682
758
  expect(() => getRuntime("does-not-exist")).toThrow('Unknown runtime: "does-not-exist"');
683
759
  });
684
760
  });
761
+
762
+ // ─── buildDirectSpawn ────────────────────────────────────────────────────────
763
+
764
+ describe("ClaudeRuntime.buildDirectSpawn", () => {
765
+ const runtime = new ClaudeRuntime();
766
+
767
+ test("returns fixed headless argv without model", () => {
768
+ const opts: DirectSpawnOpts = {
769
+ cwd: "/worktree",
770
+ env: {},
771
+ instructionPath: ".claude/CLAUDE.md",
772
+ };
773
+ expect(runtime.buildDirectSpawn(opts)).toEqual([
774
+ "claude",
775
+ "-p",
776
+ "--output-format",
777
+ "stream-json",
778
+ "--input-format",
779
+ "stream-json",
780
+ "--verbose",
781
+ "--strict-mcp-config",
782
+ "--permission-mode",
783
+ "bypassPermissions",
784
+ ]);
785
+ });
786
+
787
+ test("appends --model when model is specified", () => {
788
+ const opts: DirectSpawnOpts = {
789
+ cwd: "/worktree",
790
+ env: {},
791
+ instructionPath: ".claude/CLAUDE.md",
792
+ model: "claude-sonnet-4-6",
793
+ };
794
+ const argv = runtime.buildDirectSpawn(opts);
795
+ expect(argv.at(-2)).toBe("--model");
796
+ expect(argv.at(-1)).toBe("claude-sonnet-4-6");
797
+ expect(argv).toHaveLength(12);
798
+ });
799
+
800
+ test("does not include instructionPath in argv", () => {
801
+ const opts: DirectSpawnOpts = {
802
+ cwd: "/worktree",
803
+ env: {},
804
+ instructionPath: "/secret/path/CLAUDE.md",
805
+ };
806
+ const argv = runtime.buildDirectSpawn(opts);
807
+ expect(argv.join(" ")).not.toContain("secret");
808
+ expect(argv.join(" ")).not.toContain("CLAUDE.md");
809
+ });
810
+
811
+ test("model undefined omits --model flag", () => {
812
+ const opts: DirectSpawnOpts = {
813
+ cwd: "/worktree",
814
+ env: {},
815
+ instructionPath: ".claude/CLAUDE.md",
816
+ model: undefined,
817
+ };
818
+ expect(runtime.buildDirectSpawn(opts)).not.toContain("--model");
819
+ });
820
+
821
+ test("resumeSessionId emits --resume <id> after --model", () => {
822
+ const opts: DirectSpawnOpts = {
823
+ cwd: "/worktree",
824
+ env: {},
825
+ instructionPath: ".claude/CLAUDE.md",
826
+ model: "claude-sonnet-4-6",
827
+ resumeSessionId: "sess-resume-abc",
828
+ };
829
+ const argv = runtime.buildDirectSpawn(opts);
830
+ // --model and its value precede --resume and its value
831
+ const modelIdx = argv.indexOf("--model");
832
+ const resumeIdx = argv.indexOf("--resume");
833
+ expect(modelIdx).toBeGreaterThan(-1);
834
+ expect(resumeIdx).toBeGreaterThan(modelIdx + 1);
835
+ expect(argv[resumeIdx + 1]).toBe("sess-resume-abc");
836
+ // Trailing pair is --resume <id>
837
+ expect(argv.at(-2)).toBe("--resume");
838
+ expect(argv.at(-1)).toBe("sess-resume-abc");
839
+ });
840
+
841
+ test("omits --resume when resumeSessionId is undefined", () => {
842
+ const opts: DirectSpawnOpts = {
843
+ cwd: "/worktree",
844
+ env: {},
845
+ instructionPath: ".claude/CLAUDE.md",
846
+ };
847
+ expect(runtime.buildDirectSpawn(opts)).not.toContain("--resume");
848
+ });
849
+
850
+ test("omits --resume when resumeSessionId is empty string", () => {
851
+ const opts: DirectSpawnOpts = {
852
+ cwd: "/worktree",
853
+ env: {},
854
+ instructionPath: ".claude/CLAUDE.md",
855
+ resumeSessionId: "",
856
+ };
857
+ expect(runtime.buildDirectSpawn(opts)).not.toContain("--resume");
858
+ });
859
+
860
+ test("omits --resume when resumeSessionId is null", () => {
861
+ const opts: DirectSpawnOpts = {
862
+ cwd: "/worktree",
863
+ env: {},
864
+ instructionPath: ".claude/CLAUDE.md",
865
+ resumeSessionId: null,
866
+ };
867
+ expect(runtime.buildDirectSpawn(opts)).not.toContain("--resume");
868
+ });
869
+ });
870
+
871
+ // ─── parseEvents unit tests ──────────────────────────────────────────────────
872
+
873
+ function toStream(s: string): ReadableStream<Uint8Array> {
874
+ return new ReadableStream({
875
+ start(controller) {
876
+ controller.enqueue(new TextEncoder().encode(s));
877
+ controller.close();
878
+ },
879
+ });
880
+ }
881
+
882
+ function toChunkedStream(chunks: string[]): ReadableStream<Uint8Array> {
883
+ const enc = new TextEncoder();
884
+ return new ReadableStream({
885
+ start(controller) {
886
+ for (const c of chunks) controller.enqueue(enc.encode(c));
887
+ controller.close();
888
+ },
889
+ });
890
+ }
891
+
892
+ async function collectEvents(stream: ReadableStream<Uint8Array>): Promise<AgentEvent[]> {
893
+ const rt = new ClaudeRuntime();
894
+ const events: AgentEvent[] = [];
895
+ for await (const ev of rt.parseEvents(stream)) {
896
+ events.push(ev);
897
+ }
898
+ return events;
899
+ }
900
+
901
+ describe("ClaudeRuntime.parseEvents unit", () => {
902
+ test("empty stream yields no events", async () => {
903
+ const events = await collectEvents(toStream(""));
904
+ expect(events).toHaveLength(0);
905
+ });
906
+
907
+ test("system message → status event with sessionId and subtype", async () => {
908
+ const line = JSON.stringify({ type: "system", subtype: "init", session_id: "sess-abc" });
909
+ const events = await collectEvents(toStream(`${line}\n`));
910
+ expect(events).toHaveLength(1);
911
+ const ev = events[0];
912
+ expect(ev?.type).toBe("status");
913
+ expect(ev?.sessionId).toBe("sess-abc");
914
+ expect(ev?.subtype).toBe("init");
915
+ expect(typeof ev?.timestamp).toBe("string");
916
+ });
917
+
918
+ test("assistant text block → assistant_message with text, model, usage", async () => {
919
+ const line = JSON.stringify({
920
+ type: "assistant",
921
+ message: {
922
+ model: "claude-sonnet-4-6",
923
+ content: [{ type: "text", text: "hello world" }],
924
+ usage: { input_tokens: 10, output_tokens: 5 },
925
+ },
926
+ });
927
+ const events = await collectEvents(toStream(`${line}\n`));
928
+ expect(events).toHaveLength(1);
929
+ const ev = events[0];
930
+ expect(ev?.type).toBe("assistant_message");
931
+ expect(ev?.text).toBe("hello world");
932
+ expect(ev?.model).toBe("claude-sonnet-4-6");
933
+ expect((ev?.usage as Record<string, number>)?.input_tokens).toBe(10);
934
+ });
935
+
936
+ test("assistant text block without model/usage omits those fields", async () => {
937
+ const line = JSON.stringify({
938
+ type: "assistant",
939
+ message: { content: [{ type: "text", text: "bare text" }] },
940
+ });
941
+ const events = await collectEvents(toStream(`${line}\n`));
942
+ expect(events).toHaveLength(1);
943
+ const ev = events[0];
944
+ expect(ev).toBeDefined();
945
+ if (!ev) return;
946
+ expect(ev.type).toBe("assistant_message");
947
+ expect(ev.text).toBe("bare text");
948
+ expect(Object.hasOwn(ev, "model")).toBe(false);
949
+ expect(Object.hasOwn(ev, "usage")).toBe(false);
950
+ });
951
+
952
+ test("assistant tool_use block → tool_use event with callId, name, input", async () => {
953
+ const line = JSON.stringify({
954
+ type: "assistant",
955
+ message: {
956
+ content: [
957
+ {
958
+ type: "tool_use",
959
+ id: "call-1",
960
+ name: "Read",
961
+ input: { path: "/tmp/foo.ts" },
962
+ },
963
+ ],
964
+ },
965
+ });
966
+ const events = await collectEvents(toStream(`${line}\n`));
967
+ expect(events).toHaveLength(1);
968
+ const ev = events[0];
969
+ expect(ev?.type).toBe("tool_use");
970
+ expect(ev?.callId).toBe("call-1");
971
+ expect(ev?.name).toBe("Read");
972
+ expect((ev?.input as Record<string, string>)?.path).toBe("/tmp/foo.ts");
973
+ });
974
+
975
+ test("assistant thinking block is skipped", async () => {
976
+ const line = JSON.stringify({
977
+ type: "assistant",
978
+ message: {
979
+ content: [{ type: "thinking", thinking: "let me think" }],
980
+ },
981
+ });
982
+ const events = await collectEvents(toStream(`${line}\n`));
983
+ expect(events).toHaveLength(0);
984
+ });
985
+
986
+ test("user tool_result block → tool_result event with toolUseId and content", async () => {
987
+ const line = JSON.stringify({
988
+ type: "user",
989
+ message: {
990
+ content: [
991
+ {
992
+ type: "tool_result",
993
+ tool_use_id: "call-1",
994
+ content: "file contents here",
995
+ },
996
+ ],
997
+ },
998
+ });
999
+ const events = await collectEvents(toStream(`${line}\n`));
1000
+ expect(events).toHaveLength(1);
1001
+ const ev = events[0];
1002
+ expect(ev?.type).toBe("tool_result");
1003
+ expect(ev?.toolUseId).toBe("call-1");
1004
+ expect(ev?.content).toBe("file contents here");
1005
+ });
1006
+
1007
+ test("result message → result event with all fields", async () => {
1008
+ const line = JSON.stringify({
1009
+ type: "result",
1010
+ session_id: "sess-xyz",
1011
+ result: "task complete",
1012
+ is_error: false,
1013
+ duration_ms: 2500,
1014
+ num_turns: 3,
1015
+ });
1016
+ const events = await collectEvents(toStream(`${line}\n`));
1017
+ expect(events).toHaveLength(1);
1018
+ const ev = events[0];
1019
+ expect(ev?.type).toBe("result");
1020
+ expect(ev?.sessionId).toBe("sess-xyz");
1021
+ expect(ev?.result).toBe("task complete");
1022
+ expect(ev?.isError).toBe(false);
1023
+ expect(ev?.durationMs).toBe(2500);
1024
+ expect(ev?.numTurns).toBe(3);
1025
+ });
1026
+
1027
+ test("unknown message type (log, control_request) is skipped", async () => {
1028
+ const lines = [
1029
+ JSON.stringify({ type: "log", message: "some log line" }),
1030
+ JSON.stringify({ type: "control_request", payload: {} }),
1031
+ ].join("\n");
1032
+ const events = await collectEvents(toStream(`${lines}\n`));
1033
+ expect(events).toHaveLength(0);
1034
+ });
1035
+
1036
+ test("multi-block assistant message [text, tool_use, text] yields 3 events in order", async () => {
1037
+ const line = JSON.stringify({
1038
+ type: "assistant",
1039
+ message: {
1040
+ content: [
1041
+ { type: "text", text: "first" },
1042
+ { type: "tool_use", id: "c1", name: "Bash", input: { cmd: "ls" } },
1043
+ { type: "text", text: "second" },
1044
+ ],
1045
+ },
1046
+ });
1047
+ const events = await collectEvents(toStream(`${line}\n`));
1048
+ expect(events).toHaveLength(3);
1049
+ expect(events[0]?.type).toBe("assistant_message");
1050
+ expect(events[0]?.text).toBe("first");
1051
+ expect(events[1]?.type).toBe("tool_use");
1052
+ expect(events[1]?.name).toBe("Bash");
1053
+ expect(events[2]?.type).toBe("assistant_message");
1054
+ expect(events[2]?.text).toBe("second");
1055
+ });
1056
+
1057
+ test("user message with multiple tool_result blocks yields one event per block", async () => {
1058
+ const line = JSON.stringify({
1059
+ type: "user",
1060
+ message: {
1061
+ content: [
1062
+ { type: "tool_result", tool_use_id: "c1", content: "result 1" },
1063
+ { type: "tool_result", tool_use_id: "c2", content: "result 2" },
1064
+ ],
1065
+ },
1066
+ });
1067
+ const events = await collectEvents(toStream(`${line}\n`));
1068
+ expect(events).toHaveLength(2);
1069
+ expect(events[0]?.toolUseId).toBe("c1");
1070
+ expect(events[1]?.toolUseId).toBe("c2");
1071
+ });
1072
+
1073
+ test("partial lines (chunked reads) are buffered until newline arrives", async () => {
1074
+ const line = JSON.stringify({ type: "system", subtype: "init", session_id: "sess-chunked" });
1075
+ // Split the JSON at an arbitrary byte boundary
1076
+ const mid = Math.floor(line.length / 2);
1077
+ const chunks = [line.slice(0, mid), line.slice(mid), "\n"];
1078
+ const events = await collectEvents(toChunkedStream(chunks));
1079
+ expect(events).toHaveLength(1);
1080
+ expect(events[0]?.type).toBe("status");
1081
+ expect(events[0]?.sessionId).toBe("sess-chunked");
1082
+ });
1083
+
1084
+ test("malformed lines are silently skipped", async () => {
1085
+ const good = JSON.stringify({ type: "system", subtype: "init", session_id: "s1" });
1086
+ const input = `${good}\nnot json at all\n{broken\n`;
1087
+ const events = await collectEvents(toStream(input));
1088
+ expect(events).toHaveLength(1);
1089
+ expect(events[0]?.type).toBe("status");
1090
+ });
1091
+
1092
+ test("trailing data without newline is flushed", async () => {
1093
+ const line = JSON.stringify({ type: "system", subtype: "init", session_id: "s-trailing" });
1094
+ // No trailing newline
1095
+ const events = await collectEvents(toStream(line));
1096
+ expect(events).toHaveLength(1);
1097
+ expect(events[0]?.sessionId).toBe("s-trailing");
1098
+ });
1099
+
1100
+ test("empty lines between events are ignored", async () => {
1101
+ const l1 = JSON.stringify({ type: "system", subtype: "init", session_id: "s1" });
1102
+ const l2 = JSON.stringify({
1103
+ type: "result",
1104
+ session_id: "s1",
1105
+ result: "ok",
1106
+ is_error: false,
1107
+ duration_ms: 1,
1108
+ num_turns: 1,
1109
+ });
1110
+ const input = `${l1}\n\n\n${l2}\n`;
1111
+ const events = await collectEvents(toStream(input));
1112
+ expect(events).toHaveLength(2);
1113
+ });
1114
+
1115
+ test("multiple valid lines in sequence yield events in order", async () => {
1116
+ const l1 = JSON.stringify({ type: "system", subtype: "init", session_id: "s1" });
1117
+ const l2 = JSON.stringify({
1118
+ type: "assistant",
1119
+ message: { content: [{ type: "text", text: "hi" }] },
1120
+ });
1121
+ const l3 = JSON.stringify({
1122
+ type: "result",
1123
+ session_id: "s1",
1124
+ result: "done",
1125
+ is_error: false,
1126
+ duration_ms: 0,
1127
+ num_turns: 1,
1128
+ });
1129
+ const events = await collectEvents(toStream(`${l1}\n${l2}\n${l3}\n`));
1130
+ expect(events[0]?.type).toBe("status");
1131
+ expect(events[1]?.type).toBe("assistant_message");
1132
+ expect(events[2]?.type).toBe("result");
1133
+ });
1134
+ });
1135
+
1136
+ // ─── parseEvents onSessionId hook ────────────────────────────────────────────
1137
+
1138
+ describe("ClaudeRuntime.parseEvents onSessionId hook", () => {
1139
+ test("fires onSessionId once on first system event", async () => {
1140
+ const rt = new ClaudeRuntime();
1141
+ const called: string[] = [];
1142
+ const line = JSON.stringify({ type: "system", subtype: "init", session_id: "sess-abc" });
1143
+ for await (const _ of rt.parseEvents(toStream(`${line}\n`), {
1144
+ onSessionId: (sid) => called.push(sid),
1145
+ })) {
1146
+ // consume
1147
+ }
1148
+ expect(called).toHaveLength(1);
1149
+ expect(called[0]).toBe("sess-abc");
1150
+ });
1151
+
1152
+ test("does not fire when stream ends before any session_id event", async () => {
1153
+ const rt = new ClaudeRuntime();
1154
+ const called: string[] = [];
1155
+ const line = JSON.stringify({
1156
+ type: "assistant",
1157
+ message: { content: [{ type: "text", text: "hello" }] },
1158
+ });
1159
+ for await (const _ of rt.parseEvents(toStream(`${line}\n`), {
1160
+ onSessionId: (sid) => called.push(sid),
1161
+ })) {
1162
+ // consume
1163
+ }
1164
+ expect(called).toHaveLength(0);
1165
+ });
1166
+
1167
+ test("does not fire on subsequent events with same/different session_id", async () => {
1168
+ const rt = new ClaudeRuntime();
1169
+ const called: string[] = [];
1170
+ const l1 = JSON.stringify({ type: "system", subtype: "init", session_id: "sess-abc" });
1171
+ const l2 = JSON.stringify({
1172
+ type: "result",
1173
+ session_id: "sess-abc",
1174
+ result: "ok",
1175
+ is_error: false,
1176
+ duration_ms: 1,
1177
+ num_turns: 1,
1178
+ });
1179
+ for await (const _ of rt.parseEvents(toStream(`${l1}\n${l2}\n`), {
1180
+ onSessionId: (sid) => called.push(sid),
1181
+ })) {
1182
+ // consume
1183
+ }
1184
+ expect(called).toHaveLength(1);
1185
+ expect(called[0]).toBe("sess-abc");
1186
+ });
1187
+
1188
+ test("callback errors do not crash the parser", async () => {
1189
+ const rt = new ClaudeRuntime();
1190
+ const sysLine = JSON.stringify({ type: "system", subtype: "init", session_id: "sess-err" });
1191
+ const textLine = JSON.stringify({
1192
+ type: "assistant",
1193
+ message: { content: [{ type: "text", text: "after error" }] },
1194
+ });
1195
+ const events: AgentEvent[] = [];
1196
+ for await (const ev of rt.parseEvents(toStream(`${sysLine}\n${textLine}\n`), {
1197
+ onSessionId: () => {
1198
+ throw new Error("intentional consumer error");
1199
+ },
1200
+ })) {
1201
+ events.push(ev);
1202
+ }
1203
+ // Both events should still be yielded despite the callback throwing
1204
+ expect(events).toHaveLength(2);
1205
+ expect(events[0]?.type).toBe("status");
1206
+ expect(events[1]?.type).toBe("assistant_message");
1207
+ });
1208
+
1209
+ test("callback runs synchronously before next yield", async () => {
1210
+ const rt = new ClaudeRuntime();
1211
+ const order: string[] = [];
1212
+ const sysLine = JSON.stringify({ type: "system", subtype: "init", session_id: "sess-sync" });
1213
+ const textLine = JSON.stringify({
1214
+ type: "assistant",
1215
+ message: { content: [{ type: "text", text: "second" }] },
1216
+ });
1217
+ for await (const ev of rt.parseEvents(toStream(`${sysLine}\n${textLine}\n`), {
1218
+ onSessionId: (sid) => order.push(`callback:${sid}`),
1219
+ })) {
1220
+ order.push(`event:${ev.type}`);
1221
+ }
1222
+ // callback must appear before the second event (synchronous inline)
1223
+ expect(order[0]).toBe("callback:sess-sync");
1224
+ expect(order[1]).toBe("event:status");
1225
+ expect(order[2]).toBe("event:assistant_message");
1226
+ });
1227
+ });
1228
+
1229
+ // ─── parseEvents batching tests ─────────────────────────────────────────────
1230
+
1231
+ function controllableStream(): {
1232
+ stream: ReadableStream<Uint8Array>;
1233
+ enqueue: (data: string) => void;
1234
+ close: () => void;
1235
+ } {
1236
+ let ctrl!: ReadableStreamDefaultController<Uint8Array>;
1237
+ const enc = new TextEncoder();
1238
+ const stream = new ReadableStream<Uint8Array>({
1239
+ start(c) {
1240
+ ctrl = c;
1241
+ },
1242
+ });
1243
+ return {
1244
+ stream,
1245
+ enqueue: (data: string) => ctrl.enqueue(enc.encode(data)),
1246
+ close: () => ctrl.close(),
1247
+ };
1248
+ }
1249
+
1250
+ async function collectWithOpts(
1251
+ stream: ReadableStream<Uint8Array>,
1252
+ opts: { flushIntervalMs?: number; flushSizeBytes?: number },
1253
+ ): Promise<AgentEvent[]> {
1254
+ const rt = new ClaudeRuntime();
1255
+ const events: AgentEvent[] = [];
1256
+ for await (const ev of rt.parseEvents(stream, opts)) {
1257
+ events.push(ev);
1258
+ }
1259
+ return events;
1260
+ }
1261
+
1262
+ describe("ClaudeRuntime.parseEvents batching", () => {
1263
+ function assistantText(text: string, model?: string, usage?: Record<string, number>): string {
1264
+ const message: Record<string, unknown> = { content: [{ type: "text", text }] };
1265
+ if (model !== undefined) message.model = model;
1266
+ if (usage !== undefined) message.usage = usage;
1267
+ return JSON.stringify({ type: "assistant", message });
1268
+ }
1269
+
1270
+ function assistantMixed(blocks: unknown[]): string {
1271
+ return JSON.stringify({ type: "assistant", message: { content: blocks } });
1272
+ }
1273
+
1274
+ function systemLine(sessionId: string): string {
1275
+ return JSON.stringify({ type: "system", subtype: "init", session_id: sessionId });
1276
+ }
1277
+
1278
+ function resultLine(sessionId: string): string {
1279
+ return JSON.stringify({
1280
+ type: "result",
1281
+ session_id: sessionId,
1282
+ result: "done",
1283
+ is_error: false,
1284
+ duration_ms: 0,
1285
+ num_turns: 1,
1286
+ });
1287
+ }
1288
+
1289
+ test("1: multiple text fragments within window batch into one event", async () => {
1290
+ const fragments = ["hello", " ", "world", "!", " bye"];
1291
+ const lines = `${fragments.map((t) => assistantText(t)).join("\n")}\n`;
1292
+ const events = await collectWithOpts(toStream(lines), { flushIntervalMs: 500 });
1293
+ expect(events).toHaveLength(1);
1294
+ expect(events[0]?.type).toBe("assistant_message");
1295
+ expect(events[0]?.text).toBe("hello world! bye");
1296
+ expect(typeof events[0]?.timestamp).toBe("string");
1297
+ });
1298
+
1299
+ test("2: timer flush: first batch emitted after flushIntervalMs when stream is idle", async () => {
1300
+ const { stream, enqueue, close } = controllableStream();
1301
+ const collectPromise = collectWithOpts(stream, { flushIntervalMs: 50 });
1302
+ enqueue(`${assistantText("first")}\n`);
1303
+ await new Promise<void>((r) => setTimeout(r, 200));
1304
+ enqueue(`${assistantText("second")}\n`);
1305
+ close();
1306
+ const events = await collectPromise;
1307
+ expect(events).toHaveLength(2);
1308
+ expect(events[0]?.text).toBe("first");
1309
+ expect(events[1]?.text).toBe("second");
1310
+ });
1311
+
1312
+ test("3: tool_use mid-stream flushes pending text first", async () => {
1313
+ const line = assistantMixed([
1314
+ { type: "text", text: "before tool" },
1315
+ { type: "tool_use", id: "c1", name: "Read", input: {} },
1316
+ ]);
1317
+ const events = await collectWithOpts(toStream(`${line}\n`), { flushIntervalMs: 500 });
1318
+ expect(events).toHaveLength(2);
1319
+ expect(events[0]?.type).toBe("assistant_message");
1320
+ expect(events[0]?.text).toBe("before tool");
1321
+ expect(events[1]?.type).toBe("tool_use");
1322
+ expect(events[1]?.name).toBe("Read");
1323
+ });
1324
+
1325
+ test("4: multi-block [text, tool_use, text] preserves in-order delivery", async () => {
1326
+ const line = assistantMixed([
1327
+ { type: "text", text: "first" },
1328
+ { type: "tool_use", id: "c1", name: "Bash", input: {} },
1329
+ { type: "text", text: "second" },
1330
+ ]);
1331
+ const events = await collectWithOpts(toStream(`${line}\n`), { flushIntervalMs: 500 });
1332
+ expect(events).toHaveLength(3);
1333
+ expect(events[0]?.type).toBe("assistant_message");
1334
+ expect(events[0]?.text).toBe("first");
1335
+ expect(events[1]?.type).toBe("tool_use");
1336
+ expect(events[1]?.name).toBe("Bash");
1337
+ expect(events[2]?.type).toBe("assistant_message");
1338
+ expect(events[2]?.text).toBe("second");
1339
+ });
1340
+
1341
+ test("5: stream-end flushes pending text when no other flush trigger fires", async () => {
1342
+ const line = assistantText("only text");
1343
+ const events = await collectWithOpts(toStream(`${line}\n`), { flushIntervalMs: 500 });
1344
+ expect(events).toHaveLength(1);
1345
+ expect(events[0]?.type).toBe("assistant_message");
1346
+ expect(events[0]?.text).toBe("only text");
1347
+ });
1348
+
1349
+ test("6: size cap flush: fragments summing beyond cap produce multiple batched events", async () => {
1350
+ const textA = "a".repeat(60);
1351
+ const textB = "b".repeat(60);
1352
+ const lines = `${assistantText(textA)}\n${assistantText(textB)}\n`;
1353
+ const events = await collectWithOpts(toStream(lines), {
1354
+ flushIntervalMs: 500,
1355
+ flushSizeBytes: 100,
1356
+ });
1357
+ expect(events.length).toBeGreaterThanOrEqual(2);
1358
+ const allText = events.map((e) => e.text as string).join("");
1359
+ expect(allText).toBe(textA + textB);
1360
+ });
1361
+
1362
+ test("7: single fragment exceeding size cap is emitted as its own batch", async () => {
1363
+ const bigText = "x".repeat(200); // 200 bytes > cap of 100
1364
+ const line = assistantText(bigText);
1365
+ const events = await collectWithOpts(toStream(`${line}\n`), {
1366
+ flushIntervalMs: 500,
1367
+ flushSizeBytes: 100,
1368
+ });
1369
+ expect(events).toHaveLength(1);
1370
+ expect(events[0]?.text).toBe(bigText);
1371
+ });
1372
+
1373
+ test("8: non-text events between text batches reset the batch", async () => {
1374
+ const lines = `${[
1375
+ assistantText("alpha"),
1376
+ systemLine("s1"),
1377
+ assistantText("beta"),
1378
+ resultLine("s1"),
1379
+ ].join("\n")}\n`;
1380
+ const events = await collectWithOpts(toStream(lines), { flushIntervalMs: 500 });
1381
+ expect(events).toHaveLength(4);
1382
+ expect(events[0]?.type).toBe("assistant_message");
1383
+ expect(events[0]?.text).toBe("alpha");
1384
+ expect(events[1]?.type).toBe("status");
1385
+ expect(events[2]?.type).toBe("assistant_message");
1386
+ expect(events[2]?.text).toBe("beta");
1387
+ expect(events[3]?.type).toBe("result");
1388
+ });
1389
+
1390
+ test("9: batched event model/usage use the latest contributing message (latest wins)", async () => {
1391
+ const msg1 = assistantText("hello ", "model-A", { input_tokens: 10, output_tokens: 5 });
1392
+ const msg2 = assistantText("world", "model-B", { input_tokens: 20, output_tokens: 10 });
1393
+ const lines = `${msg1}\n${msg2}\n`;
1394
+ const events = await collectWithOpts(toStream(lines), { flushIntervalMs: 500 });
1395
+ expect(events).toHaveLength(1);
1396
+ expect(events[0]?.model).toBe("model-B");
1397
+ expect((events[0]?.usage as Record<string, number>)?.input_tokens).toBe(20);
1398
+ });
1399
+
1400
+ test("10: model/usage are omitted on batched event when no contributing message provided them", async () => {
1401
+ const msg = assistantText("no model here");
1402
+ const events = await collectWithOpts(toStream(`${msg}\n`), { flushIntervalMs: 500 });
1403
+ expect(events).toHaveLength(1);
1404
+ expect(Object.hasOwn(events[0] ?? {}, "model")).toBe(false);
1405
+ expect(Object.hasOwn(events[0] ?? {}, "usage")).toBe(false);
1406
+ });
1407
+ });
1408
+
1409
+ // ─── parseEvents + EventStore integration test ───────────────────────────────
1410
+
1411
+ describe("ClaudeRuntime integration: parseEvents + EventStore", () => {
1412
+ let tempDir: string;
1413
+
1414
+ beforeEach(async () => {
1415
+ tempDir = await mkdtemp(join(tmpdir(), "claude-parse-events-int-"));
1416
+ });
1417
+
1418
+ afterEach(async () => {
1419
+ await cleanupTempDir(tempDir);
1420
+ });
1421
+
1422
+ test("fixture events land in EventStore and round-trip correctly", async () => {
1423
+ const fixturePath = join(import.meta.dir, "__fixtures__", "claude-stream-fixture.ts");
1424
+ const proc = Bun.spawn(["bun", fixturePath], { stdout: "pipe" });
1425
+
1426
+ const runtime = new ClaudeRuntime();
1427
+ const collected: AgentEvent[] = [];
1428
+ for await (const ev of runtime.parseEvents(proc.stdout)) {
1429
+ collected.push(ev);
1430
+ }
1431
+ await proc.exited;
1432
+
1433
+ // Fixture emits: system init, assistant text, result → 3 events
1434
+ expect(collected).toHaveLength(3);
1435
+ expect(collected[0]?.type).toBe("status");
1436
+ expect(collected[0]?.sessionId).toBe("sess-123");
1437
+ expect(collected[1]?.type).toBe("assistant_message");
1438
+ expect(collected[1]?.text).toBe("hello");
1439
+ expect(collected[2]?.type).toBe("result");
1440
+ expect(collected[2]?.result).toBe("done");
1441
+
1442
+ // Insert each event into a fresh EventStore
1443
+ const dbPath = join(tempDir, "events.db");
1444
+ const store = createEventStore(dbPath);
1445
+ const agentName = "fixture-agent";
1446
+
1447
+ for (const ev of collected) {
1448
+ store.insert({
1449
+ runId: null,
1450
+ agentName,
1451
+ sessionId: typeof ev.sessionId === "string" ? ev.sessionId : null,
1452
+ eventType: "custom",
1453
+ toolName: typeof ev.name === "string" ? ev.name : null,
1454
+ toolArgs: null,
1455
+ toolDurationMs: null,
1456
+ level: "info",
1457
+ data: JSON.stringify(ev),
1458
+ });
1459
+ }
1460
+
1461
+ // Query and verify count, order, and data round-trip
1462
+ const stored = store.getByAgent(agentName);
1463
+ expect(stored).toHaveLength(3);
1464
+
1465
+ for (let i = 0; i < stored.length; i++) {
1466
+ const row = stored[i];
1467
+ const original = collected[i];
1468
+ if (!row || !original) continue;
1469
+ expect(row.data).not.toBeNull();
1470
+ const parsed = JSON.parse(row.data as string) as AgentEvent;
1471
+ expect(parsed.type).toBe(original.type);
1472
+ }
1473
+ });
1474
+ });