@stigmer/cli 3.1.6 → 3.1.8

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 (93) hide show
  1. package/commands/share.d.ts +3 -0
  2. package/commands/share.d.ts.map +1 -0
  3. package/commands/share.js +75 -0
  4. package/commands/share.js.map +1 -0
  5. package/config/index.d.ts +1 -1
  6. package/config/index.d.ts.map +1 -1
  7. package/config/index.js +1 -1
  8. package/config/index.js.map +1 -1
  9. package/config/resolve.d.ts +19 -2
  10. package/config/resolve.d.ts.map +1 -1
  11. package/config/resolve.js +40 -2
  12. package/config/resolve.js.map +1 -1
  13. package/local/runtime/node.d.ts +9 -2
  14. package/local/runtime/node.d.ts.map +1 -1
  15. package/local/runtime/node.js +38 -23
  16. package/local/runtime/node.js.map +1 -1
  17. package/package.json +6 -6
  18. package/program.d.ts.map +1 -1
  19. package/program.js +2 -0
  20. package/program.js.map +1 -1
  21. package/resources/connect/connect.d.ts.map +1 -1
  22. package/resources/connect/connect.js +76 -8
  23. package/resources/connect/connect.js.map +1 -1
  24. package/resources/connect/discover.d.ts.map +1 -1
  25. package/resources/connect/discover.js +12 -4
  26. package/resources/connect/discover.js.map +1 -1
  27. package/resources/connect/oauth.d.ts +0 -9
  28. package/resources/connect/oauth.d.ts.map +1 -1
  29. package/resources/connect/oauth.js +1 -17
  30. package/resources/connect/oauth.js.map +1 -1
  31. package/resources/mcp/placeholder-resolver.d.ts +19 -0
  32. package/resources/mcp/placeholder-resolver.d.ts.map +1 -0
  33. package/resources/mcp/placeholder-resolver.js +52 -0
  34. package/resources/mcp/placeholder-resolver.js.map +1 -0
  35. package/resources/mcp/runtime-env.d.ts +16 -0
  36. package/resources/mcp/runtime-env.d.ts.map +1 -1
  37. package/resources/mcp/runtime-env.js +29 -8
  38. package/resources/mcp/runtime-env.js.map +1 -1
  39. package/resources/share.d.ts +37 -0
  40. package/resources/share.d.ts.map +1 -0
  41. package/resources/share.js +144 -0
  42. package/resources/share.js.map +1 -0
  43. package/resources/stream/convert.d.ts +6 -2
  44. package/resources/stream/convert.d.ts.map +1 -1
  45. package/resources/stream/convert.js +8 -2
  46. package/resources/stream/convert.js.map +1 -1
  47. package/resources/stream/events.d.ts +14 -2
  48. package/resources/stream/events.d.ts.map +1 -1
  49. package/resources/stream/events.js.map +1 -1
  50. package/resources/stream/render-ndjson.js +2 -0
  51. package/resources/stream/render-ndjson.js.map +1 -1
  52. package/resources/stream/render-plaintext.d.ts.map +1 -1
  53. package/resources/stream/render-plaintext.js +3 -0
  54. package/resources/stream/render-plaintext.js.map +1 -1
  55. package/resources/stream/snapshot.js +6 -1
  56. package/resources/stream/snapshot.js.map +1 -1
  57. package/resources/stream/tool-state.d.ts +1 -1
  58. package/resources/stream/tool-state.d.ts.map +1 -1
  59. package/resources/stream/tool-state.js +13 -4
  60. package/resources/stream/tool-state.js.map +1 -1
  61. package/src/commands/connect.test.ts +26 -4
  62. package/src/commands/share.ts +99 -0
  63. package/src/config/index.ts +2 -0
  64. package/src/config/resolve.test.ts +38 -0
  65. package/src/config/resolve.ts +42 -3
  66. package/src/local/runtime/node.ts +43 -25
  67. package/src/local/runtime/runtime.test.ts +5 -4
  68. package/src/program.test.ts +7 -0
  69. package/src/program.ts +2 -0
  70. package/src/resources/connect/__fixtures__/stdio-server.mjs +8 -3
  71. package/src/resources/connect/connect.integration.test.ts +91 -0
  72. package/src/resources/connect/connect.ts +86 -8
  73. package/src/resources/connect/discover.test.ts +40 -1
  74. package/src/resources/connect/discover.ts +16 -4
  75. package/src/resources/connect/oauth.test.ts +1 -24
  76. package/src/resources/connect/oauth.ts +1 -17
  77. package/src/resources/mcp/placeholder-resolver.test.ts +71 -0
  78. package/src/resources/mcp/placeholder-resolver.ts +63 -0
  79. package/src/resources/mcp/runtime-env.test.ts +35 -1
  80. package/src/resources/mcp/runtime-env.ts +35 -9
  81. package/src/resources/read-verbs.integration.test.ts +1 -1
  82. package/src/resources/share.test.ts +389 -0
  83. package/src/resources/share.ts +227 -0
  84. package/src/resources/stream/convert.ts +17 -3
  85. package/src/resources/stream/diff.test.ts +22 -0
  86. package/src/resources/stream/events.ts +15 -1
  87. package/src/resources/stream/render-ndjson.test.ts +10 -0
  88. package/src/resources/stream/render-ndjson.ts +2 -0
  89. package/src/resources/stream/render-plaintext.test.ts +8 -0
  90. package/src/resources/stream/render-plaintext.ts +3 -0
  91. package/src/resources/stream/snapshot.test.ts +14 -0
  92. package/src/resources/stream/snapshot.ts +5 -1
  93. package/src/resources/stream/tool-state.ts +13 -4
@@ -0,0 +1,227 @@
1
+ // `share agent` dispatch: enable or disable public sharing for an agent and
2
+ // report the hosted chat link + embed snippet.
3
+ //
4
+ // The one correctness invariant here: the `updateSharing` RPC replaces
5
+ // `spec.sharing` WHOLESALE. A naive "set enabled" would silently wipe any
6
+ // `allowed_origins` or visitor messages the owner configured in the console.
7
+ // So this module always reads the current state first and sends the complete
8
+ // block back with only `enabled` flipped — the same merge-preserve discipline
9
+ // as the web dialog's draftFromAgent (sdk/react ShareAgentDialog.tsx).
10
+ //
11
+ // URL and snippet shapes come from @stigmer/sdk's sharing helpers — the single
12
+ // source of truth shared with the web console — so every surface emits
13
+ // byte-identical output. The caller supplies the app origin (resolved from the
14
+ // backend type by the command layer) to keep this module pure and env-free.
15
+
16
+ import { create } from "@bufbuild/protobuf";
17
+ import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
18
+ import {
19
+ RotateShareLinkInputSchema,
20
+ UpdateAgentSharingInputSchema,
21
+ } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/io_pb";
22
+ import {
23
+ AgentSharingAudience,
24
+ AgentSharingMessagesSchema,
25
+ AgentSharingSchema,
26
+ type AgentSharing,
27
+ } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
28
+ import { buildChatUrl, buildEmbedSnippet, type Stigmer } from "@stigmer/sdk";
29
+ import { UsageError } from "../errors/index.js";
30
+ import { CommandResult } from "../output/index.js";
31
+ import { isAgentId } from "./reference.js";
32
+ import { resolveAgentRef } from "./run/resolve.js";
33
+
34
+ /** Who can chat over the shared link. Mirrors the SDK's SharingAudience. */
35
+ export type ShareAudience = "public" | "org";
36
+
37
+ /** Inputs for {@link shareAgent} beyond the reference itself. */
38
+ export interface ShareAgentOptions {
39
+ /** Desired sharing state: `true` to enable, `false` to disable. */
40
+ readonly enabled: boolean;
41
+ /**
42
+ * Desired audience. Omitted means "keep the agent's current audience" —
43
+ * a plain toggle must never flip an org-members-only share back to
44
+ * public (or vice versa).
45
+ */
46
+ readonly audience?: ShareAudience;
47
+ /**
48
+ * Rotate the share-link token: the server generates a fresh `?k=`
49
+ * secret and the current link (tokened or plain) stops working
50
+ * immediately. The token is server-owned status — never part of the
51
+ * sharing block this module merges.
52
+ */
53
+ readonly resetLink?: boolean;
54
+ /** The app origin serving the hosted chat page and `embed.js`. */
55
+ readonly appOrigin: string;
56
+ /**
57
+ * Whether the CLI points at a local backend. Guest chat (the token mint
58
+ * behind the hosted page) is a Stigmer Cloud capability, so a local link
59
+ * gets a "won't serve visitors" warning alongside the result.
60
+ */
61
+ readonly isLocal: boolean;
62
+ }
63
+
64
+ /**
65
+ * Enable or disable sharing for the referenced agent and describe the
66
+ * outcome. Idempotent: when the agent is already in the desired state, no
67
+ * write is issued (the link is still reported when sharing is on).
68
+ */
69
+ export async function shareAgent(
70
+ client: Stigmer,
71
+ ref: string,
72
+ org: string,
73
+ options: ShareAgentOptions,
74
+ ): Promise<CommandResult> {
75
+ // Fail fast with actionable guidance instead of the backend's cryptic
76
+ // "org value length must be at least 1" — mirrors the connect org guard.
77
+ if (org === "" && !isAgentId(ref) && !ref.includes("/")) {
78
+ throw new UsageError(
79
+ "organization not set\n\n" +
80
+ "Set it with:\n" +
81
+ " stigmer config context set --org <org>\n" +
82
+ ` stigmer share agent ${ref} --org <org>`,
83
+ );
84
+ }
85
+
86
+ const agent = await resolveAgentRef(client, ref, org);
87
+ const current = agent.spec?.sharing;
88
+
89
+ const currentAudience = audienceFromProto(current?.audience);
90
+ const targetAudience = options.audience ?? currentAudience;
91
+
92
+ // Resetting the link is meaningless for org-members-only shares: their
93
+ // access is gated by live membership, and the member link never carries
94
+ // the token. Refuse rather than silently rotating an unused secret —
95
+ // the share dialog hides its Reset control for org audience for the
96
+ // same reason.
97
+ if (options.resetLink === true && targetAudience === "org") {
98
+ throw new UsageError(
99
+ "--reset-link only applies to public links\n\n" +
100
+ "This share is restricted to organization members, whose access is\n" +
101
+ "checked on every message. To cut someone off, remove them from the\n" +
102
+ "organization.",
103
+ );
104
+ }
105
+
106
+ const alreadyInState =
107
+ (current?.enabled ?? false) === options.enabled &&
108
+ currentAudience === targetAudience;
109
+
110
+ if (!alreadyInState) {
111
+ // Send the COMPLETE sharing block with only the requested fields changed,
112
+ // so a CLI toggle can never wipe console-configured origins, visitor
113
+ // messages, or an org-members-only audience.
114
+ await client.agent.updateSharing(
115
+ create(UpdateAgentSharingInputSchema, {
116
+ resourceId: agent.metadata?.id ?? "",
117
+ sharing: preservingSharing(current, options.enabled, targetAudience),
118
+ }),
119
+ );
120
+ }
121
+
122
+ // The rotation is a separate targeted RPC (the token is server-owned
123
+ // status, not part of the sharing block). The returned agent carries the
124
+ // fresh token, so the link printed below is the new one.
125
+ let linkToken = agent.status?.shareLinkToken ?? "";
126
+ if (options.resetLink === true) {
127
+ const rotated = await client.agent.rotateShareLink(
128
+ create(RotateShareLinkInputSchema, {
129
+ resourceId: agent.metadata?.id ?? "",
130
+ }),
131
+ );
132
+ linkToken = rotated.status?.shareLinkToken ?? "";
133
+ }
134
+
135
+ return describeOutcome(agent, options, targetAudience, linkToken, alreadyInState && options.resetLink !== true);
136
+ }
137
+
138
+ // Unspecified means public by contract (pre-audience shares keep their
139
+ // anyone-with-link behavior).
140
+ function audienceFromProto(audience: AgentSharingAudience | undefined): ShareAudience {
141
+ return audience === AgentSharingAudience.org ? "org" : "public";
142
+ }
143
+
144
+ // The full sharing config with the desired `enabled` and audience,
145
+ // preserving origins and messages (empty defaults when the agent was never
146
+ // shared before). The audience is written explicitly — never left
147
+ // unspecified — so a console-managed org share can't drift back to public.
148
+ function preservingSharing(
149
+ current: AgentSharing | undefined,
150
+ enabled: boolean,
151
+ audience: ShareAudience,
152
+ ): AgentSharing {
153
+ return create(AgentSharingSchema, {
154
+ enabled,
155
+ audience:
156
+ audience === "org"
157
+ ? AgentSharingAudience.org
158
+ : AgentSharingAudience.public,
159
+ allowedOrigins: [...(current?.allowedOrigins ?? [])],
160
+ messages: create(AgentSharingMessagesSchema, {
161
+ rateLimited: current?.messages?.rateLimited ?? "",
162
+ unavailable: current?.messages?.unavailable ?? "",
163
+ conversationEnded: current?.messages?.conversationEnded ?? "",
164
+ }),
165
+ });
166
+ }
167
+
168
+ // Build the user-facing result. Org/slug come from the RESOLVED agent's
169
+ // metadata (authoritative even when the user passed an ID), matching how the
170
+ // web share dialog derives them. The link token comes from the agent's
171
+ // status (post-rotation when --reset-link ran) and rides the printed URL
172
+ // and snippet — public audience only (org access is gated by membership).
173
+ function describeOutcome(
174
+ agent: Agent,
175
+ options: ShareAgentOptions,
176
+ audience: ShareAudience,
177
+ linkToken: string,
178
+ alreadyInState: boolean,
179
+ ): CommandResult {
180
+ const org = agent.metadata?.org ?? "";
181
+ const slug = agent.metadata?.slug ?? "";
182
+ const name = agent.metadata?.name || slug;
183
+
184
+ if (!options.enabled) {
185
+ const result = CommandResult.success(
186
+ alreadyInState ? `Sharing is already off for '${name}'` : `Sharing disabled for '${name}'`,
187
+ );
188
+ result.hint("The share link no longer works. Re-enable with:");
189
+ result.hint(` stigmer share agent ${org}/${slug}`);
190
+ return result;
191
+ }
192
+
193
+ const result = CommandResult.success(
194
+ options.resetLink === true
195
+ ? `Share link reset for '${name}' — the old link no longer works`
196
+ : alreadyInState
197
+ ? `Sharing is already on for '${name}'`
198
+ : `Sharing enabled for '${name}'`,
199
+ );
200
+
201
+ const publicLinkToken = audience === "org" ? undefined : linkToken || undefined;
202
+ result
203
+ .addSection(audience === "org" ? "Member chat link" : "Public chat link")
204
+ .item(buildChatUrl(options.appOrigin, org, slug, publicLinkToken));
205
+
206
+ if (audience === "org") {
207
+ // Embeds serve anonymous guests, which org-members-only shares refuse
208
+ // by design — no snippet to print.
209
+ result.hint(`Only signed-in members of ${org} can chat. Access ends when they leave the organization.`);
210
+ result.hint(`Members chat on ${org}'s credits.`);
211
+ } else {
212
+ const snippetSection = result.addSection("Embed on your site");
213
+ for (const line of buildEmbedSnippet(options.appOrigin, org, slug, publicLinkToken).split("\n")) {
214
+ snippetSection.item(line);
215
+ }
216
+
217
+ result.hint(`Visitors chat on ${org}'s credits.`);
218
+ result.hint("Public links can be forwarded and indexed by search engines.");
219
+ if (options.resetLink === true) {
220
+ result.hint("Re-share the new link with the people who should keep access.");
221
+ }
222
+ }
223
+ if (options.isLocal) {
224
+ result.hint("Guest chat is a Stigmer Cloud capability — this local link won't serve visitors.");
225
+ }
226
+ return result;
227
+ }
@@ -18,7 +18,15 @@ import {
18
18
  import type { ToolCallInfo, TodoItemView } from "./events.js";
19
19
 
20
20
  /** Mapped tool-call status strings. */
21
- export type ToolStatus = "pending" | "running" | "completed" | "failed" | "waiting_approval" | "skipped" | "unknown";
21
+ export type ToolStatus =
22
+ | "pending"
23
+ | "running"
24
+ | "completed"
25
+ | "failed"
26
+ | "waiting_approval"
27
+ | "skipped"
28
+ | "interrupted"
29
+ | "unknown";
22
30
 
23
31
  /** Convert a proto ToolCall to render info. Mirrors Go's convertToolCall. */
24
32
  export function convertToolCall(tc: ToolCall): ToolCallInfo {
@@ -110,6 +118,8 @@ export function mapToolCallStatus(status: ToolCallStatus): ToolStatus {
110
118
  return "waiting_approval";
111
119
  case ToolCallStatus.TOOL_CALL_SKIPPED:
112
120
  return "skipped";
121
+ case ToolCallStatus.TOOL_CALL_INTERRUPTED:
122
+ return "interrupted";
113
123
  default:
114
124
  return "unknown";
115
125
  }
@@ -148,9 +158,13 @@ export function subAgentStatusName(status: SubAgentStatus): string {
148
158
  return SubAgentStatus[status] ?? "SUB_AGENT_STATUS_UNSPECIFIED";
149
159
  }
150
160
 
151
- /** True for tool statuses that mean the tool is no longer executing. Mirrors Go's isTerminalToolStatus. */
161
+ /**
162
+ * True for tool statuses that mean the tool is no longer executing.
163
+ * "interrupted" (platform-settled at terminalization, issue #207) is terminal:
164
+ * without it, a settled call replays as a live running block forever.
165
+ */
152
166
  export function isTerminalToolStatus(status: string): boolean {
153
- return status === "completed" || status === "failed" || status === "skipped";
167
+ return status === "completed" || status === "failed" || status === "skipped" || status === "interrupted";
154
168
  }
155
169
 
156
170
  /** True for terminal agent phases. Mirrors Go's isTerminalAgentPhase. */
@@ -190,6 +190,28 @@ describe("tool call interleaving", () => {
190
190
  expect(kindsFor(seq)).toContain("toolCompleted");
191
191
  });
192
192
 
193
+ it("running → interrupted closes the block with toolInterrupted, not toolCompleted", () => {
194
+ // Issue #207: the platform settles an in-flight call to INTERRUPTED when
195
+ // the execution terminalizes; the live differ must close its running block
196
+ // with the honest event (a checkmark would claim the tool finished).
197
+ const seq = [
198
+ snapshot({ messages: [aiMsg("calling", { toolCalls: [tool("t1", "shell", ToolCallStatus.TOOL_CALL_RUNNING)] })] }),
199
+ snapshot({ messages: [aiMsg("calling", { toolCalls: [tool("t1", "shell", ToolCallStatus.TOOL_CALL_INTERRUPTED)] })] }),
200
+ ];
201
+ const kinds = kindsFor(seq);
202
+ expect(kinds).toContain("toolInterrupted");
203
+ expect(kinds).not.toContain("toolCompleted");
204
+ });
205
+
206
+ it("first-sight interrupted tool (re-attach) synthesizes toolInterrupted", () => {
207
+ const seq = [
208
+ snapshot({
209
+ messages: [toolMsg("", [tool("t1", "read", ToolCallStatus.TOOL_CALL_INTERRUPTED)])],
210
+ }),
211
+ ];
212
+ expect(kindsFor(seq)).toContain("toolInterrupted");
213
+ });
214
+
193
215
  it("emits a streaming delta when a running tool's result grows", () => {
194
216
  const seq = [
195
217
  snapshot({
@@ -15,7 +15,7 @@ import type { JsonObject } from "@bufbuild/protobuf";
15
15
  export interface ToolCallInfo {
16
16
  readonly id: string;
17
17
  readonly name: string;
18
- /** Mapped status: pending|running|completed|failed|waiting_approval|skipped|unknown. */
18
+ /** Mapped status: pending|running|completed|failed|waiting_approval|skipped|interrupted|unknown. */
19
19
  readonly status: string;
20
20
  readonly args?: JsonObject;
21
21
  readonly result: string;
@@ -85,6 +85,19 @@ export interface ToolCompletedEvent {
85
85
  readonly subAgentId: string;
86
86
  }
87
87
 
88
+ /**
89
+ * A tool call the platform settled to INTERRUPTED because its execution
90
+ * terminalized mid-call (issue #207). A distinct terminal event — routing it
91
+ * through toolCompleted would print a dishonest checkmark for a tool that
92
+ * never finished.
93
+ */
94
+ export interface ToolInterruptedEvent {
95
+ readonly kind: "toolInterrupted";
96
+ readonly toolCallId: string;
97
+ readonly toolCall: ToolCallInfo;
98
+ readonly subAgentId: string;
99
+ }
100
+
88
101
  export interface ToolWaitingApprovalEvent {
89
102
  readonly kind: "toolWaitingApproval";
90
103
  readonly toolCallId: string;
@@ -175,6 +188,7 @@ export type StreamEvent =
175
188
  | ToolResultEvent
176
189
  | ToolRunningEvent
177
190
  | ToolCompletedEvent
191
+ | ToolInterruptedEvent
178
192
  | ToolWaitingApprovalEvent
179
193
  | ToolStreamDeltaEvent
180
194
  | SystemMessageEvent
@@ -62,6 +62,16 @@ describe("envelope", () => {
62
62
  });
63
63
 
64
64
  describe("tool events", () => {
65
+ it("emits tool_interrupted as its own wire type (issue #207)", () => {
66
+ const { data } = render([
67
+ { kind: "toolInterrupted", toolCallId: "t1", subAgentId: "", toolCall: toolInfo({ id: "t1", name: "shell", status: "interrupted" }) },
68
+ ]);
69
+ expect(data.lines()[0]).toMatchObject({
70
+ type: "tool_interrupted",
71
+ payload: { tool_call_id: "t1", tool_name: "shell", status: "interrupted" },
72
+ });
73
+ });
74
+
65
75
  it("emits tool_running with only the meaningful fields", () => {
66
76
  const { data } = render([
67
77
  { kind: "toolRunning", toolCallId: "t1", subAgentId: "", toolCall: toolInfo({ id: "t1", result: "out", durationMs: 42 }) },
@@ -77,6 +77,8 @@ function mapToNdjson(event: StreamEvent): MappedEvent | undefined {
77
77
  return { type: "tool_running", payload: toolEventPayload(event.toolCallId, event.toolCall, event.subAgentId) };
78
78
  case "toolCompleted":
79
79
  return { type: "tool_completed", payload: toolEventPayload(event.toolCallId, event.toolCall, event.subAgentId) };
80
+ case "toolInterrupted":
81
+ return { type: "tool_interrupted", payload: toolEventPayload(event.toolCallId, event.toolCall, event.subAgentId) };
80
82
  case "toolWaitingApproval":
81
83
  return {
82
84
  type: "tool_waiting_approval",
@@ -39,6 +39,14 @@ describe("PlaintextRenderer", () => {
39
39
  expect(status).toContain("✗ shell: exploded");
40
40
  });
41
41
 
42
+ it("renders an interrupted tool with the neutral glyph, not a checkmark (issue #207)", () => {
43
+ const { status } = render([
44
+ { kind: "toolInterrupted", toolCallId: "t1", subAgentId: "", toolCall: toolInfo({ name: "shell", status: "interrupted" }) },
45
+ ]);
46
+ expect(status).toContain("⊘ shell (interrupted)");
47
+ expect(status).not.toContain("✓");
48
+ });
49
+
42
50
  it("notes the auto-skip for approvals and resolves to SKIP", () => {
43
51
  const r = new PlaintextRenderer({ data: { write: () => {} }, status: { write: () => {} } });
44
52
  const action = r.resolveApproval({
@@ -49,6 +49,9 @@ export class PlaintextRenderer implements HeadlessRenderer {
49
49
  : ` ✓ ${event.toolCall.name}\n`,
50
50
  );
51
51
  return;
52
+ case "toolInterrupted":
53
+ status.write(` ⊘ ${event.toolCall.name} (interrupted)\n`);
54
+ return;
52
55
  case "approvalNeeded":
53
56
  status.write(` ⚠ Approval needed: ${event.toolName} (auto-skipped in non-TTY mode)\n`);
54
57
  return;
@@ -79,6 +79,20 @@ describe("snapshotToEvents", () => {
79
79
  expect(events[0]).toMatchObject({ kind: "toolCompleted", toolCallId: "tc1" });
80
80
  });
81
81
 
82
+ it("replays a platform-interrupted call as toolInterrupted — never a live running block", () => {
83
+ // Issue #207: before the fix an INTERRUPTED status fell through to
84
+ // "unknown" (non-terminal) and replayed as toolRunning, a block that never
85
+ // closes. It must replay as its own settled event.
86
+ const events = snapshotToEvents([
87
+ exec({
88
+ phase: ExecutionPhase.EXECUTION_CANCELLED,
89
+ messages: [toolMsg(tool("tc1", ToolCallStatus.TOOL_CALL_INTERRUPTED))],
90
+ }),
91
+ ]);
92
+ expect(kinds(events)).toEqual(["toolInterrupted", "done"]);
93
+ expect(events[0]).toMatchObject({ kind: "toolInterrupted", toolCallId: "tc1" });
94
+ });
95
+
82
96
  it("only the final execution emits done", () => {
83
97
  const events = snapshotToEvents([
84
98
  exec({ message: "first", messages: [aiMsg("a")] }),
@@ -159,7 +159,11 @@ function emitOtherMessage(out: StreamEvent[], msg: AgentMessage): void {
159
159
  function emitToolByStatus(out: StreamEvent[], tc: ToolCall): void {
160
160
  const status = mapToolCallStatus(tc.status);
161
161
  const toolCall = convertToolCall(tc);
162
- if (isTerminalToolStatus(status)) {
162
+ if (status === "interrupted") {
163
+ // Terminal but never "completed": replaying a platform-interrupted call as
164
+ // either a checkmark or a live running block would lie (issue #207).
165
+ out.push({ kind: "toolInterrupted", toolCallId: tc.id, toolCall, subAgentId: "" });
166
+ } else if (isTerminalToolStatus(status)) {
163
167
  out.push({ kind: "toolCompleted", toolCallId: tc.id, toolCall, subAgentId: "" });
164
168
  } else if (status === "waiting_approval") {
165
169
  out.push({ kind: "toolWaitingApproval", toolCallId: tc.id, toolCall, subAgentId: "" });
@@ -55,14 +55,14 @@ export class ToolStateTracker {
55
55
  // First sight, already terminal (e.g. re-attach): synthesize completion so
56
56
  // the block exists — otherwise its MESSAGE_TOOL is suppressed and it vanishes.
57
57
  if (!seen && isTerminalToolStatus(current)) {
58
- pending.push(this.completed(tc, subAgentId));
58
+ pending.push(this.terminal(tc, current, subAgentId));
59
59
  this.states.set(tc.id, current);
60
60
  continue;
61
61
  }
62
62
 
63
63
  // running/waiting -> terminal: close the block.
64
64
  if (seen && (prev === "running" || prev === "waiting_approval") && isTerminalToolStatus(current)) {
65
- pending.push(this.completed(tc, subAgentId));
65
+ pending.push(this.terminal(tc, current, subAgentId));
66
66
  this.states.set(tc.id, current);
67
67
  this.results.delete(tc.id);
68
68
  continue;
@@ -113,8 +113,16 @@ export class ToolStateTracker {
113
113
  return { kind: "toolWaitingApproval", toolCallId: tc.id, toolCall: convertToolCall(tc), subAgentId };
114
114
  }
115
115
 
116
- private completed(tc: ToolCall, subAgentId: string): StreamEvent {
117
- return { kind: "toolCompleted", toolCallId: tc.id, toolCall: convertToolCall(tc), subAgentId };
116
+ // The terminal-close event for a settled status: interrupted gets its own
117
+ // kind (issue #207) so renderers never print a checkmark for a tool the
118
+ // platform cut short; every other terminal status closes as completed.
119
+ private terminal(tc: ToolCall, status: string, subAgentId: string): StreamEvent {
120
+ return {
121
+ kind: status === "interrupted" ? "toolInterrupted" : "toolCompleted",
122
+ toolCallId: tc.id,
123
+ toolCall: convertToolCall(tc),
124
+ subAgentId,
125
+ };
118
126
  }
119
127
  }
120
128
 
@@ -123,6 +131,7 @@ export function toolEventId(event: StreamEvent): string {
123
131
  switch (event.kind) {
124
132
  case "toolRunning":
125
133
  case "toolCompleted":
134
+ case "toolInterrupted":
126
135
  case "toolWaitingApproval":
127
136
  case "toolStreamDelta":
128
137
  return event.toolCallId;