jeopi-cli 16.4.3 → 16.4.5
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/dist/cli.js +2817 -2814
- package/dist/types/advisor/advise-tool.d.ts +5 -1
- package/dist/types/advisor/runtime.d.ts +15 -0
- package/dist/types/config/model-registry.d.ts +3 -3
- package/dist/types/config/models-config.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +0 -10
- package/dist/types/modes/rpc/host-tools.d.ts +2 -0
- package/dist/types/modes/rpc/rpc-mode.d.ts +27 -7
- package/dist/types/modes/utils/transcript-render-helpers.d.ts +9 -0
- package/dist/types/session/agent-session.d.ts +1 -0
- package/package.json +12 -12
- package/src/advisor/advise-tool.ts +6 -1
- package/src/advisor/runtime.ts +127 -0
- package/src/autolearn/controller.ts +1 -1
- package/src/config/config-file.ts +30 -12
- package/src/config/model-registry.ts +7 -7
- package/src/config/models-config.ts +1 -1
- package/src/config/settings-schema.ts +0 -12
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +9 -1
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +30 -3
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/modes/components/chat-transcript-builder.ts +31 -11
- package/src/modes/controllers/event-controller.ts +41 -12
- package/src/modes/rpc/host-tools.ts +13 -0
- package/src/modes/rpc/rpc-mode.ts +130 -56
- package/src/modes/utils/transcript-render-helpers.ts +44 -0
- package/src/modes/utils/ui-helpers.ts +16 -1
- package/src/session/agent-session.ts +95 -11
- package/src/task/render.ts +51 -44
- package/src/tools/bash.ts +0 -11
- package/src/tools/path-utils.ts +6 -1
- package/src/utils/title-generator.ts +5 -1
- package/dist/types/tools/bash-command-fixup.d.ts +0 -11
- package/src/tools/bash-command-fixup.ts +0 -37
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
buildIrcMessageCard,
|
|
35
35
|
normalizeToolArgs,
|
|
36
36
|
resolveAssistantErrorMessage,
|
|
37
|
+
splitAssistantMessageToolTimeline,
|
|
37
38
|
} from "../utils/transcript-render-helpers";
|
|
38
39
|
import { createAdvisorMessageCard } from "./advisor-message";
|
|
39
40
|
import { AssistantMessageComponent } from "./assistant-message";
|
|
@@ -268,13 +269,16 @@ export class ChatTranscriptBuilder {
|
|
|
268
269
|
}
|
|
269
270
|
|
|
270
271
|
#appendAssistantMessage(message: Extract<AgentMessage, { role: "assistant" }>): void {
|
|
272
|
+
const hideThinkingBlock = this.deps.hideThinkingBlock?.() ?? false;
|
|
273
|
+
const proseOnlyThinking = this.deps.proseOnlyThinking ? this.deps.proseOnlyThinking() : true;
|
|
274
|
+
const timeline = splitAssistantMessageToolTimeline(message);
|
|
271
275
|
const assistantComponent = new AssistantMessageComponent(
|
|
272
|
-
|
|
273
|
-
|
|
276
|
+
timeline.beforeTools,
|
|
277
|
+
hideThinkingBlock,
|
|
274
278
|
() => this.deps.requestRender(),
|
|
275
|
-
this.deps.getMessageRenderer ? undefined : [],
|
|
276
|
-
undefined,
|
|
277
|
-
|
|
279
|
+
this.deps.getMessageRenderer ? undefined : [],
|
|
280
|
+
undefined,
|
|
281
|
+
proseOnlyThinking,
|
|
278
282
|
);
|
|
279
283
|
this.container.addChild(assistantComponent);
|
|
280
284
|
|
|
@@ -286,18 +290,30 @@ export class ChatTranscriptBuilder {
|
|
|
286
290
|
this.#lastAssistantUsage = message.usage;
|
|
287
291
|
}
|
|
288
292
|
|
|
289
|
-
|
|
290
|
-
if (hasVisibleAssistantContent) {
|
|
291
|
-
// New visible turn content closes the current read run (mirrors rebuild).
|
|
293
|
+
if (assistantHasVisibleContent(message)) {
|
|
292
294
|
this.#readGroup?.seal();
|
|
293
295
|
this.#readGroup = null;
|
|
294
296
|
}
|
|
295
297
|
|
|
296
298
|
const { hasErrorStop, errorMessage } = resolveAssistantErrorMessage(message);
|
|
299
|
+
const appendAssistantSegment = (segment: Extract<AgentMessage, { role: "assistant" }> | undefined) => {
|
|
300
|
+
if (!segment || !assistantHasVisibleContent(segment)) return;
|
|
301
|
+
this.container.addChild(
|
|
302
|
+
new AssistantMessageComponent(
|
|
303
|
+
segment,
|
|
304
|
+
hideThinkingBlock,
|
|
305
|
+
() => this.deps.requestRender(),
|
|
306
|
+
this.deps.getMessageRenderer ? undefined : [],
|
|
307
|
+
undefined,
|
|
308
|
+
proseOnlyThinking,
|
|
309
|
+
),
|
|
310
|
+
);
|
|
311
|
+
};
|
|
297
312
|
|
|
298
313
|
for (const content of message.content) {
|
|
299
314
|
if (content.type !== "toolCall") continue;
|
|
300
315
|
this.#resolveWaitingPoll(content.name);
|
|
316
|
+
const afterToolSegment = timeline.afterToolCalls.get(content.id);
|
|
301
317
|
|
|
302
318
|
if (
|
|
303
319
|
content.name === "read" &&
|
|
@@ -312,10 +328,14 @@ export class ChatTranscriptBuilder {
|
|
|
312
328
|
false,
|
|
313
329
|
content.id,
|
|
314
330
|
);
|
|
331
|
+
} else if (afterToolSegment) {
|
|
332
|
+
const group = this.#ensureReadGroup();
|
|
333
|
+
group.updateArgs(content.arguments, content.id);
|
|
334
|
+
this.#pendingTools.set(content.id, group);
|
|
315
335
|
} else {
|
|
316
|
-
|
|
317
|
-
this.#readArgs.set(content.id, normalizedArgs);
|
|
336
|
+
this.#readArgs.set(content.id, normalizeToolArgs(content.arguments));
|
|
318
337
|
}
|
|
338
|
+
appendAssistantSegment(afterToolSegment);
|
|
319
339
|
continue;
|
|
320
340
|
}
|
|
321
341
|
|
|
@@ -325,7 +345,6 @@ export class ChatTranscriptBuilder {
|
|
|
325
345
|
content.name,
|
|
326
346
|
content.arguments,
|
|
327
347
|
{
|
|
328
|
-
// Images can't be sliced through the scroll viewport; keep them off.
|
|
329
348
|
showImages: false,
|
|
330
349
|
editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
|
|
331
350
|
editAllowFuzzy: settings.get("edit.fuzzyMatch"),
|
|
@@ -348,6 +367,7 @@ export class ChatTranscriptBuilder {
|
|
|
348
367
|
} else {
|
|
349
368
|
this.#pendingTools.set(content.id, component);
|
|
350
369
|
}
|
|
370
|
+
appendAssistantSegment(afterToolSegment);
|
|
351
371
|
}
|
|
352
372
|
|
|
353
373
|
this.#pendingUsage = settings.get("display.showTokenUsage") ? message.usage : undefined;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ImageContent } from "jeopi-ai";
|
|
1
|
+
import type { AssistantMessage, ImageContent } from "jeopi-ai";
|
|
2
2
|
import * as AIError from "jeopi-ai/error";
|
|
3
3
|
import { getStreamingPartialJson } from "jeopi-ai/utils/block-symbols";
|
|
4
4
|
import { type Component, Loader, TERMINAL } from "jeopi-tui";
|
|
@@ -31,6 +31,7 @@ import { vocalizer } from "../../tts/vocalizer";
|
|
|
31
31
|
import { canonicalizeMessage } from "../../utils/thinking-display";
|
|
32
32
|
import { interruptHint } from "../shared";
|
|
33
33
|
import { createAssistantMessageComponent } from "../utils/interactive-context-helpers";
|
|
34
|
+
import { assistantHasVisibleContent, splitAssistantMessageToolTimeline } from "../utils/transcript-render-helpers";
|
|
34
35
|
import { StreamingRevealController } from "./streaming-reveal";
|
|
35
36
|
import { streamingStringKeysForTool, ToolArgsRevealController } from "./tool-args-reveal";
|
|
36
37
|
|
|
@@ -76,6 +77,7 @@ export class EventController {
|
|
|
76
77
|
#lastIntent: string | undefined = undefined;
|
|
77
78
|
#backgroundTaskCallIds = new Set<string>();
|
|
78
79
|
#readToolCallArgs = new Map<string, Record<string, unknown>>();
|
|
80
|
+
#toolTimelineComponents = new Map<string, Component>();
|
|
79
81
|
#readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
|
|
80
82
|
#lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
|
|
81
83
|
// Assistant component whose turn-ending error is currently mirrored in the
|
|
@@ -235,6 +237,24 @@ export class EventController {
|
|
|
235
237
|
assistantComponent.setToolResultImages(toolCallId, images);
|
|
236
238
|
return true;
|
|
237
239
|
}
|
|
240
|
+
|
|
241
|
+
#insertAfterTranscriptComponent(anchor: Component | undefined, component: Component): void {
|
|
242
|
+
const children = this.ctx.chatContainer.children;
|
|
243
|
+
const anchorIndex = anchor ? children.indexOf(anchor) : -1;
|
|
244
|
+
this.ctx.chatContainer.addChild(component);
|
|
245
|
+
if (anchorIndex < 0) return;
|
|
246
|
+
children.splice(children.length - 1, 1);
|
|
247
|
+
children.splice(anchorIndex + 1, 0, component);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#appendPostToolAssistantSegment(toolCallId: string, segment: AssistantMessage | undefined) {
|
|
251
|
+
if (!segment || !assistantHasVisibleContent(segment)) return undefined;
|
|
252
|
+
const component = createAssistantMessageComponent(this.ctx);
|
|
253
|
+
component.updateContent(segment);
|
|
254
|
+
component.markTranscriptBlockFinalized();
|
|
255
|
+
this.#insertAfterTranscriptComponent(this.#toolTimelineComponents.get(toolCallId), component);
|
|
256
|
+
return component;
|
|
257
|
+
}
|
|
238
258
|
#updateWorkingMessageFromIntent(intent: unknown): void {
|
|
239
259
|
if (this.ctx.session.isAborting) return;
|
|
240
260
|
// Streamed JSON can deliver non-string `i` (object, number, boolean) before
|
|
@@ -262,6 +282,7 @@ export class EventController {
|
|
|
262
282
|
this.#lastVisibleBlockCount = 0;
|
|
263
283
|
this.#renderedCustomMessages.clear();
|
|
264
284
|
this.#lastIntent = undefined;
|
|
285
|
+
this.#toolTimelineComponents.clear();
|
|
265
286
|
this.#backgroundTaskCallIds.clear();
|
|
266
287
|
this.#readToolCallArgs.clear();
|
|
267
288
|
this.#readToolCallAssistantComponents.clear();
|
|
@@ -306,6 +327,7 @@ export class EventController {
|
|
|
306
327
|
}
|
|
307
328
|
|
|
308
329
|
async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
|
|
330
|
+
this.#toolTimelineComponents.clear();
|
|
309
331
|
this.#lastIntent = undefined;
|
|
310
332
|
this.#readToolCallArgs.clear();
|
|
311
333
|
this.#readToolCallAssistantComponents.clear();
|
|
@@ -626,6 +648,7 @@ export class EventController {
|
|
|
626
648
|
const group = this.#getReadGroup();
|
|
627
649
|
group.updateArgs(content.arguments, content.id);
|
|
628
650
|
this.ctx.pendingTools.set(content.id, group);
|
|
651
|
+
this.#toolTimelineComponents.set(content.id, group);
|
|
629
652
|
}
|
|
630
653
|
continue;
|
|
631
654
|
}
|
|
@@ -672,6 +695,7 @@ export class EventController {
|
|
|
672
695
|
component.setExpanded(this.ctx.toolOutputExpanded);
|
|
673
696
|
this.ctx.chatContainer.addChild(component);
|
|
674
697
|
this.ctx.pendingTools.set(content.id, component);
|
|
698
|
+
this.#toolTimelineComponents.set(content.id, component);
|
|
675
699
|
this.#toolArgsReveal.bind(content.id, component);
|
|
676
700
|
} else {
|
|
677
701
|
const component = this.ctx.pendingTools.get(content.id);
|
|
@@ -745,15 +769,12 @@ export class EventController {
|
|
|
745
769
|
errorMessage = resolveAbortLabel(this.ctx.streamingMessage, this.ctx.viewSession.retryAttempt);
|
|
746
770
|
this.ctx.streamingMessage.errorMessage = errorMessage;
|
|
747
771
|
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
} else {
|
|
755
|
-
this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
|
|
756
|
-
}
|
|
772
|
+
const displayMessage: AssistantMessage =
|
|
773
|
+
silentlyAborted || ttsrSilenced
|
|
774
|
+
? { ...this.ctx.streamingMessage, stopReason: "stop" as const }
|
|
775
|
+
: this.ctx.streamingMessage;
|
|
776
|
+
const displayTimeline = splitAssistantMessageToolTimeline(displayMessage);
|
|
777
|
+
this.ctx.streamingComponent.updateContent(displayTimeline.beforeTools);
|
|
757
778
|
|
|
758
779
|
if (this.ctx.streamingMessage.stopReason !== "aborted" && this.ctx.streamingMessage.stopReason !== "error") {
|
|
759
780
|
for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
|
|
@@ -783,8 +804,13 @@ export class EventController {
|
|
|
783
804
|
}
|
|
784
805
|
this.ctx.lastAssistantUsage = usage;
|
|
785
806
|
}
|
|
786
|
-
this
|
|
787
|
-
|
|
807
|
+
this.ctx.streamingComponent.markTranscriptBlockFinalized();
|
|
808
|
+
let lastPostToolAssistantComponent: AssistantMessageComponent | undefined;
|
|
809
|
+
for (const [toolCallId, segment] of displayTimeline.afterToolCalls) {
|
|
810
|
+
const component = this.#appendPostToolAssistantSegment(toolCallId, segment);
|
|
811
|
+
if (component) lastPostToolAssistantComponent = component;
|
|
812
|
+
}
|
|
813
|
+
this.#lastAssistantComponent = lastPostToolAssistantComponent ?? this.ctx.streamingComponent;
|
|
788
814
|
if (settings.get("display.showTokenUsage")) {
|
|
789
815
|
this.ctx.chatContainer.addChild(
|
|
790
816
|
createUsageRowBlock(event.message.usage, event.message.duration, event.message.ttft),
|
|
@@ -821,6 +847,7 @@ export class EventController {
|
|
|
821
847
|
const group = this.#getReadGroup();
|
|
822
848
|
group.updateArgs(event.args, event.toolCallId);
|
|
823
849
|
this.ctx.pendingTools.set(event.toolCallId, group);
|
|
850
|
+
this.#toolTimelineComponents.set(event.toolCallId, group);
|
|
824
851
|
}
|
|
825
852
|
this.ctx.ui.requestRender();
|
|
826
853
|
return;
|
|
@@ -846,6 +873,7 @@ export class EventController {
|
|
|
846
873
|
component.setExpanded(this.ctx.toolOutputExpanded);
|
|
847
874
|
this.ctx.chatContainer.addChild(component);
|
|
848
875
|
this.ctx.pendingTools.set(event.toolCallId, component);
|
|
876
|
+
this.#toolTimelineComponents.set(event.toolCallId, component);
|
|
849
877
|
this.ctx.ui.requestRender();
|
|
850
878
|
} else {
|
|
851
879
|
// The tool is about to run, so its arguments are final and validated.
|
|
@@ -918,6 +946,7 @@ export class EventController {
|
|
|
918
946
|
}
|
|
919
947
|
component = group;
|
|
920
948
|
this.ctx.pendingTools.set(event.toolCallId, group);
|
|
949
|
+
this.#toolTimelineComponents.set(event.toolCallId, group);
|
|
921
950
|
}
|
|
922
951
|
component.updateResult({ ...event.result, isError: event.isError }, false, event.toolCallId);
|
|
923
952
|
this.ctx.pendingTools.delete(event.toolCallId);
|
|
@@ -75,6 +75,7 @@ export class RpcHostToolBridge {
|
|
|
75
75
|
#output: RpcHostToolOutput;
|
|
76
76
|
#definitions = new Map<string, RpcHostToolDefinition>();
|
|
77
77
|
#pendingCalls = new Map<string, PendingHostToolCall>();
|
|
78
|
+
#closedError: Error | undefined;
|
|
78
79
|
|
|
79
80
|
constructor(output: RpcHostToolOutput) {
|
|
80
81
|
this.#output = output;
|
|
@@ -125,6 +126,9 @@ export class RpcHostToolBridge {
|
|
|
125
126
|
if (signal?.aborted) {
|
|
126
127
|
return Promise.reject(new Error(`Host tool "${definition.name}" was aborted`));
|
|
127
128
|
}
|
|
129
|
+
if (this.#closedError) {
|
|
130
|
+
return Promise.reject(this.#closedError);
|
|
131
|
+
}
|
|
128
132
|
|
|
129
133
|
const id = Snowflake.next() as string;
|
|
130
134
|
const { promise, resolve, reject } = Promise.withResolvers<AgentToolResult<unknown>>();
|
|
@@ -183,4 +187,13 @@ export class RpcHostToolBridge {
|
|
|
183
187
|
pending.reject(error);
|
|
184
188
|
}
|
|
185
189
|
}
|
|
190
|
+
/** Reject active and future host tool requests after the RPC client disconnects. */
|
|
191
|
+
close(message: string): void {
|
|
192
|
+
if (!this.#closedError) this.#closedError = new Error(message);
|
|
193
|
+
const pendingCalls = Array.from(this.#pendingCalls.values());
|
|
194
|
+
this.#pendingCalls.clear();
|
|
195
|
+
for (const pending of pendingCalls) {
|
|
196
|
+
pending.reject(this.#closedError);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
186
199
|
}
|
|
@@ -58,6 +58,28 @@ export type PendingExtensionRequest = {
|
|
|
58
58
|
resolve: (response: RpcExtensionUIResponse) => void;
|
|
59
59
|
reject: (error: Error) => void;
|
|
60
60
|
};
|
|
61
|
+
/** Pending extension UI request map that can fail closed when the RPC client disconnects. */
|
|
62
|
+
export class RpcPendingExtensionRequests extends Map<string, PendingExtensionRequest> {
|
|
63
|
+
#closedError: Error | undefined;
|
|
64
|
+
|
|
65
|
+
override set(id: string, request: PendingExtensionRequest): this {
|
|
66
|
+
if (this.#closedError) {
|
|
67
|
+
request.reject(this.#closedError);
|
|
68
|
+
return this;
|
|
69
|
+
}
|
|
70
|
+
return super.set(id, request);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Reject every active and future extension UI request. */
|
|
74
|
+
rejectAll(message: string): void {
|
|
75
|
+
if (!this.#closedError) this.#closedError = new Error(message);
|
|
76
|
+
const requests = Array.from(this.values());
|
|
77
|
+
this.clear();
|
|
78
|
+
for (const request of requests) {
|
|
79
|
+
request.reject(this.#closedError);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
61
83
|
|
|
62
84
|
type RpcOutput = (
|
|
63
85
|
obj:
|
|
@@ -88,6 +110,7 @@ export type RpcSkillCommandResult = { agentInvoked: true };
|
|
|
88
110
|
export async function tryRunRpcSkillCommand(
|
|
89
111
|
session: RpcSkillCommandSession,
|
|
90
112
|
text: string,
|
|
113
|
+
streamingBehavior: "steer" | "followUp" = "steer",
|
|
91
114
|
): Promise<RpcSkillCommandResult | false> {
|
|
92
115
|
if (!session.skillsSettings?.enableSkillCommands) return false;
|
|
93
116
|
const parsed = parseSkillInvocation(text);
|
|
@@ -95,13 +118,16 @@ export async function tryRunRpcSkillCommand(
|
|
|
95
118
|
const skill = session.skills.find(candidate => candidate.name === parsed.name);
|
|
96
119
|
if (!skill) return false;
|
|
97
120
|
const built = await buildSkillPromptMessage(skill, parsed.args, "user");
|
|
98
|
-
await session.promptCustomMessage(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
121
|
+
await session.promptCustomMessage(
|
|
122
|
+
{
|
|
123
|
+
customType: SKILL_PROMPT_MESSAGE_TYPE,
|
|
124
|
+
content: built.message,
|
|
125
|
+
display: true,
|
|
126
|
+
details: built.details,
|
|
127
|
+
attribution: "user",
|
|
128
|
+
},
|
|
129
|
+
{ streamingBehavior },
|
|
130
|
+
);
|
|
105
131
|
return { agentInvoked: true };
|
|
106
132
|
}
|
|
107
133
|
|
|
@@ -242,45 +268,50 @@ function isRpcExtensionUIResponse(value: unknown): value is RpcExtensionUIRespon
|
|
|
242
268
|
return value.type === "extension_ui_response" && typeof value.id === "string";
|
|
243
269
|
}
|
|
244
270
|
|
|
245
|
-
/**
|
|
246
|
-
|
|
247
|
-
*
|
|
248
|
-
* Bash commands are dispatched in the background so the caller (the stdin loop
|
|
249
|
-
* in {@link runRpcMode}) can keep reading subsequent frames while a shell
|
|
250
|
-
* command is still running. This lets a client send `abort_bash` (or any other
|
|
251
|
-
* command) while a long-running `bash` is in flight. Response correlation is
|
|
252
|
-
* preserved via each command's `id`; ordering across concurrent commands is
|
|
253
|
-
* not guaranteed and clients MUST match on `id`.
|
|
254
|
-
*
|
|
255
|
-
* @returns `undefined` when the frame was routed to a side-channel handler
|
|
256
|
-
* (extension UI response, host tool/URI frames) or dispatched in the
|
|
257
|
-
* background (`bash`). Otherwise a promise that resolves once the response
|
|
258
|
-
* for the command has been emitted via `output`. Errors from `handleCommand`
|
|
259
|
-
* on non-`bash` commands propagate; the caller is expected to wrap them.
|
|
260
|
-
*/
|
|
261
|
-
export function dispatchRpcInputFrame(parsed: unknown, deps: RpcInputFrameDeps): Promise<void> | undefined {
|
|
262
|
-
// Side-channel: extension UI responses resolve a pending dialog promise.
|
|
271
|
+
/** Dispatch side-channel frames that must overtake the serialized command queue. */
|
|
272
|
+
export function dispatchRpcControlFrame(parsed: unknown, deps: RpcInputFrameDeps): boolean {
|
|
263
273
|
if (isRpcExtensionUIResponse(parsed)) {
|
|
264
274
|
const pending = deps.pendingExtensionRequests.get(parsed.id);
|
|
265
275
|
if (pending) pending.resolve(parsed);
|
|
266
|
-
return
|
|
276
|
+
return true;
|
|
267
277
|
}
|
|
268
278
|
|
|
269
279
|
if (isRpcHostToolResult(parsed)) {
|
|
270
280
|
deps.onHostToolResult(parsed);
|
|
271
|
-
return
|
|
281
|
+
return true;
|
|
272
282
|
}
|
|
273
283
|
|
|
274
284
|
if (isRpcHostToolUpdate(parsed)) {
|
|
275
285
|
deps.onHostToolUpdate(parsed);
|
|
276
|
-
return
|
|
286
|
+
return true;
|
|
277
287
|
}
|
|
278
288
|
|
|
279
289
|
if (isRpcHostUriResult(parsed)) {
|
|
280
290
|
deps.onHostUriResult(parsed);
|
|
281
|
-
return
|
|
291
|
+
return true;
|
|
282
292
|
}
|
|
283
293
|
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Dispatch a single parsed frame from the RPC input stream.
|
|
299
|
+
*
|
|
300
|
+
* Bash commands are dispatched in the background so the caller can keep reading
|
|
301
|
+
* subsequent frames while a shell command is still running. This lets a client
|
|
302
|
+
* send `abort_bash` while a long-running `bash` is in flight. Response
|
|
303
|
+
* correlation is preserved via each command's `id`; ordering across concurrent
|
|
304
|
+
* commands is not guaranteed and clients MUST match on `id`.
|
|
305
|
+
*
|
|
306
|
+
* @returns `undefined` when the frame was routed to a side-channel handler
|
|
307
|
+
* (extension UI response, host tool/URI frames) or dispatched in the
|
|
308
|
+
* background (`bash`). Otherwise a promise that resolves once the response
|
|
309
|
+
* for the command has been emitted via `output`. Errors from `handleCommand`
|
|
310
|
+
* on non-`bash` commands propagate; the caller is expected to wrap them.
|
|
311
|
+
*/
|
|
312
|
+
export function dispatchRpcInputFrame(parsed: unknown, deps: RpcInputFrameDeps): Promise<void> | undefined {
|
|
313
|
+
if (dispatchRpcControlFrame(parsed, deps)) return undefined;
|
|
314
|
+
|
|
284
315
|
// Regular RPC command. The transport contract states each remaining frame
|
|
285
316
|
// is an {@link RpcCommand}; `handleCommand`'s `default` arm surfaces
|
|
286
317
|
// unknown discriminants as an error response, so we do not shape-check
|
|
@@ -309,6 +340,59 @@ export function dispatchRpcInputFrame(parsed: unknown, deps: RpcInputFrameDeps):
|
|
|
309
340
|
})();
|
|
310
341
|
}
|
|
311
342
|
|
|
343
|
+
/** Serializes ordinary RPC commands while allowing control frames to dispatch immediately. */
|
|
344
|
+
export class RpcInputDispatcher {
|
|
345
|
+
#tail: Promise<void> = Promise.resolve();
|
|
346
|
+
#tasks = new Set<Promise<void>>();
|
|
347
|
+
readonly #deps: RpcInputFrameDeps;
|
|
348
|
+
readonly #afterSerialCommand: (() => Promise<void>) | undefined;
|
|
349
|
+
|
|
350
|
+
constructor(options: { deps: RpcInputFrameDeps; afterSerialCommand?: () => Promise<void> }) {
|
|
351
|
+
this.#deps = options.deps;
|
|
352
|
+
this.#afterSerialCommand = options.afterSerialCommand;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Accept a parsed input frame without blocking the stdin reader. */
|
|
356
|
+
dispatch(parsed: unknown): void {
|
|
357
|
+
if (dispatchRpcControlFrame(parsed, this.#deps)) return;
|
|
358
|
+
|
|
359
|
+
const command = parsed as RpcCommand;
|
|
360
|
+
if (command.type === "bash") {
|
|
361
|
+
dispatchRpcInputFrame(command, this.#deps);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const task = this.#tail.then(
|
|
366
|
+
() => this.#dispatchSerialCommand(command),
|
|
367
|
+
() => this.#dispatchSerialCommand(command),
|
|
368
|
+
);
|
|
369
|
+
this.#tail = task.catch(() => {});
|
|
370
|
+
this.#tasks.add(task);
|
|
371
|
+
void task.finally(() => {
|
|
372
|
+
this.#tasks.delete(task);
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Await every accepted serial command, including commands queued before EOF. */
|
|
377
|
+
async drain(): Promise<void> {
|
|
378
|
+
while (this.#tasks.size > 0) {
|
|
379
|
+
await Promise.allSettled(Array.from(this.#tasks));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async #dispatchSerialCommand(command: RpcCommand): Promise<void> {
|
|
384
|
+
try {
|
|
385
|
+
const awaited = dispatchRpcInputFrame(command, this.#deps);
|
|
386
|
+
if (awaited) await awaited;
|
|
387
|
+
} catch (err: unknown) {
|
|
388
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
389
|
+
this.#deps.output(this.#deps.errorResponse(command.id, command.type, message));
|
|
390
|
+
} finally {
|
|
391
|
+
await this.#afterSerialCommand?.();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
312
396
|
/**
|
|
313
397
|
* Coordinates deferred shutdown with in-flight background input tasks.
|
|
314
398
|
*
|
|
@@ -547,7 +631,7 @@ export async function runRpcMode(
|
|
|
547
631
|
|
|
548
632
|
const extensionUserMessageTracker = new RpcExtensionUserMessageTracker();
|
|
549
633
|
|
|
550
|
-
const pendingExtensionRequests = new
|
|
634
|
+
const pendingExtensionRequests = new RpcPendingExtensionRequests();
|
|
551
635
|
const hostToolBridge = new RpcHostToolBridge(output);
|
|
552
636
|
const hostUriBridge = new RpcHostUriBridge(output);
|
|
553
637
|
const subagentRegistry = eventBus ? new RpcSubagentRegistry(eventBus, output) : undefined;
|
|
@@ -842,7 +926,7 @@ export async function runRpcMode(
|
|
|
842
926
|
// =================================================================
|
|
843
927
|
|
|
844
928
|
case "prompt": {
|
|
845
|
-
const skillResult = await tryRunRpcSkillCommand(session, command.message);
|
|
929
|
+
const skillResult = await tryRunRpcSkillCommand(session, command.message, command.streamingBehavior);
|
|
846
930
|
if (skillResult) {
|
|
847
931
|
return success(id, "prompt", skillResult);
|
|
848
932
|
}
|
|
@@ -1276,35 +1360,25 @@ export async function runRpcMode(
|
|
|
1276
1360
|
onHostUriResult: frame => hostUriBridge.handleResult(frame),
|
|
1277
1361
|
};
|
|
1278
1362
|
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1363
|
+
const inputDispatcher = new RpcInputDispatcher({
|
|
1364
|
+
deps: dispatchFrameDeps,
|
|
1365
|
+
afterSerialCommand: () => shutdownCoordinator.checkShutdownRequested(),
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
// Keep the stdin reader moving: side-channel frames dispatch immediately,
|
|
1369
|
+
// ordinary commands serialize through inputDispatcher, and bash remains
|
|
1370
|
+
// background-dispatched so abort_bash can overtake it.
|
|
1282
1371
|
for await (const parsed of readJsonl(Bun.stdin.stream())) {
|
|
1283
|
-
|
|
1284
|
-
const awaited = dispatchRpcInputFrame(parsed, dispatchFrameDeps);
|
|
1285
|
-
if (awaited) {
|
|
1286
|
-
await awaited;
|
|
1287
|
-
// Check for deferred shutdown request (idle between commands).
|
|
1288
|
-
// Background-dispatched bash frames skip this check so a later
|
|
1289
|
-
// abort_bash can still be read; the coordinator re-checks when
|
|
1290
|
-
// each tracked task settles, so a shutdown requested mid-bash
|
|
1291
|
-
// fires once the response frame is written even if no further
|
|
1292
|
-
// client frames arrive.
|
|
1293
|
-
await shutdownCoordinator.checkShutdownRequested();
|
|
1294
|
-
}
|
|
1295
|
-
} catch (e: unknown) {
|
|
1296
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
1297
|
-
output(error(undefined, "parse", `Failed to parse command: ${message}`));
|
|
1298
|
-
}
|
|
1372
|
+
inputDispatcher.dispatch(parsed);
|
|
1299
1373
|
}
|
|
1300
1374
|
|
|
1301
|
-
//
|
|
1302
|
-
//
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
// stdin closed — RPC client is gone, exit cleanly
|
|
1306
|
-
hostToolBridge.rejectAllPending("RPC client disconnected before host tool execution completed");
|
|
1375
|
+
// stdin closed — RPC client is gone. Fail pending side-channel requests
|
|
1376
|
+
// first so active/queued commands can settle, then drain accepted work.
|
|
1377
|
+
pendingExtensionRequests.rejectAll("RPC client disconnected before extension UI response completed");
|
|
1378
|
+
hostToolBridge.close("RPC client disconnected before host tool execution completed");
|
|
1307
1379
|
hostUriBridge.clear("RPC client disconnected before host URI request completed");
|
|
1380
|
+
await inputDispatcher.drain();
|
|
1381
|
+
await shutdownCoordinator.drain();
|
|
1308
1382
|
subagentRegistry?.dispose();
|
|
1309
1383
|
process.exit(0);
|
|
1310
1384
|
}
|
|
@@ -130,6 +130,50 @@ export function assistantHasVisibleContent(message: AssistantAgentMessage): bool
|
|
|
130
130
|
);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Split mixed assistant turns into visible text before tool execution and
|
|
135
|
+
* visible text segments that must render immediately after the preceding tool.
|
|
136
|
+
*/
|
|
137
|
+
export function splitAssistantMessageToolTimeline(message: AssistantAgentMessage): {
|
|
138
|
+
beforeTools: AssistantAgentMessage;
|
|
139
|
+
afterToolCalls: ReadonlyMap<string, AssistantAgentMessage>;
|
|
140
|
+
hasToolCalls: boolean;
|
|
141
|
+
} {
|
|
142
|
+
const beforeTools: AssistantAgentMessage["content"] = [];
|
|
143
|
+
const afterToolCalls = new Map<string, AssistantAgentMessage>();
|
|
144
|
+
let pendingAfterTool: AssistantAgentMessage["content"] = [];
|
|
145
|
+
let lastToolCallId: string | undefined;
|
|
146
|
+
let sawToolCall = false;
|
|
147
|
+
|
|
148
|
+
const displaySegment = (content: AssistantAgentMessage["content"]): AssistantAgentMessage => ({
|
|
149
|
+
...message,
|
|
150
|
+
content,
|
|
151
|
+
stopReason: "stop",
|
|
152
|
+
errorMessage: undefined,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const flushPendingAfterTool = () => {
|
|
156
|
+
if (!lastToolCallId || pendingAfterTool.length === 0) return;
|
|
157
|
+
afterToolCalls.set(lastToolCallId, displaySegment(pendingAfterTool));
|
|
158
|
+
pendingAfterTool = [];
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
for (const content of message.content) {
|
|
162
|
+
if (content.type === "toolCall") {
|
|
163
|
+
flushPendingAfterTool();
|
|
164
|
+
sawToolCall = true;
|
|
165
|
+
lastToolCallId = content.id;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (sawToolCall) pendingAfterTool.push(content);
|
|
169
|
+
else beforeTools.push(content);
|
|
170
|
+
}
|
|
171
|
+
flushPendingAfterTool();
|
|
172
|
+
|
|
173
|
+
if (!sawToolCall) return { beforeTools: message, afterToolCalls, hasToolCalls: false };
|
|
174
|
+
return { beforeTools: displaySegment(beforeTools), afterToolCalls, hasToolCalls: true };
|
|
175
|
+
}
|
|
176
|
+
|
|
133
177
|
/**
|
|
134
178
|
* Normalize raw tool-call arguments to a plain record, collapsing non-object or
|
|
135
179
|
* array values to an empty object.
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
buildIrcMessageCard,
|
|
57
57
|
normalizeToolArgs,
|
|
58
58
|
resolveAssistantErrorMessage,
|
|
59
|
+
splitAssistantMessageToolTimeline,
|
|
59
60
|
} from "./transcript-render-helpers";
|
|
60
61
|
|
|
61
62
|
type TextBlock = { type: "text"; text: string };
|
|
@@ -350,7 +351,8 @@ export class UiHelpers {
|
|
|
350
351
|
if (message.role !== "toolResult") flushPendingUsage();
|
|
351
352
|
// Assistant messages need special handling for tool calls
|
|
352
353
|
if (message.role === "assistant") {
|
|
353
|
-
|
|
354
|
+
const timeline = splitAssistantMessageToolTimeline(message);
|
|
355
|
+
this.ctx.addMessageToChat(timeline.beforeTools);
|
|
354
356
|
const lastChild = this.ctx.chatContainer.children[this.ctx.chatContainer.children.length - 1];
|
|
355
357
|
const assistantComponent = lastChild instanceof AssistantMessageComponent ? lastChild : undefined;
|
|
356
358
|
if (assistantComponent) {
|
|
@@ -384,6 +386,7 @@ export class UiHelpers {
|
|
|
384
386
|
continue;
|
|
385
387
|
}
|
|
386
388
|
resolveWaitingPoll(content.name);
|
|
389
|
+
const afterToolSegment = timeline.afterToolCalls.get(content.id);
|
|
387
390
|
|
|
388
391
|
if (
|
|
389
392
|
content.name === "read" &&
|
|
@@ -404,6 +407,16 @@ export class UiHelpers {
|
|
|
404
407
|
false,
|
|
405
408
|
content.id,
|
|
406
409
|
);
|
|
410
|
+
} else if (afterToolSegment) {
|
|
411
|
+
if (!readGroup) {
|
|
412
|
+
readGroup = new ReadToolGroupComponent({
|
|
413
|
+
showContentPreview: this.ctx.settings.get("read.toolResultPreview"),
|
|
414
|
+
});
|
|
415
|
+
readGroup.setExpanded(this.ctx.toolOutputExpanded);
|
|
416
|
+
this.ctx.chatContainer.addChild(readGroup);
|
|
417
|
+
}
|
|
418
|
+
readGroup.updateArgs(content.arguments, content.id);
|
|
419
|
+
this.ctx.pendingTools.set(content.id, readGroup);
|
|
407
420
|
} else {
|
|
408
421
|
const normalizedArgs = normalizeToolArgs(content.arguments);
|
|
409
422
|
readToolCallArgs.set(content.id, normalizedArgs);
|
|
@@ -411,6 +424,7 @@ export class UiHelpers {
|
|
|
411
424
|
readToolCallAssistantComponents.set(content.id, assistantComponent);
|
|
412
425
|
}
|
|
413
426
|
}
|
|
427
|
+
if (afterToolSegment) this.ctx.addMessageToChat(afterToolSegment);
|
|
414
428
|
continue;
|
|
415
429
|
}
|
|
416
430
|
|
|
@@ -458,6 +472,7 @@ export class UiHelpers {
|
|
|
458
472
|
} else {
|
|
459
473
|
this.ctx.pendingTools.set(content.id, component);
|
|
460
474
|
}
|
|
475
|
+
if (afterToolSegment) this.ctx.addMessageToChat(afterToolSegment);
|
|
461
476
|
}
|
|
462
477
|
pendingUsage = this.ctx.settings.get("display.showTokenUsage") ? message.usage : undefined;
|
|
463
478
|
pendingUsageDuration = message.duration;
|