@stigmer/cli 3.1.7 → 3.1.9
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.
- package/commands/share.d.ts +3 -0
- package/commands/share.d.ts.map +1 -0
- package/commands/share.js +75 -0
- package/commands/share.js.map +1 -0
- package/config/index.d.ts +1 -1
- package/config/index.d.ts.map +1 -1
- package/config/index.js +1 -1
- package/config/index.js.map +1 -1
- package/config/resolve.d.ts +19 -2
- package/config/resolve.d.ts.map +1 -1
- package/config/resolve.js +40 -2
- package/config/resolve.js.map +1 -1
- package/local/runtime/node.d.ts +9 -2
- package/local/runtime/node.d.ts.map +1 -1
- package/local/runtime/node.js +38 -23
- package/local/runtime/node.js.map +1 -1
- package/package.json +6 -6
- package/program.d.ts.map +1 -1
- package/program.js +2 -0
- package/program.js.map +1 -1
- package/registry/metadata.d.ts.map +1 -1
- package/registry/metadata.js +2 -0
- package/registry/metadata.js.map +1 -1
- package/registry/verb-support.d.ts.map +1 -1
- package/registry/verb-support.js +3 -0
- package/registry/verb-support.js.map +1 -1
- package/resources/apply/apply.d.ts.map +1 -1
- package/resources/apply/apply.js +2 -0
- package/resources/apply/apply.js.map +1 -1
- package/resources/apply/handlers.d.ts.map +1 -1
- package/resources/apply/handlers.js +11 -0
- package/resources/apply/handlers.js.map +1 -1
- package/resources/connect/oauth.d.ts +0 -9
- package/resources/connect/oauth.d.ts.map +1 -1
- package/resources/connect/oauth.js +1 -17
- package/resources/connect/oauth.js.map +1 -1
- package/resources/share.d.ts +38 -0
- package/resources/share.d.ts.map +1 -0
- package/resources/share.js +188 -0
- package/resources/share.js.map +1 -0
- package/resources/stream/convert.d.ts +6 -2
- package/resources/stream/convert.d.ts.map +1 -1
- package/resources/stream/convert.js +8 -2
- package/resources/stream/convert.js.map +1 -1
- package/resources/stream/events.d.ts +14 -2
- package/resources/stream/events.d.ts.map +1 -1
- package/resources/stream/events.js.map +1 -1
- package/resources/stream/render-ndjson.js +2 -0
- package/resources/stream/render-ndjson.js.map +1 -1
- package/resources/stream/render-plaintext.d.ts.map +1 -1
- package/resources/stream/render-plaintext.js +3 -0
- package/resources/stream/render-plaintext.js.map +1 -1
- package/resources/stream/snapshot.js +6 -1
- package/resources/stream/snapshot.js.map +1 -1
- package/resources/stream/tool-state.d.ts +1 -1
- package/resources/stream/tool-state.d.ts.map +1 -1
- package/resources/stream/tool-state.js +13 -4
- package/resources/stream/tool-state.js.map +1 -1
- package/src/commands/connect.test.ts +26 -4
- package/src/commands/share.ts +99 -0
- package/src/config/index.ts +2 -0
- package/src/config/resolve.test.ts +38 -0
- package/src/config/resolve.ts +42 -3
- package/src/local/runtime/node.ts +43 -25
- package/src/local/runtime/runtime.test.ts +5 -4
- package/src/program.test.ts +7 -0
- package/src/program.ts +2 -0
- package/src/registry/metadata.ts +2 -0
- package/src/registry/verb-support.ts +3 -0
- package/src/resources/apply/apply.ts +2 -0
- package/src/resources/apply/handlers.ts +11 -0
- package/src/resources/connect/oauth.test.ts +1 -24
- package/src/resources/connect/oauth.ts +1 -17
- package/src/resources/read-verbs.integration.test.ts +1 -1
- package/src/resources/share.test.ts +498 -0
- package/src/resources/share.ts +287 -0
- package/src/resources/stream/convert.ts +17 -3
- package/src/resources/stream/diff.test.ts +22 -0
- package/src/resources/stream/events.ts +15 -1
- package/src/resources/stream/render-ndjson.test.ts +10 -0
- package/src/resources/stream/render-ndjson.ts +2 -0
- package/src/resources/stream/render-plaintext.test.ts +8 -0
- package/src/resources/stream/render-plaintext.ts +3 -0
- package/src/resources/stream/snapshot.test.ts +14 -0
- package/src/resources/stream/snapshot.ts +5 -1
- package/src/resources/stream/tool-state.ts +13 -4
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// `share agent` dispatch: enable or disable sharing for an agent and report
|
|
2
|
+
// the hosted chat link + embed snippet.
|
|
3
|
+
//
|
|
4
|
+
// Sharing lives in its own AgentShare resource (decision 011) — the agent is
|
|
5
|
+
// never modified. This module resolves the agent, reads its canonical share,
|
|
6
|
+
// and commits changes via `agentShare.apply`, an idempotent upsert keyed on
|
|
7
|
+
// the share's (org, slug) identity: the first enable creates the share, later
|
|
8
|
+
// toggles update it, one code path.
|
|
9
|
+
//
|
|
10
|
+
// The one correctness invariant here: apply replaces the share's spec
|
|
11
|
+
// WHOLESALE. A naive "set enabled" would silently wipe any allowed_origins,
|
|
12
|
+
// visitor messages, audience, or credential bindings the owner configured in
|
|
13
|
+
// the console. So this module always reads the current share first and sends
|
|
14
|
+
// the complete spec back with only the requested fields flipped — the same
|
|
15
|
+
// merge-preserve discipline as the web dialog. (The rotatable link token is
|
|
16
|
+
// exempt: it is server-owned status, which survives every apply verbatim.)
|
|
17
|
+
//
|
|
18
|
+
// URL and snippet shapes come from @stigmer/sdk's sharing helpers — the single
|
|
19
|
+
// source of truth shared with the web console — so every surface emits
|
|
20
|
+
// byte-identical output. The caller supplies the app origin (resolved from the
|
|
21
|
+
// backend type by the command layer) to keep this module pure and env-free.
|
|
22
|
+
|
|
23
|
+
import { create } from "@bufbuild/protobuf";
|
|
24
|
+
import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
25
|
+
import type { AgentShare } from "@stigmer/protos/ai/stigmer/agentic/agentshare/v1/api_pb";
|
|
26
|
+
import {
|
|
27
|
+
GetAgentSharesByAgentRequestSchema,
|
|
28
|
+
RotateShareLinkInputSchema,
|
|
29
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentshare/v1/io_pb";
|
|
30
|
+
import { AgentShareAudience } from "@stigmer/protos/ai/stigmer/agentic/agentshare/v1/spec_pb";
|
|
31
|
+
import {
|
|
32
|
+
buildChatUrl,
|
|
33
|
+
buildEmbedSnippet,
|
|
34
|
+
type AgentShareInput,
|
|
35
|
+
type Stigmer,
|
|
36
|
+
} from "@stigmer/sdk";
|
|
37
|
+
import { UsageError } from "../errors/index.js";
|
|
38
|
+
import { CommandResult } from "../output/index.js";
|
|
39
|
+
import { isAgentId } from "./reference.js";
|
|
40
|
+
import { resolveAgentRef } from "./run/resolve.js";
|
|
41
|
+
|
|
42
|
+
/** Who can chat over the shared link. Mirrors the SDK's SharingAudience. */
|
|
43
|
+
export type ShareAudience = "public" | "org";
|
|
44
|
+
|
|
45
|
+
/** Inputs for {@link shareAgent} beyond the reference itself. */
|
|
46
|
+
export interface ShareAgentOptions {
|
|
47
|
+
/** Desired sharing state: `true` to enable, `false` to disable. */
|
|
48
|
+
readonly enabled: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Desired audience. Omitted means "keep the share's current audience" —
|
|
51
|
+
* a plain toggle must never flip an org-members-only share back to
|
|
52
|
+
* public (or vice versa).
|
|
53
|
+
*/
|
|
54
|
+
readonly audience?: ShareAudience;
|
|
55
|
+
/**
|
|
56
|
+
* Rotate the share-link token: the server generates a fresh `?k=`
|
|
57
|
+
* secret and the current link (tokened or plain) stops working
|
|
58
|
+
* immediately. The token is server-owned status — never part of the
|
|
59
|
+
* spec this module merges.
|
|
60
|
+
*/
|
|
61
|
+
readonly resetLink?: boolean;
|
|
62
|
+
/** The app origin serving the hosted chat page and `embed.js`. */
|
|
63
|
+
readonly appOrigin: string;
|
|
64
|
+
/**
|
|
65
|
+
* Whether the CLI points at a local backend. Guest chat (the token mint
|
|
66
|
+
* behind the hosted page) is a Stigmer Cloud capability, so a local link
|
|
67
|
+
* gets a "won't serve visitors" warning alongside the result.
|
|
68
|
+
*/
|
|
69
|
+
readonly isLocal: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Enable or disable sharing for the referenced agent and describe the
|
|
74
|
+
* outcome. Idempotent: when the canonical share is already in the desired
|
|
75
|
+
* state, no write is issued (the link is still reported when sharing is
|
|
76
|
+
* on). When the agent has never been shared, enabling creates the share.
|
|
77
|
+
*/
|
|
78
|
+
export async function shareAgent(
|
|
79
|
+
client: Stigmer,
|
|
80
|
+
ref: string,
|
|
81
|
+
org: string,
|
|
82
|
+
options: ShareAgentOptions,
|
|
83
|
+
): Promise<CommandResult> {
|
|
84
|
+
// Fail fast with actionable guidance instead of the backend's cryptic
|
|
85
|
+
// "org value length must be at least 1" — mirrors the connect org guard.
|
|
86
|
+
if (org === "" && !isAgentId(ref) && !ref.includes("/")) {
|
|
87
|
+
throw new UsageError(
|
|
88
|
+
"organization not set\n\n" +
|
|
89
|
+
"Set it with:\n" +
|
|
90
|
+
" stigmer config context set --org <org>\n" +
|
|
91
|
+
` stigmer share agent ${ref} --org <org>`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const agent = await resolveAgentRef(client, ref, org);
|
|
96
|
+
let share = await resolveCanonicalShare(client, agent);
|
|
97
|
+
const current = share?.spec;
|
|
98
|
+
|
|
99
|
+
const currentAudience = audienceFromProto(current?.audience);
|
|
100
|
+
const targetAudience = options.audience ?? currentAudience;
|
|
101
|
+
|
|
102
|
+
// Resetting the link is meaningless for org-members-only shares: their
|
|
103
|
+
// access is gated by live membership, and the member link never carries
|
|
104
|
+
// the token. Refuse rather than silently rotating an unused secret —
|
|
105
|
+
// the share dialog hides its Reset control for org audience for the
|
|
106
|
+
// same reason.
|
|
107
|
+
if (options.resetLink === true && targetAudience === "org") {
|
|
108
|
+
throw new UsageError(
|
|
109
|
+
"--reset-link only applies to public links\n\n" +
|
|
110
|
+
"This share is restricted to organization members, whose access is\n" +
|
|
111
|
+
"checked on every message. To cut someone off, remove them from the\n" +
|
|
112
|
+
"organization.",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Never-shared + disable is a no-op, not a write: creating a share row
|
|
117
|
+
// just to mark it disabled would materialize a resource the owner never
|
|
118
|
+
// asked for. The one exception is an explicit --audience org, which is
|
|
119
|
+
// real configuration worth persisting as a paused share (mirroring the
|
|
120
|
+
// pre-promotion behavior of storing audience on a disabled block).
|
|
121
|
+
const alreadyInState =
|
|
122
|
+
share !== null
|
|
123
|
+
? (current?.enabled ?? false) === options.enabled &&
|
|
124
|
+
currentAudience === targetAudience
|
|
125
|
+
: !options.enabled && targetAudience !== "org";
|
|
126
|
+
|
|
127
|
+
if (!alreadyInState) {
|
|
128
|
+
// Apply the COMPLETE spec with only the requested fields changed, so a
|
|
129
|
+
// CLI toggle can never wipe console-configured origins, visitor
|
|
130
|
+
// messages, credential bindings, or an org-members-only audience.
|
|
131
|
+
share = await client.agentShare.apply(
|
|
132
|
+
preservingShareInput(agent, share, options.enabled, targetAudience),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The rotation is a separate targeted RPC (the token is server-owned
|
|
137
|
+
// status, not part of the spec apply merges). The returned share carries
|
|
138
|
+
// the fresh token, so the link printed below is the new one.
|
|
139
|
+
if (options.resetLink === true && share !== null) {
|
|
140
|
+
share = await client.agentShare.rotateShareLink(
|
|
141
|
+
create(RotateShareLinkInputSchema, {
|
|
142
|
+
resourceId: share.metadata?.id ?? "",
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return describeOutcome(
|
|
148
|
+
agent,
|
|
149
|
+
share,
|
|
150
|
+
options,
|
|
151
|
+
targetAudience,
|
|
152
|
+
alreadyInState && options.resetLink !== true,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The agent's canonical share: the one whose slug equals the agent's (the
|
|
158
|
+
* server's default on create), else the first entry, else null when the
|
|
159
|
+
* agent has never been shared. Mirrors the web dialog's selection so both
|
|
160
|
+
* surfaces manage the same row (decision 011 D3: one canonical share in
|
|
161
|
+
* Phase A).
|
|
162
|
+
*/
|
|
163
|
+
async function resolveCanonicalShare(
|
|
164
|
+
client: Stigmer,
|
|
165
|
+
agent: Agent,
|
|
166
|
+
): Promise<AgentShare | null> {
|
|
167
|
+
const result = await client.agentShare.getByAgent(
|
|
168
|
+
create(GetAgentSharesByAgentRequestSchema, {
|
|
169
|
+
agentId: agent.metadata?.id ?? "",
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
const agentSlug = agent.metadata?.slug ?? "";
|
|
173
|
+
return (
|
|
174
|
+
result.items.find((share) => share.metadata?.slug === agentSlug) ??
|
|
175
|
+
result.items[0] ??
|
|
176
|
+
null
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Unspecified means public by contract (a share created without an explicit
|
|
181
|
+
// audience is an anyone-with-link share).
|
|
182
|
+
function audienceFromProto(audience: AgentShareAudience | undefined): ShareAudience {
|
|
183
|
+
return audience === AgentShareAudience.org ? "org" : "public";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The full share input with the desired `enabled` and audience, preserving
|
|
187
|
+
// origins, messages, and credential bindings (empty defaults when the agent
|
|
188
|
+
// was never shared before). Identity comes from the existing share when one
|
|
189
|
+
// exists — a manifest-created share may carry a non-default slug, and
|
|
190
|
+
// applying with the agent's slug would create a SECOND share — and from the
|
|
191
|
+
// agent otherwise (the server's own D2 default, made explicit). The audience
|
|
192
|
+
// is written explicitly — never left unspecified — so a console-managed org
|
|
193
|
+
// share can't drift back to public.
|
|
194
|
+
function preservingShareInput(
|
|
195
|
+
agent: Agent,
|
|
196
|
+
share: AgentShare | null,
|
|
197
|
+
enabled: boolean,
|
|
198
|
+
audience: ShareAudience,
|
|
199
|
+
): AgentShareInput {
|
|
200
|
+
const agentOrg = agent.metadata?.org ?? "";
|
|
201
|
+
const agentSlug = agent.metadata?.slug ?? "";
|
|
202
|
+
const current = share?.spec;
|
|
203
|
+
return {
|
|
204
|
+
org: share?.metadata?.org || agentOrg,
|
|
205
|
+
slug: share?.metadata?.slug || agentSlug,
|
|
206
|
+
name: share?.metadata?.name || agent.metadata?.name || agentSlug,
|
|
207
|
+
agentRef: { org: agentOrg, slug: agentSlug },
|
|
208
|
+
enabled,
|
|
209
|
+
audience:
|
|
210
|
+
audience === "org"
|
|
211
|
+
? AgentShareAudience.org
|
|
212
|
+
: AgentShareAudience.public,
|
|
213
|
+
allowedOrigins: [...(current?.allowedOrigins ?? [])],
|
|
214
|
+
messages: {
|
|
215
|
+
rateLimited: current?.messages?.rateLimited ?? "",
|
|
216
|
+
unavailable: current?.messages?.unavailable ?? "",
|
|
217
|
+
conversationEnded: current?.messages?.conversationEnded ?? "",
|
|
218
|
+
},
|
|
219
|
+
environmentRefs: (current?.environmentRefs ?? []).map((envRef) => ({
|
|
220
|
+
org: envRef.org,
|
|
221
|
+
slug: envRef.slug,
|
|
222
|
+
})),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Build the user-facing result. Org/slug come from the SHARE's metadata —
|
|
227
|
+
// the identity in the hosted chat URL (a share may carry a non-default
|
|
228
|
+
// slug) — falling back to the resolved agent's on disable-when-never-shared.
|
|
229
|
+
// The link token comes from the share's status (post-rotation when
|
|
230
|
+
// --reset-link ran) and rides the printed URL and snippet — public audience
|
|
231
|
+
// only (org access is gated by membership).
|
|
232
|
+
function describeOutcome(
|
|
233
|
+
agent: Agent,
|
|
234
|
+
share: AgentShare | null,
|
|
235
|
+
options: ShareAgentOptions,
|
|
236
|
+
audience: ShareAudience,
|
|
237
|
+
alreadyInState: boolean,
|
|
238
|
+
): CommandResult {
|
|
239
|
+
const org = share?.metadata?.org || (agent.metadata?.org ?? "");
|
|
240
|
+
const slug = share?.metadata?.slug || (agent.metadata?.slug ?? "");
|
|
241
|
+
const name = agent.metadata?.name || slug;
|
|
242
|
+
const linkToken = share?.status?.shareLinkToken ?? "";
|
|
243
|
+
|
|
244
|
+
if (!options.enabled) {
|
|
245
|
+
const result = CommandResult.success(
|
|
246
|
+
alreadyInState ? `Sharing is already off for '${name}'` : `Sharing disabled for '${name}'`,
|
|
247
|
+
);
|
|
248
|
+
result.hint("The share link no longer works. Re-enable with:");
|
|
249
|
+
result.hint(` stigmer share agent ${org}/${slug}`);
|
|
250
|
+
return result;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const result = CommandResult.success(
|
|
254
|
+
options.resetLink === true
|
|
255
|
+
? `Share link reset for '${name}' — the old link no longer works`
|
|
256
|
+
: alreadyInState
|
|
257
|
+
? `Sharing is already on for '${name}'`
|
|
258
|
+
: `Sharing enabled for '${name}'`,
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const publicLinkToken = audience === "org" ? undefined : linkToken || undefined;
|
|
262
|
+
result
|
|
263
|
+
.addSection(audience === "org" ? "Member chat link" : "Public chat link")
|
|
264
|
+
.item(buildChatUrl(options.appOrigin, org, slug, publicLinkToken));
|
|
265
|
+
|
|
266
|
+
if (audience === "org") {
|
|
267
|
+
// Embeds serve anonymous guests, which org-members-only shares refuse
|
|
268
|
+
// by design — no snippet to print.
|
|
269
|
+
result.hint(`Only signed-in members of ${org} can chat. Access ends when they leave the organization.`);
|
|
270
|
+
result.hint(`Members chat on ${org}'s credits.`);
|
|
271
|
+
} else {
|
|
272
|
+
const snippetSection = result.addSection("Embed on your site");
|
|
273
|
+
for (const line of buildEmbedSnippet(options.appOrigin, org, slug, publicLinkToken).split("\n")) {
|
|
274
|
+
snippetSection.item(line);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
result.hint(`Visitors chat on ${org}'s credits.`);
|
|
278
|
+
result.hint("Public links can be forwarded and indexed by search engines.");
|
|
279
|
+
if (options.resetLink === true) {
|
|
280
|
+
result.hint("Re-share the new link with the people who should keep access.");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (options.isLocal) {
|
|
284
|
+
result.hint("Guest chat is a Stigmer Cloud capability — this local link won't serve visitors.");
|
|
285
|
+
}
|
|
286
|
+
return result;
|
|
287
|
+
}
|
|
@@ -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 =
|
|
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
|
-
/**
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
-
|
|
117
|
-
|
|
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;
|