@botcord/daemon 0.2.13 → 0.2.14

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.
@@ -9,12 +9,14 @@ import type {
9
9
  ChannelSendContext,
10
10
  ChannelSendResult,
11
11
  ChannelStreamBlockContext,
12
+ ChannelTypingContext,
12
13
  GatewayConfig,
13
14
  GatewayInboundEnvelope,
14
15
  GatewayInboundMessage,
15
16
  RuntimeAdapter,
16
17
  RuntimeRunOptions,
17
18
  RuntimeRunResult,
19
+ RuntimeStatusEvent,
18
20
  StreamBlock,
19
21
  } from "../types.js";
20
22
  import type { GatewayLogger } from "../log.js";
@@ -26,8 +28,10 @@ function silentLogger(): GatewayLogger {
26
28
  interface FakeChannelOptions {
27
29
  id?: string;
28
30
  withStream?: boolean;
31
+ withTyping?: boolean;
29
32
  sendImpl?: (ctx: ChannelSendContext) => Promise<ChannelSendResult> | ChannelSendResult;
30
33
  streamImpl?: (ctx: ChannelStreamBlockContext) => Promise<void> | void;
34
+ typingImpl?: (ctx: ChannelTypingContext) => Promise<void> | void;
31
35
  }
32
36
 
33
37
  class FakeChannel implements ChannelAdapter {
@@ -35,20 +39,30 @@ class FakeChannel implements ChannelAdapter {
35
39
  readonly type = "fake";
36
40
  readonly sends: ChannelSendContext[] = [];
37
41
  readonly streams: ChannelStreamBlockContext[] = [];
42
+ readonly typings: ChannelTypingContext[] = [];
38
43
  private readonly sendImpl?: FakeChannelOptions["sendImpl"];
39
44
  private readonly streamImpl?: FakeChannelOptions["streamImpl"];
45
+ private readonly typingImpl?: FakeChannelOptions["typingImpl"];
40
46
  streamBlock?: (ctx: ChannelStreamBlockContext) => Promise<void>;
47
+ typing?: (ctx: ChannelTypingContext) => Promise<void>;
41
48
 
42
49
  constructor(opts: FakeChannelOptions = {}) {
43
50
  this.id = opts.id ?? "botcord";
44
51
  this.sendImpl = opts.sendImpl;
45
52
  this.streamImpl = opts.streamImpl;
53
+ this.typingImpl = opts.typingImpl;
46
54
  if (opts.withStream !== false) {
47
55
  this.streamBlock = async (ctx) => {
48
56
  this.streams.push(ctx);
49
57
  if (this.streamImpl) await this.streamImpl(ctx);
50
58
  };
51
59
  }
60
+ if (opts.withTyping !== false) {
61
+ this.typing = async (ctx) => {
62
+ this.typings.push(ctx);
63
+ if (this.typingImpl) await this.typingImpl(ctx);
64
+ };
65
+ }
52
66
  }
53
67
 
54
68
  async start(): Promise<void> {}
@@ -67,6 +81,13 @@ interface FakeRuntimeOptions {
67
81
  throwError?: Error | string;
68
82
  errorText?: string;
69
83
  blocks?: StreamBlock[];
84
+ /** Status events emitted before any blocks. */
85
+ preStatus?: RuntimeStatusEvent[];
86
+ /** Interleaved scripted events (status + blocks) replayed in order. */
87
+ events?: Array<
88
+ | { kind: "block"; block: StreamBlock }
89
+ | { kind: "status"; event: RuntimeStatusEvent }
90
+ >;
70
91
  hang?: boolean;
71
92
  observeRun?: (opts: RuntimeRunOptions) => void;
72
93
  }
@@ -84,9 +105,18 @@ class FakeRuntime implements RuntimeAdapter {
84
105
  async run(options: RuntimeRunOptions): Promise<RuntimeRunResult> {
85
106
  this.calls.push(options);
86
107
  this.opts.observeRun?.(options);
108
+ if (this.opts.preStatus) {
109
+ for (const s of this.opts.preStatus) options.onStatus?.(s);
110
+ }
87
111
  if (this.opts.blocks) {
88
112
  for (const b of this.opts.blocks) options.onBlock?.(b);
89
113
  }
114
+ if (this.opts.events) {
115
+ for (const ev of this.opts.events) {
116
+ if (ev.kind === "status") options.onStatus?.(ev.event);
117
+ else options.onBlock?.(ev.block);
118
+ }
119
+ }
90
120
  if (this.opts.hang) {
91
121
  // Never resolve naturally; wait for abort.
92
122
  await new Promise<void>((resolve, reject) => {
@@ -523,9 +553,11 @@ describe("Dispatcher", () => {
523
553
  });
524
554
 
525
555
  it("streaming: forwards blocks when trace.streamable === true and channel has streamBlock", async () => {
556
+ // Use only assistant_text so we don't trip the thinking-synthesis path
557
+ // (covered separately below); this test stays focused on basic forwarding.
526
558
  const blocks: StreamBlock[] = [
527
559
  { raw: { type: "a" }, kind: "assistant_text", seq: 1 },
528
- { raw: { type: "b" }, kind: "tool_use", seq: 2 },
560
+ { raw: { type: "b" }, kind: "assistant_text", seq: 2 },
529
561
  ];
530
562
  const runtime = new FakeRuntime({ blocks, newSessionId: "sid" });
531
563
  const channel = new FakeChannel();
@@ -540,6 +572,8 @@ describe("Dispatcher", () => {
540
572
  await new Promise((r) => setTimeout(r, 5));
541
573
  expect(channel.streams.length).toBe(2);
542
574
  expect(channel.streams[0].traceId).toBe("trace_abc");
575
+ // Dispatcher re-sequences on the wire so synthesized thinking blocks
576
+ // interleave cleanly. Two assistant_text blocks → wire seq 1, 2.
543
577
  expect(channel.streams.map((s) => (s.block as StreamBlock).seq)).toEqual([1, 2]);
544
578
  });
545
579
 
@@ -569,6 +603,523 @@ describe("Dispatcher", () => {
569
603
  expect(channel.sends[0].message.text).toBe("ok");
570
604
  });
571
605
 
606
+ // ---------------------------------------------------------------------------
607
+ // typing / thinking lifecycle (design: runtime-typing-thinking-status-design.md)
608
+ // ---------------------------------------------------------------------------
609
+
610
+ it("typing: fires channel.typing once before runtime.run when canStream", async () => {
611
+ const observed: Array<{ typings: number; calls: number }> = [];
612
+ const runtime = new FakeRuntime({
613
+ observeRun: () => {
614
+ // Snapshot the typing count at the moment runtime.run is invoked so we
615
+ // can prove typing.started fired BEFORE the runtime started.
616
+ observed.push({ typings: channel.typings.length, calls: 1 });
617
+ },
618
+ reply: "ok",
619
+ newSessionId: "sid",
620
+ });
621
+ const channel = new FakeChannel();
622
+ const { dispatcher } = await scaffold({ channel, runtimeFactory: () => runtime });
623
+
624
+ await dispatcher.handle(
625
+ makeEnvelope({ trace: { id: "trace_t", streamable: true } }),
626
+ );
627
+ await new Promise((r) => setTimeout(r, 5));
628
+
629
+ expect(channel.typings.length).toBe(1);
630
+ expect(channel.typings[0].traceId).toBe("trace_t");
631
+ expect(channel.typings[0].conversationId).toBe("rm_oc_1");
632
+ expect(observed[0]?.typings).toBe(1);
633
+ });
634
+
635
+ it("typing: not fired when streamable is false", async () => {
636
+ const channel = new FakeChannel();
637
+ const { dispatcher } = await scaffold({
638
+ channel,
639
+ runtimeFactory: () => new FakeRuntime({ reply: "ok", newSessionId: "sid" }),
640
+ });
641
+
642
+ await dispatcher.handle(
643
+ makeEnvelope({ trace: { id: "t1", streamable: false } }),
644
+ );
645
+ expect(channel.typings.length).toBe(0);
646
+ });
647
+
648
+ it("typing: not fired when channel has no typing capability", async () => {
649
+ const channel = new FakeChannel({ withTyping: false });
650
+ const { dispatcher } = await scaffold({
651
+ channel,
652
+ runtimeFactory: () => new FakeRuntime({ reply: "ok", newSessionId: "sid" }),
653
+ });
654
+
655
+ await dispatcher.handle(
656
+ makeEnvelope({ trace: { id: "t1", streamable: true } }),
657
+ );
658
+ expect(channel.typings.length).toBe(0);
659
+ });
660
+
661
+ it("typing: failure in channel.typing must not break the turn", async () => {
662
+ const channel = new FakeChannel({
663
+ typingImpl: () => {
664
+ throw new Error("typing exploded");
665
+ },
666
+ });
667
+ const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid" });
668
+ const { dispatcher } = await scaffold({ channel, runtimeFactory: () => runtime });
669
+
670
+ await dispatcher.handle(
671
+ makeEnvelope({ trace: { id: "t1", streamable: true } }),
672
+ );
673
+ expect(channel.sends.length).toBe(1);
674
+ expect(channel.sends[0].message.text).toBe("ok");
675
+ });
676
+
677
+ it("thinking: synthesized before first non-assistant block", async () => {
678
+ const blocks: StreamBlock[] = [
679
+ { raw: { type: "system", subtype: "init" }, kind: "system", seq: 1 },
680
+ { raw: { type: "assistant" }, kind: "assistant_text", seq: 2 },
681
+ ];
682
+ const runtime = new FakeRuntime({ blocks, reply: "ok", newSessionId: "sid" });
683
+ const channel = new FakeChannel();
684
+ const { dispatcher } = await scaffold({ channel, runtimeFactory: () => runtime });
685
+
686
+ await dispatcher.handle(
687
+ makeEnvelope({ trace: { id: "tr", streamable: true } }),
688
+ );
689
+ await new Promise((r) => setTimeout(r, 5));
690
+
691
+ // [synthesized thinking, system, assistant_text]
692
+ expect(channel.streams.length).toBe(3);
693
+ const kinds = channel.streams.map((s) => (s.block as StreamBlock).kind);
694
+ expect(kinds).toEqual(["thinking", "system", "assistant_text"]);
695
+ expect(channel.streams.map((s) => (s.block as StreamBlock).seq)).toEqual([1, 2, 3]);
696
+ const thinkingRaw = channel.streams[0].block as StreamBlock;
697
+ expect((thinkingRaw.raw as { phase: string }).phase).toBe("started");
698
+ expect((thinkingRaw.raw as { source: string }).source).toBe("dispatcher");
699
+ });
700
+
701
+ it("thinking: NOT synthesized when first block is assistant_text", async () => {
702
+ const blocks: StreamBlock[] = [
703
+ { raw: { type: "assistant" }, kind: "assistant_text", seq: 1 },
704
+ ];
705
+ const runtime = new FakeRuntime({ blocks, reply: "ok", newSessionId: "sid" });
706
+ const channel = new FakeChannel();
707
+ const { dispatcher } = await scaffold({ channel, runtimeFactory: () => runtime });
708
+
709
+ await dispatcher.handle(
710
+ makeEnvelope({ trace: { id: "tr", streamable: true } }),
711
+ );
712
+ await new Promise((r) => setTimeout(r, 5));
713
+
714
+ expect(channel.streams.length).toBe(1);
715
+ expect((channel.streams[0].block as StreamBlock).kind).toBe("assistant_text");
716
+ });
717
+
718
+ it("thinking: re-enters thinking on tool_use after assistant_text exits it, then closes on terminal", async () => {
719
+ const blocks: StreamBlock[] = [
720
+ { raw: { type: "assistant" }, kind: "assistant_text", seq: 1 },
721
+ { raw: { type: "tool" }, kind: "tool_use", seq: 2 },
722
+ ];
723
+ const runtime = new FakeRuntime({ blocks, reply: "ok", newSessionId: "sid" });
724
+ const channel = new FakeChannel();
725
+ const { dispatcher } = await scaffold({ channel, runtimeFactory: () => runtime });
726
+
727
+ await dispatcher.handle(
728
+ makeEnvelope({ trace: { id: "tr", streamable: true } }),
729
+ );
730
+ await new Promise((r) => setTimeout(r, 5));
731
+
732
+ // [assistant_text, synthesized thinking.started, tool_use, terminal thinking.stopped]
733
+ expect(channel.streams.length).toBe(4);
734
+ const kinds = channel.streams.map((s) => (s.block as StreamBlock).kind);
735
+ expect(kinds).toEqual(["assistant_text", "thinking", "tool_use", "thinking"]);
736
+ const terminal = channel.streams[3].block as StreamBlock;
737
+ expect((terminal.raw as { phase: string }).phase).toBe("stopped");
738
+ });
739
+
740
+ it("thinking: runtime onStatus(thinking.started) suppresses dispatcher synthesis and forwards label", async () => {
741
+ const runtime = new FakeRuntime({
742
+ events: [
743
+ {
744
+ kind: "status",
745
+ event: { kind: "thinking", phase: "started", label: "Searching web" },
746
+ },
747
+ { kind: "block", block: { raw: { type: "tool" }, kind: "tool_use", seq: 1 } },
748
+ ],
749
+ reply: "ok",
750
+ newSessionId: "sid",
751
+ });
752
+ const channel = new FakeChannel();
753
+ const { dispatcher } = await scaffold({ channel, runtimeFactory: () => runtime });
754
+
755
+ await dispatcher.handle(
756
+ makeEnvelope({ trace: { id: "tr", streamable: true } }),
757
+ );
758
+ await new Promise((r) => setTimeout(r, 5));
759
+
760
+ // [labeled thinking, tool_use, terminal thinking.stopped] — no auto-synthesized bare thinking.
761
+ expect(channel.streams.length).toBe(3);
762
+ const first = channel.streams[0].block as StreamBlock;
763
+ expect(first.kind).toBe("thinking");
764
+ expect((first.raw as { label: string }).label).toBe("Searching web");
765
+ expect((first.raw as { source: string }).source).toBe("runtime");
766
+ expect((channel.streams[1].block as StreamBlock).kind).toBe("tool_use");
767
+ const terminal = channel.streams[2].block as StreamBlock;
768
+ expect(terminal.kind).toBe("thinking");
769
+ expect((terminal.raw as { phase: string }).phase).toBe("stopped");
770
+ });
771
+
772
+ it("thinking: runtime onStatus(typing.started) is idempotent — no second /hub/typing call", async () => {
773
+ const runtime = new FakeRuntime({
774
+ preStatus: [{ kind: "typing", phase: "started" }],
775
+ reply: "ok",
776
+ newSessionId: "sid",
777
+ });
778
+ const channel = new FakeChannel();
779
+ const { dispatcher } = await scaffold({ channel, runtimeFactory: () => runtime });
780
+
781
+ await dispatcher.handle(
782
+ makeEnvelope({ trace: { id: "tr", streamable: true } }),
783
+ );
784
+ await new Promise((r) => setTimeout(r, 5));
785
+
786
+ // Dispatcher pre-fires once before runtime.run; runtime's redundant
787
+ // typing.started status event must not trigger a second hub ping.
788
+ expect(channel.typings.length).toBe(1);
789
+ });
790
+
791
+ it("thinking: timeout path emits terminal thinking.stopped + no extra frames after abort", async () => {
792
+ const runtime = new FakeRuntime({
793
+ blocks: [{ raw: {}, kind: "system", seq: 1 }],
794
+ hang: true,
795
+ });
796
+ const channel = new FakeChannel();
797
+ const { dispatcher } = await scaffold({
798
+ channel,
799
+ runtimeFactory: () => runtime,
800
+ turnTimeoutMs: 30,
801
+ });
802
+
803
+ await dispatcher.handle(
804
+ makeEnvelope({ trace: { id: "tr", streamable: true } }),
805
+ );
806
+
807
+ // Timeout reply is sent in owner-chat.
808
+ expect(channel.sends.length).toBe(1);
809
+ expect(channel.sends[0].message.text).toMatch(/Runtime timeout/);
810
+ // [synth thinking.started, system, terminal thinking.stopped]
811
+ const kinds = channel.streams.map((s) => (s.block as StreamBlock).kind);
812
+ expect(kinds).toEqual(["thinking", "system", "thinking"]);
813
+ const terminal = channel.streams[2].block as StreamBlock;
814
+ expect((terminal.raw as { phase: string }).phase).toBe("stopped");
815
+ // Nothing else should sneak in after the timeout's abort.
816
+ const lastSeq = channel.streams.length;
817
+ await new Promise((r) => setTimeout(r, 30));
818
+ expect(channel.streams.length).toBe(lastSeq);
819
+ });
820
+
821
+ it("thinking: terminal thinking.stopped fires on success when turn ends with thinking active", async () => {
822
+ // Empty reply + only a tool_use block → assistant_text never lands, so
823
+ // the dispatcher's finally is the only place thinking gets收束.
824
+ const blocks: StreamBlock[] = [
825
+ { raw: { type: "tool" }, kind: "tool_use", seq: 1 },
826
+ ];
827
+ const runtime = new FakeRuntime({ blocks, reply: "", newSessionId: "sid" });
828
+ const channel = new FakeChannel();
829
+ const { dispatcher } = await scaffold({ channel, runtimeFactory: () => runtime });
830
+
831
+ await dispatcher.handle(
832
+ makeEnvelope({ trace: { id: "tr", streamable: true } }),
833
+ );
834
+ await new Promise((r) => setTimeout(r, 5));
835
+
836
+ // [synth thinking.started, tool_use, terminal thinking.stopped]
837
+ const kinds = channel.streams.map((s) => (s.block as StreamBlock).kind);
838
+ expect(kinds).toEqual(["thinking", "tool_use", "thinking"]);
839
+ expect((channel.streams[2].block as StreamBlock).raw as { phase: string }).toMatchObject({
840
+ phase: "stopped",
841
+ });
842
+ });
843
+
844
+ it("thinking: post-abort onBlock callbacks are dropped after controller.signal.aborted", async () => {
845
+ // Drive runtime manually so we can fire callbacks AFTER the dispatcher
846
+ // marks the turn aborted (simulating the NDJSON adapter's stdout-flush
847
+ // window between SIGTERM and SIGKILL).
848
+ let captured: RuntimeRunOptions | null = null;
849
+ const runtime: RuntimeAdapter = {
850
+ id: "fake",
851
+ run: async (opts) => {
852
+ captured = opts;
853
+ // Block 1 fires while the turn is still live.
854
+ opts.onBlock?.({ raw: {}, kind: "system", seq: 1 });
855
+ // Wait for caller-side abort. Reject to simulate the runtime exiting.
856
+ return new Promise<RuntimeRunResult>((_resolve, reject) => {
857
+ opts.signal.addEventListener("abort", () => reject(new Error("aborted")), {
858
+ once: true,
859
+ });
860
+ });
861
+ },
862
+ };
863
+ const channel = new FakeChannel();
864
+ const { dispatcher } = await scaffold({
865
+ channel,
866
+ runtimeFactory: () => runtime,
867
+ turnTimeoutMs: 30,
868
+ });
869
+
870
+ await dispatcher.handle(
871
+ makeEnvelope({ trace: { id: "tr", streamable: true } }),
872
+ );
873
+ // Timeout has fired by now → controller.signal.aborted=true.
874
+ const beforeLate = channel.streams.length;
875
+ // Simulate adapter flushing one more block AFTER abort.
876
+ captured!.onBlock?.({ raw: { type: "late" }, kind: "tool_use", seq: 99 });
877
+ captured!.onStatus?.({ kind: "thinking", phase: "updated", label: "late" });
878
+ await new Promise((r) => setTimeout(r, 5));
879
+ // Late callbacks are dropped — wire frame count unchanged.
880
+ expect(channel.streams.length).toBe(beforeLate);
881
+ });
882
+
883
+ it("typing: cancel-previous within debounce window does NOT double-ping /hub/typing", async () => {
884
+ const runtime = new FakeRuntime({ hang: true });
885
+ const channel = new FakeChannel();
886
+ const { dispatcher } = await scaffold({
887
+ channel,
888
+ runtimeFactory: () => runtime,
889
+ turnTimeoutMs: 30,
890
+ });
891
+
892
+ // First turn pings typing.
893
+ await dispatcher.handle(
894
+ makeEnvelope({
895
+ id: "m1",
896
+ conversation: { id: "rm_oc_dbnc", kind: "direct" },
897
+ trace: { id: "tr1", streamable: true },
898
+ }),
899
+ );
900
+ expect(channel.typings.length).toBe(1);
901
+
902
+ // Second turn arrives immediately (cancel-previous superseder); within
903
+ // the 2s debounce, no second hub ping should fire.
904
+ await dispatcher.handle(
905
+ makeEnvelope({
906
+ id: "m2",
907
+ conversation: { id: "rm_oc_dbnc", kind: "direct" },
908
+ trace: { id: "tr2", streamable: true },
909
+ }),
910
+ );
911
+ expect(channel.typings.length).toBe(1);
912
+ });
913
+
914
+ it("typing: synchronous throw from channel.typing is logged but does not break turn", async () => {
915
+ const channel = new FakeChannel();
916
+ // Replace typing with a sync-throwing function (non-async).
917
+ (channel as unknown as { typing: (ctx: ChannelTypingContext) => Promise<void> }).typing =
918
+ ((_ctx: ChannelTypingContext) => {
919
+ throw new Error("sync boom");
920
+ }) as unknown as (ctx: ChannelTypingContext) => Promise<void>;
921
+ const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid" });
922
+ const { dispatcher } = await scaffold({ channel, runtimeFactory: () => runtime });
923
+
924
+ await dispatcher.handle(
925
+ makeEnvelope({ trace: { id: "tr", streamable: true } }),
926
+ );
927
+ expect(channel.sends.length).toBe(1);
928
+ expect(channel.sends[0].message.text).toBe("ok");
929
+ });
930
+
931
+ it("thinking: post-assistant_text system/other blocks do NOT re-flicker thinking (sticky guard)", async () => {
932
+ // Mirrors the codex `turn.completed` / claude `result` shapes — both arrive
933
+ // as system/other AFTER the prose. They must NOT re-enter thinking,
934
+ // otherwise the UI flickers right after the final answer.
935
+ const blocks: StreamBlock[] = [
936
+ { raw: { type: "assistant" }, kind: "assistant_text", seq: 1 },
937
+ { raw: { type: "turn.completed" }, kind: "system", seq: 2 },
938
+ { raw: { type: "result" }, kind: "other", seq: 3 },
939
+ ];
940
+ const runtime = new FakeRuntime({ blocks, reply: "ok", newSessionId: "sid" });
941
+ const channel = new FakeChannel();
942
+ const { dispatcher } = await scaffold({ channel, runtimeFactory: () => runtime });
943
+
944
+ await dispatcher.handle(
945
+ makeEnvelope({ trace: { id: "tr", streamable: true } }),
946
+ );
947
+ await new Promise((r) => setTimeout(r, 5));
948
+
949
+ const kinds = channel.streams.map((s) => (s.block as StreamBlock).kind);
950
+ // [assistant_text, system, other] — no thinking flicker after prose.
951
+ expect(kinds).toEqual(["assistant_text", "system", "other"]);
952
+ });
953
+
954
+ it("thinking: tool_use AFTER assistant_text still re-enters thinking (multi-step reply)", async () => {
955
+ // Sticky only blocks system/other re-entry. A genuine follow-up tool call
956
+ // SHOULD drive "Thinking…" again so the user knows the agent is working.
957
+ const blocks: StreamBlock[] = [
958
+ { raw: { type: "assistant" }, kind: "assistant_text", seq: 1 },
959
+ { raw: { type: "tool" }, kind: "tool_use", seq: 2 },
960
+ ];
961
+ const runtime = new FakeRuntime({ blocks, reply: "ok", newSessionId: "sid" });
962
+ const channel = new FakeChannel();
963
+ const { dispatcher } = await scaffold({ channel, runtimeFactory: () => runtime });
964
+
965
+ await dispatcher.handle(
966
+ makeEnvelope({ trace: { id: "tr", streamable: true } }),
967
+ );
968
+ await new Promise((r) => setTimeout(r, 5));
969
+
970
+ const kinds = channel.streams.map((s) => (s.block as StreamBlock).kind);
971
+ // [assistant_text, synth thinking.started, tool_use, terminal thinking.stopped]
972
+ expect(kinds).toEqual(["assistant_text", "thinking", "tool_use", "thinking"]);
973
+ });
974
+
975
+ it("thinking: runtime onStatus(thinking.stopped) forwards to wire and prevents finally double-emit", async () => {
976
+ // Mirrors hermes/acp-stream's prompt-done path: the runtime explicitly
977
+ // tells us thinking is over BEFORE the dispatcher's finally runs.
978
+ const runtime = new FakeRuntime({
979
+ events: [
980
+ { kind: "block", block: { raw: { type: "tool" }, kind: "tool_use", seq: 1 } },
981
+ { kind: "status", event: { kind: "thinking", phase: "stopped" } },
982
+ ],
983
+ reply: "ok",
984
+ newSessionId: "sid",
985
+ });
986
+ const channel = new FakeChannel();
987
+ const { dispatcher } = await scaffold({ channel, runtimeFactory: () => runtime });
988
+
989
+ await dispatcher.handle(
990
+ makeEnvelope({ trace: { id: "tr", streamable: true } }),
991
+ );
992
+ await new Promise((r) => setTimeout(r, 5));
993
+
994
+ // [synth thinking.started, tool_use, runtime thinking.stopped]
995
+ // The finalizeThinkingIfActive in finally must NOT re-emit a 4th frame.
996
+ const kinds = channel.streams.map((s) => (s.block as StreamBlock).kind);
997
+ expect(kinds).toEqual(["thinking", "tool_use", "thinking"]);
998
+ const stoppedFrames = channel.streams.filter(
999
+ (s) => (s.block as StreamBlock).kind === "thinking" &&
1000
+ ((s.block as StreamBlock).raw as { phase: string }).phase === "stopped",
1001
+ );
1002
+ expect(stoppedFrames.length).toBe(1);
1003
+ });
1004
+
1005
+ it("thinking: cancel-previous superseder skips finalize (prior turn does NOT emit terminal stopped)", async () => {
1006
+ // Streamable cancel-previous race: the SUPERSEDED turn's finalize must
1007
+ // NOT push a `thinking.stopped` frame, because the new turn is about to
1008
+ // start its own typing/thinking lifecycle and an old stopped frame would
1009
+ // race the new started frame on the wire.
1010
+ let priorObserved: RuntimeRunOptions | null = null;
1011
+ const prior = new FakeRuntime({
1012
+ hang: true,
1013
+ observeRun: (opts) => {
1014
+ priorObserved = opts;
1015
+ },
1016
+ });
1017
+ const newer = new FakeRuntime({ reply: "newer", newSessionId: "sid-new" });
1018
+ let callNo = 0;
1019
+ const runtimeFactory: RuntimeFactory = () => (++callNo === 1 ? prior : newer);
1020
+ const channel = new FakeChannel();
1021
+ const { dispatcher } = await scaffold({ channel, runtimeFactory });
1022
+
1023
+ // First turn — fires typing + a system block to prime thinkingActive,
1024
+ // then hangs.
1025
+ const first = dispatcher.handle(
1026
+ makeEnvelope({
1027
+ id: "m_prior",
1028
+ conversation: { id: "rm_oc_cancel", kind: "direct" },
1029
+ trace: { id: "tr_prior", streamable: true },
1030
+ }),
1031
+ );
1032
+ while (!priorObserved) await new Promise((r) => setTimeout(r, 1));
1033
+ priorObserved!.onBlock?.({ raw: { type: "system" }, kind: "system", seq: 1 });
1034
+ await new Promise((r) => setTimeout(r, 5));
1035
+ const beforeSupersede = channel.streams.length;
1036
+
1037
+ // Second turn supersedes prior.
1038
+ await dispatcher.handle(
1039
+ makeEnvelope({
1040
+ id: "m_newer",
1041
+ conversation: { id: "rm_oc_cancel", kind: "direct" },
1042
+ trace: { id: "tr_newer", streamable: true },
1043
+ }),
1044
+ );
1045
+ await first.catch(() => undefined);
1046
+ await new Promise((r) => setTimeout(r, 5));
1047
+
1048
+ // Frames belonging to prior trace before supersede:
1049
+ // [synth thinking.started, system]
1050
+ // After supersede no terminal `thinking.stopped` should be emitted for
1051
+ // tr_prior — `finalizeThinkingIfActive` skips on superseded turns.
1052
+ const priorFrames = channel.streams
1053
+ .slice(0, beforeSupersede)
1054
+ .map((s) => (s.block as StreamBlock).kind);
1055
+ expect(priorFrames).toEqual(["thinking", "system"]);
1056
+ // No frame WITH the prior traceId after the supersede mark either.
1057
+ const postSupersedePriorFrames = channel.streams
1058
+ .slice(beforeSupersede)
1059
+ .filter((s) => s.traceId === "tr_prior");
1060
+ expect(postSupersedePriorFrames.length).toBe(0);
1061
+ });
1062
+
1063
+ it("typing: second ping within debounce window does not double-fire /hub/typing", async () => {
1064
+ // The recentTypingPings map's true-LRU behavior is implemented but not
1065
+ // directly observable from this test surface (would require >1024 cold
1066
+ // rooms to exercise eviction). What we DO verify here is the user-visible
1067
+ // contract: rapid same-room pings within the 2s debounce coalesce to one.
1068
+ const channel = new FakeChannel();
1069
+ const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid" });
1070
+ const { dispatcher } = await scaffold({ channel, runtimeFactory: () => runtime });
1071
+
1072
+ await dispatcher.handle(
1073
+ makeEnvelope({
1074
+ id: "hot1",
1075
+ conversation: { id: "rm_oc_hot", kind: "direct" },
1076
+ trace: { id: "trh1", streamable: true },
1077
+ }),
1078
+ );
1079
+ expect(channel.typings.length).toBe(1);
1080
+
1081
+ await dispatcher.handle(
1082
+ makeEnvelope({
1083
+ id: "hot2",
1084
+ conversation: { id: "rm_oc_hot", kind: "direct" },
1085
+ trace: { id: "trh2", streamable: true },
1086
+ }),
1087
+ );
1088
+ expect(channel.typings.length).toBe(1);
1089
+ });
1090
+
1091
+ it("transcript: synthesized thinking does NOT pollute outbound.blocks", async () => {
1092
+ const blocks: StreamBlock[] = [
1093
+ { raw: { type: "system" }, kind: "system", seq: 1 },
1094
+ { raw: { type: "tool" }, kind: "tool_use", seq: 2 },
1095
+ ];
1096
+ const runtime = new FakeRuntime({ blocks, reply: "ok", newSessionId: "sid" });
1097
+ const channel = new FakeChannel();
1098
+ const records: import("../transcript.js").TranscriptRecord[] = [];
1099
+ const { store, dir } = await makeStore();
1100
+ tempDirs.push(dir);
1101
+ const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
1102
+ const dispatcher = new Dispatcher({
1103
+ config: baseConfig(),
1104
+ channels,
1105
+ runtime: () => runtime,
1106
+ sessionStore: store,
1107
+ log: silentLogger(),
1108
+ transcript: { enabled: true, rootDir: dir, write: (rec) => records.push(rec) },
1109
+ });
1110
+
1111
+ await dispatcher.handle(
1112
+ makeEnvelope({ trace: { id: "tr", streamable: true } }),
1113
+ );
1114
+ await new Promise((r) => setTimeout(r, 5));
1115
+
1116
+ const outbound = records.find((r) => r.kind === "outbound");
1117
+ expect(outbound).toBeDefined();
1118
+ const blockTypes = (outbound as { blocks?: Array<{ type: string }> }).blocks?.map((b) => b.type) ?? [];
1119
+ // Only the runtime-emitted blocks land in the transcript; synth thinking is intentionally skipped.
1120
+ expect(blockTypes).toEqual(["system", "tool_use"]);
1121
+ });
1122
+
572
1123
  it("runtime throws: sends error reply, does not write session", async () => {
573
1124
  const runtime = new FakeRuntime({ throwError: "boom" });
574
1125
  const channel = new FakeChannel();
@@ -91,6 +91,7 @@ interface RunOpts {
91
91
  systemContext?: string;
92
92
  accountId?: string;
93
93
  onBlock?: (b: unknown) => void;
94
+ onStatus?: (e: unknown) => void;
94
95
  }
95
96
 
96
97
  function runAdapter(script: string, opts: RunOpts = {}) {
@@ -105,6 +106,7 @@ function runAdapter(script: string, opts: RunOpts = {}) {
105
106
  trustLevel: opts.trustLevel ?? "owner",
106
107
  systemContext: opts.systemContext,
107
108
  onBlock: opts.onBlock as never,
109
+ onStatus: opts.onStatus as never,
108
110
  });
109
111
  }
110
112
 
@@ -327,4 +329,41 @@ describe("HermesAgentAdapter", () => {
327
329
  const res = await runAdapter(p);
328
330
  expect(res.error).toBeDefined();
329
331
  });
332
+
333
+ it("agent_thought_chunk emits ONLY thinking.updated status, not a block", async () => {
334
+ const script = makeAcpServer(
335
+ "thoughtonly.js",
336
+ `
337
+ if (msg.method === "initialize") {
338
+ reply(msg, { protocolVersion: 1 });
339
+ } else if (msg.method === "session/new") {
340
+ reply(msg, { sessionId: "sess-thought" });
341
+ } else if (msg.method === "session/prompt") {
342
+ notify("session/update", { sessionId: msg.params.sessionId, update: { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "musing..." } } });
343
+ notify("session/update", { sessionId: msg.params.sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "answer" } } });
344
+ reply(msg, { stopReason: "end_turn" });
345
+ process.stdin.pause();
346
+ process.exit(0);
347
+ }
348
+ `,
349
+ );
350
+ const blocks: Array<{ kind: string }> = [];
351
+ const status: Array<{ phase: string; label?: string }> = [];
352
+ await runAdapter(script, {
353
+ onBlock: (b) => blocks.push(b as { kind: string }),
354
+ onStatus: (e) => {
355
+ const ev = e as { kind: string; phase: string; label?: string };
356
+ if (ev.kind === "thinking") status.push({ phase: ev.phase, label: ev.label });
357
+ },
358
+ });
359
+ // No block was produced for the thought chunk — the only block should be
360
+ // the assistant_message_chunk.
361
+ const blockKinds = blocks.map((b) => b.kind);
362
+ expect(blockKinds).not.toContain("system");
363
+ expect(blockKinds).toContain("assistant_text");
364
+ // But the status stream did surface the thinking.updated frame for it.
365
+ expect(status.some((s) => s.phase === "updated" && s.label === "Thinking")).toBe(true);
366
+ // And the prompt-done thinking.stopped fires from the ACP base.
367
+ expect(status.some((s) => s.phase === "stopped")).toBe(true);
368
+ });
330
369
  });