Skip to content

Commit 9de6abd

Browse files
adele-with-a-bobviyus
authored andcommitted
fix(agents): bridge CLI tool progress events
1 parent c7f5073 commit 9de6abd

9 files changed

Lines changed: 889 additions & 8 deletions

src/agents/cli-output.test.ts

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
extractCliErrorMessage,
55
parseCliJson,
66
parseCliJsonl,
7+
type CliToolUseStartDelta,
78
} from "./cli-output.js";
89
import { createClaudeApiErrorFixture } from "./test-helpers/claude-api-error-fixture.js";
910

@@ -537,4 +538,331 @@ describe("createCliJsonlStreamingParser", () => {
537538
total: undefined,
538539
});
539540
});
541+
542+
it("surfaces Claude tool_use start and result events", () => {
543+
const starts: CliToolUseStartDelta[] = [];
544+
const results: Array<{ toolCallId: string; name: string; isError: boolean; result?: unknown }> =
545+
[];
546+
const parser = createCliJsonlStreamingParser({
547+
backend: {
548+
command: "local-cli",
549+
output: "jsonl",
550+
jsonlDialect: "claude-stream-json",
551+
sessionIdFields: ["session_id"],
552+
},
553+
providerId: "claude-cli",
554+
onAssistantDelta: () => undefined,
555+
onToolUseStart: (delta) => starts.push(delta),
556+
onToolResult: (delta) => results.push(delta),
557+
});
558+
559+
parser.push(
560+
[
561+
JSON.stringify({
562+
type: "assistant",
563+
message: {
564+
role: "assistant",
565+
content: [
566+
{ type: "tool_use", id: "toolu_1", name: "Bash", input: { command: "ls -la" } },
567+
],
568+
},
569+
}),
570+
JSON.stringify({
571+
type: "user",
572+
message: {
573+
role: "user",
574+
content: [
575+
{
576+
type: "tool_result",
577+
tool_use_id: "toolu_1",
578+
content: "total 0\n",
579+
is_error: false,
580+
},
581+
],
582+
},
583+
}),
584+
].join("\n") + "\n",
585+
);
586+
parser.finish();
587+
588+
expect(starts).toEqual([{ toolCallId: "toolu_1", name: "Bash", args: { command: "ls -la" } }]);
589+
expect(results).toEqual([
590+
{ toolCallId: "toolu_1", name: "Bash", isError: false, result: "total 0\n" },
591+
]);
592+
});
593+
594+
it("reassembles streamed tool args from input_json_delta chunks", () => {
595+
const starts: CliToolUseStartDelta[] = [];
596+
const parser = createCliJsonlStreamingParser({
597+
backend: {
598+
command: "local-cli",
599+
output: "jsonl",
600+
jsonlDialect: "claude-stream-json",
601+
sessionIdFields: ["session_id"],
602+
},
603+
providerId: "claude-cli",
604+
onAssistantDelta: () => undefined,
605+
onToolUseStart: (delta) => starts.push(delta),
606+
});
607+
608+
parser.push(
609+
[
610+
JSON.stringify({
611+
type: "stream_event",
612+
event: {
613+
type: "content_block_start",
614+
index: 0,
615+
content_block: { type: "tool_use", id: "toolu_chunked", name: "Bash", input: {} },
616+
},
617+
}),
618+
JSON.stringify({
619+
type: "stream_event",
620+
event: {
621+
type: "content_block_delta",
622+
index: 0,
623+
delta: { type: "input_json_delta", partial_json: '{"command":' },
624+
},
625+
}),
626+
JSON.stringify({
627+
type: "stream_event",
628+
event: {
629+
type: "content_block_delta",
630+
index: 0,
631+
delta: { type: "input_json_delta", partial_json: ' "echo hi"}' },
632+
},
633+
}),
634+
JSON.stringify({
635+
type: "stream_event",
636+
event: { type: "content_block_stop", index: 0 },
637+
}),
638+
].join("\n") + "\n",
639+
);
640+
parser.finish();
641+
642+
expect(starts).toEqual([
643+
{ toolCallId: "toolu_chunked", name: "Bash", args: { command: "echo hi" } },
644+
]);
645+
});
646+
647+
it("emits empty args when streamed tool args are malformed", () => {
648+
const starts: CliToolUseStartDelta[] = [];
649+
const parser = createCliJsonlStreamingParser({
650+
backend: {
651+
command: "local-cli",
652+
output: "jsonl",
653+
jsonlDialect: "claude-stream-json",
654+
sessionIdFields: ["session_id"],
655+
},
656+
providerId: "claude-cli",
657+
onAssistantDelta: () => undefined,
658+
onToolUseStart: (delta) => starts.push(delta),
659+
});
660+
661+
parser.push(
662+
[
663+
JSON.stringify({
664+
type: "stream_event",
665+
event: {
666+
type: "content_block_start",
667+
index: 0,
668+
content_block: { type: "tool_use", id: "toolu_bad", name: "Bash", input: {} },
669+
},
670+
}),
671+
JSON.stringify({
672+
type: "stream_event",
673+
event: {
674+
type: "content_block_delta",
675+
index: 0,
676+
delta: { type: "input_json_delta", partial_json: '{"command": "ls' },
677+
},
678+
}),
679+
JSON.stringify({
680+
type: "stream_event",
681+
event: { type: "content_block_stop", index: 0 },
682+
}),
683+
].join("\n") + "\n",
684+
);
685+
parser.finish();
686+
687+
expect(starts).toEqual([{ toolCallId: "toolu_bad", name: "Bash", args: {} }]);
688+
});
689+
690+
it.each(["server_tool_use", "mcp_tool_use"])("recognizes %s blocks", (type) => {
691+
const starts: CliToolUseStartDelta[] = [];
692+
const parser = createCliJsonlStreamingParser({
693+
backend: {
694+
command: "local-cli",
695+
output: "jsonl",
696+
jsonlDialect: "claude-stream-json",
697+
sessionIdFields: ["session_id"],
698+
},
699+
providerId: "claude-cli",
700+
onAssistantDelta: () => undefined,
701+
onToolUseStart: (delta) => starts.push(delta),
702+
});
703+
704+
parser.push(
705+
[
706+
JSON.stringify({
707+
type: "stream_event",
708+
event: {
709+
type: "content_block_start",
710+
index: 0,
711+
content_block: { type, id: "toolu_hosted", name: "web_search", input: {} },
712+
},
713+
}),
714+
JSON.stringify({
715+
type: "stream_event",
716+
event: {
717+
type: "content_block_delta",
718+
index: 0,
719+
delta: { type: "input_json_delta", partial_json: '{"query":"openclaw"}' },
720+
},
721+
}),
722+
JSON.stringify({
723+
type: "stream_event",
724+
event: { type: "content_block_stop", index: 0 },
725+
}),
726+
].join("\n") + "\n",
727+
);
728+
parser.finish();
729+
730+
expect(starts).toEqual([
731+
{ toolCallId: "toolu_hosted", name: "web_search", args: { query: "openclaw" } },
732+
]);
733+
});
734+
735+
it.each([
736+
{
737+
useType: "server_tool_use",
738+
resultType: "web_search_tool_result",
739+
toolCallId: "srvtoolu_1",
740+
name: "web_search",
741+
input: { query: "openclaw" },
742+
result: [{ type: "web_search_result", title: "OpenClaw", url: "https://example.com" }],
743+
isError: false,
744+
},
745+
{
746+
useType: "mcp_tool_use",
747+
resultType: "mcp_tool_result",
748+
toolCallId: "mcptoolu_1",
749+
name: "echo",
750+
input: { value: "hello" },
751+
result: [{ type: "text", text: "hello" }],
752+
isError: false,
753+
},
754+
])("emits hosted result events for $useType", (fixture) => {
755+
const starts: CliToolUseStartDelta[] = [];
756+
const results: Array<{ toolCallId: string; name: string; isError: boolean; result?: unknown }> =
757+
[];
758+
const parser = createCliJsonlStreamingParser({
759+
backend: {
760+
command: "local-cli",
761+
output: "jsonl",
762+
jsonlDialect: "claude-stream-json",
763+
sessionIdFields: ["session_id"],
764+
},
765+
providerId: "claude-cli",
766+
onAssistantDelta: () => undefined,
767+
onToolUseStart: (delta) => starts.push(delta),
768+
onToolResult: (delta) => results.push(delta),
769+
});
770+
771+
parser.push(
772+
[
773+
JSON.stringify({
774+
type: "assistant",
775+
message: {
776+
role: "assistant",
777+
content: [
778+
{
779+
type: fixture.useType,
780+
id: fixture.toolCallId,
781+
name: fixture.name,
782+
input: fixture.input,
783+
},
784+
{
785+
type: fixture.resultType,
786+
tool_use_id: fixture.toolCallId,
787+
content: fixture.result,
788+
is_error: fixture.isError,
789+
},
790+
],
791+
},
792+
}),
793+
].join("\n") + "\n",
794+
);
795+
parser.finish();
796+
797+
expect(starts).toEqual([
798+
{ toolCallId: fixture.toolCallId, name: fixture.name, args: fixture.input },
799+
]);
800+
expect(results).toEqual([
801+
{
802+
toolCallId: fixture.toolCallId,
803+
name: fixture.name,
804+
isError: fixture.isError,
805+
result: fixture.result,
806+
},
807+
]);
808+
});
809+
810+
it("emits streamed server tool result blocks", () => {
811+
const results: Array<{ toolCallId: string; name: string; isError: boolean; result?: unknown }> =
812+
[];
813+
const parser = createCliJsonlStreamingParser({
814+
backend: {
815+
command: "local-cli",
816+
output: "jsonl",
817+
jsonlDialect: "claude-stream-json",
818+
sessionIdFields: ["session_id"],
819+
},
820+
providerId: "claude-cli",
821+
onAssistantDelta: () => undefined,
822+
onToolUseStart: () => undefined,
823+
onToolResult: (delta) => results.push(delta),
824+
});
825+
826+
parser.push(
827+
[
828+
JSON.stringify({
829+
type: "stream_event",
830+
event: {
831+
type: "content_block_start",
832+
index: 0,
833+
content_block: { type: "server_tool_use", id: "srvtoolu_stream", name: "web_search" },
834+
},
835+
}),
836+
JSON.stringify({
837+
type: "stream_event",
838+
event: {
839+
type: "content_block_stop",
840+
index: 0,
841+
},
842+
}),
843+
JSON.stringify({
844+
type: "stream_event",
845+
event: {
846+
type: "content_block_start",
847+
index: 1,
848+
content_block: {
849+
type: "web_search_tool_result",
850+
tool_use_id: "srvtoolu_stream",
851+
content: { type: "web_search_tool_result_error", error_code: "unavailable" },
852+
},
853+
},
854+
}),
855+
].join("\n") + "\n",
856+
);
857+
parser.finish();
858+
859+
expect(results).toEqual([
860+
{
861+
toolCallId: "srvtoolu_stream",
862+
name: "web_search",
863+
isError: true,
864+
result: { type: "web_search_tool_result_error", error_code: "unavailable" },
865+
},
866+
]);
867+
});
540868
});

0 commit comments

Comments
 (0)