@posthog/agent 2.3.735 → 2.3.736
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/adapters/claude/conversion/tool-use-to-acp.d.ts +2 -10
- package/dist/adapters/claude/conversion/tool-use-to-acp.js +24 -10
- package/dist/adapters/claude/conversion/tool-use-to-acp.js.map +1 -1
- package/dist/adapters/claude/permissions/permission-options.js +6 -4
- package/dist/adapters/claude/permissions/permission-options.js.map +1 -1
- package/dist/adapters/claude/session/jsonl-hydration.js +1 -0
- package/dist/adapters/claude/session/jsonl-hydration.js.map +1 -1
- package/dist/adapters/claude/session/models.js +20 -1
- package/dist/adapters/claude/session/models.js.map +1 -1
- package/dist/adapters/claude/tools.js +4 -2
- package/dist/adapters/claude/tools.js.map +1 -1
- package/dist/adapters/reasoning-effort.js +3 -1
- package/dist/adapters/reasoning-effort.js.map +1 -1
- package/dist/agent.js +524 -72
- package/dist/agent.js.map +1 -1
- package/dist/posthog-api.js +4 -4
- package/dist/posthog-api.js.map +1 -1
- package/dist/server/agent-server.js +524 -72
- package/dist/server/agent-server.js.map +1 -1
- package/dist/server/bin.cjs +524 -72
- package/dist/server/bin.cjs.map +1 -1
- package/package.json +6 -6
- package/src/adapters/claude/UPSTREAM.md +80 -3
- package/src/adapters/claude/claude-agent.ts +201 -18
- package/src/adapters/claude/conversion/sdk-to-acp.ts +182 -16
- package/src/adapters/claude/conversion/task-state.test.ts +338 -0
- package/src/adapters/claude/conversion/task-state.ts +178 -0
- package/src/adapters/claude/conversion/tool-use-to-acp.ts +29 -19
- package/src/adapters/claude/hooks.test.ts +162 -0
- package/src/adapters/claude/hooks.ts +44 -3
- package/src/adapters/claude/permissions/permission-options.ts +7 -2
- package/src/adapters/claude/session/commands.ts +1 -0
- package/src/adapters/claude/session/mcp-config.ts +23 -7
- package/src/adapters/claude/session/model-config.test.ts +60 -0
- package/src/adapters/claude/session/model-config.ts +56 -0
- package/src/adapters/claude/session/models.test.ts +119 -0
- package/src/adapters/claude/session/models.ts +31 -0
- package/src/adapters/claude/session/options.test.ts +1 -0
- package/src/adapters/claude/session/options.ts +29 -1
- package/src/adapters/claude/session/settings.test.ts +102 -1
- package/src/adapters/claude/session/settings.ts +33 -6
- package/src/adapters/claude/tools.ts +4 -2
- package/src/adapters/claude/types.ts +8 -0
- package/src/adapters/codex/codex-agent.ts +2 -2
- package/src/server/agent-server.test.ts +173 -0
- package/src/test/mocks/claude-sdk.ts +4 -0
- package/src/test/native-binary.test.ts +27 -0
- package/dist/claude-cli/cli.js +0 -18412
- package/dist/claude-cli/vendor/audio-capture/arm64-darwin/audio-capture.node +0 -0
- package/dist/claude-cli/vendor/audio-capture/arm64-linux/audio-capture.node +0 -0
- package/dist/claude-cli/vendor/audio-capture/arm64-win32/audio-capture.node +0 -0
- package/dist/claude-cli/vendor/audio-capture/x64-darwin/audio-capture.node +0 -0
- package/dist/claude-cli/vendor/audio-capture/x64-linux/audio-capture.node +0 -0
- package/dist/claude-cli/vendor/audio-capture/x64-win32/audio-capture.node +0 -0
- package/dist/claude-cli/vendor/ripgrep/COPYING +0 -3
- package/dist/claude-cli/vendor/ripgrep/arm64-darwin/rg +0 -0
- package/dist/claude-cli/vendor/ripgrep/arm64-linux/rg +0 -0
- package/dist/claude-cli/vendor/ripgrep/arm64-win32/rg.exe +0 -0
- package/dist/claude-cli/vendor/ripgrep/x64-darwin/rg +0 -0
- package/dist/claude-cli/vendor/ripgrep/x64-linux/rg +0 -0
- package/dist/claude-cli/vendor/ripgrep/x64-win32/rg.exe +0 -0
- package/dist/claude-cli/yoga.wasm +0 -0
|
@@ -12,6 +12,10 @@ import type {
|
|
|
12
12
|
SDKResultMessage,
|
|
13
13
|
SDKUserMessage,
|
|
14
14
|
} from "@anthropic-ai/claude-agent-sdk";
|
|
15
|
+
import type {
|
|
16
|
+
TaskCreateInput,
|
|
17
|
+
TaskUpdateInput,
|
|
18
|
+
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
|
15
19
|
import type { ContentBlockParam } from "@anthropic-ai/sdk/resources";
|
|
16
20
|
import type {
|
|
17
21
|
BetaContentBlock,
|
|
@@ -31,8 +35,13 @@ import type {
|
|
|
31
35
|
ToolUseStreamCache,
|
|
32
36
|
} from "../types";
|
|
33
37
|
import {
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
applyTaskCreate,
|
|
39
|
+
applyTaskUpdate,
|
|
40
|
+
parseTaskCreateOutput,
|
|
41
|
+
type TaskState,
|
|
42
|
+
taskStateToPlanEntries,
|
|
43
|
+
} from "./task-state";
|
|
44
|
+
import {
|
|
36
45
|
toolInfoFromToolUse,
|
|
37
46
|
toolUpdateFromEditToolResponse,
|
|
38
47
|
toolUpdateFromToolResult,
|
|
@@ -49,7 +58,6 @@ interface AnthropicMessageWithContent {
|
|
|
49
58
|
type: Role;
|
|
50
59
|
message: {
|
|
51
60
|
content: AnthropicMessageContent;
|
|
52
|
-
role?: Role;
|
|
53
61
|
model?: string;
|
|
54
62
|
};
|
|
55
63
|
}
|
|
@@ -67,6 +75,8 @@ type ChunkHandlerContext = {
|
|
|
67
75
|
cwd?: string;
|
|
68
76
|
/** Raw MCP tool result from SDKUserMessage.tool_use_result (contains content, structuredContent, _meta) */
|
|
69
77
|
mcpToolUseResult?: Record<string, unknown>;
|
|
78
|
+
/** Per-session task list (populated by createTaskHook + tool_result handler) */
|
|
79
|
+
taskState?: TaskState;
|
|
70
80
|
};
|
|
71
81
|
|
|
72
82
|
export interface MessageHandlerContext {
|
|
@@ -168,14 +178,14 @@ function handleToolUseChunk(
|
|
|
168
178
|
const alreadyCached = chunk.id in ctx.toolUseCache;
|
|
169
179
|
ctx.toolUseCache[chunk.id] = chunk;
|
|
170
180
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
181
|
+
// Suppress Task* tool_calls — plan updates are emitted from the matching
|
|
182
|
+
// tool_result handler instead, after taskState has been mutated.
|
|
183
|
+
if (
|
|
184
|
+
chunk.name === "TaskCreate" ||
|
|
185
|
+
chunk.name === "TaskUpdate" ||
|
|
186
|
+
chunk.name === "TaskList" ||
|
|
187
|
+
chunk.name === "TaskGet"
|
|
188
|
+
) {
|
|
179
189
|
return null;
|
|
180
190
|
}
|
|
181
191
|
|
|
@@ -185,7 +195,7 @@ function handleToolUseChunk(
|
|
|
185
195
|
const toolUse = ctx.toolUseCache[toolUseId];
|
|
186
196
|
if (toolUse) {
|
|
187
197
|
const editUpdate =
|
|
188
|
-
toolUse.name === "Edit"
|
|
198
|
+
toolUse.name === "Edit" || toolUse.name === "Write"
|
|
189
199
|
? toolUpdateFromEditToolResponse(toolResponse)
|
|
190
200
|
: null;
|
|
191
201
|
|
|
@@ -334,7 +344,33 @@ function handleToolResultChunk(
|
|
|
334
344
|
return [];
|
|
335
345
|
}
|
|
336
346
|
|
|
337
|
-
if (
|
|
347
|
+
if (
|
|
348
|
+
toolUse.name === "TaskCreate" ||
|
|
349
|
+
toolUse.name === "TaskUpdate" ||
|
|
350
|
+
toolUse.name === "TaskList" ||
|
|
351
|
+
toolUse.name === "TaskGet"
|
|
352
|
+
) {
|
|
353
|
+
if (chunk.is_error || !ctx.taskState) return [];
|
|
354
|
+
if (toolUse.name === "TaskCreate") {
|
|
355
|
+
applyTaskCreate(
|
|
356
|
+
ctx.taskState,
|
|
357
|
+
toolUse.input as TaskCreateInput | undefined,
|
|
358
|
+
parseTaskCreateOutput(chunk.content),
|
|
359
|
+
);
|
|
360
|
+
} else if (toolUse.name === "TaskUpdate") {
|
|
361
|
+
applyTaskUpdate(
|
|
362
|
+
ctx.taskState,
|
|
363
|
+
toolUse.input as TaskUpdateInput | undefined,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
if (toolUse.name === "TaskCreate" || toolUse.name === "TaskUpdate") {
|
|
367
|
+
return [
|
|
368
|
+
{
|
|
369
|
+
sessionUpdate: "plan",
|
|
370
|
+
entries: taskStateToPlanEntries(ctx.taskState),
|
|
371
|
+
},
|
|
372
|
+
];
|
|
373
|
+
}
|
|
338
374
|
return [];
|
|
339
375
|
}
|
|
340
376
|
|
|
@@ -460,6 +496,8 @@ function processContentChunk(
|
|
|
460
496
|
case "container_upload":
|
|
461
497
|
case "compaction":
|
|
462
498
|
case "compaction_delta":
|
|
499
|
+
case "advisor_tool_result":
|
|
500
|
+
case "mid_conv_system":
|
|
463
501
|
return [];
|
|
464
502
|
|
|
465
503
|
default:
|
|
@@ -486,6 +524,7 @@ function toAcpNotifications(
|
|
|
486
524
|
cwd?: string,
|
|
487
525
|
mcpToolUseResult?: Record<string, unknown>,
|
|
488
526
|
enrichedReadCache?: EnrichedReadCache,
|
|
527
|
+
taskState?: TaskState,
|
|
489
528
|
): SessionNotification[] {
|
|
490
529
|
if (typeof content === "string") {
|
|
491
530
|
const update: SessionUpdate = {
|
|
@@ -514,6 +553,7 @@ function toAcpNotifications(
|
|
|
514
553
|
supportsTerminalOutput,
|
|
515
554
|
cwd,
|
|
516
555
|
mcpToolUseResult,
|
|
556
|
+
taskState,
|
|
517
557
|
};
|
|
518
558
|
const output: SessionNotification[] = [];
|
|
519
559
|
|
|
@@ -539,6 +579,7 @@ function streamEventToAcpNotifications(
|
|
|
539
579
|
supportsTerminalOutput?: boolean,
|
|
540
580
|
cwd?: string,
|
|
541
581
|
enrichedReadCache?: EnrichedReadCache,
|
|
582
|
+
taskState?: TaskState,
|
|
542
583
|
): SessionNotification[] {
|
|
543
584
|
const event = message.event;
|
|
544
585
|
switch (event.type) {
|
|
@@ -564,6 +605,7 @@ function streamEventToAcpNotifications(
|
|
|
564
605
|
cwd,
|
|
565
606
|
undefined,
|
|
566
607
|
enrichedReadCache,
|
|
608
|
+
taskState,
|
|
567
609
|
);
|
|
568
610
|
}
|
|
569
611
|
case "content_block_delta": {
|
|
@@ -589,11 +631,17 @@ function streamEventToAcpNotifications(
|
|
|
589
631
|
cwd,
|
|
590
632
|
undefined,
|
|
591
633
|
enrichedReadCache,
|
|
634
|
+
taskState,
|
|
592
635
|
);
|
|
593
636
|
}
|
|
594
637
|
case "content_block_stop":
|
|
595
638
|
toolUseStreamCache.delete(event.index);
|
|
596
639
|
return [];
|
|
640
|
+
// `ping` is a Messages-API keep-alive event that the SDK's
|
|
641
|
+
// `BetaRawMessageStreamEvent` union doesn't include even though the
|
|
642
|
+
// wire format emits it; the `as never` cast lets us no-op it here
|
|
643
|
+
// instead of falling through to `unreachable`.
|
|
644
|
+
case "ping" as never:
|
|
597
645
|
case "message_start":
|
|
598
646
|
case "message_delta":
|
|
599
647
|
case "message_stop":
|
|
@@ -678,6 +726,52 @@ export async function handleSystemMessage(
|
|
|
678
726
|
});
|
|
679
727
|
break;
|
|
680
728
|
}
|
|
729
|
+
case "memory_recall": {
|
|
730
|
+
const isSynthesis = message.mode === "synthesize";
|
|
731
|
+
// Skip empty recalls — they're the dominant source of UI clutter on
|
|
732
|
+
// memory-heavy turns and carry no signal (no paths, no content).
|
|
733
|
+
if (!isSynthesis && message.memories.length === 0) break;
|
|
734
|
+
const locations = isSynthesis
|
|
735
|
+
? []
|
|
736
|
+
: message.memories.map((m) => ({ path: m.path }));
|
|
737
|
+
const content = isSynthesis
|
|
738
|
+
? message.memories
|
|
739
|
+
.filter(
|
|
740
|
+
(
|
|
741
|
+
m,
|
|
742
|
+
): m is (typeof message.memories)[number] & {
|
|
743
|
+
content: string;
|
|
744
|
+
} => typeof m.content === "string",
|
|
745
|
+
)
|
|
746
|
+
.map((m) => ({
|
|
747
|
+
type: "content" as const,
|
|
748
|
+
content: { type: "text" as const, text: m.content },
|
|
749
|
+
}))
|
|
750
|
+
: [];
|
|
751
|
+
const count = message.memories.length;
|
|
752
|
+
const title = isSynthesis
|
|
753
|
+
? "Recalled synthesized memory"
|
|
754
|
+
: `Recalled ${count} ${count === 1 ? "memory" : "memories"}`;
|
|
755
|
+
await client.sessionUpdate({
|
|
756
|
+
sessionId: message.session_id,
|
|
757
|
+
update: {
|
|
758
|
+
sessionUpdate: "tool_call",
|
|
759
|
+
toolCallId: message.uuid,
|
|
760
|
+
title,
|
|
761
|
+
kind: "read",
|
|
762
|
+
status: "completed",
|
|
763
|
+
...(locations.length > 0 && { locations }),
|
|
764
|
+
...(content.length > 0 && { content }),
|
|
765
|
+
_meta: {
|
|
766
|
+
claudeCode: {
|
|
767
|
+
toolName: "memory_recall",
|
|
768
|
+
toolResponse: { mode: message.mode },
|
|
769
|
+
},
|
|
770
|
+
} satisfies ToolUpdateMeta,
|
|
771
|
+
},
|
|
772
|
+
});
|
|
773
|
+
break;
|
|
774
|
+
}
|
|
681
775
|
default:
|
|
682
776
|
break;
|
|
683
777
|
}
|
|
@@ -819,6 +913,7 @@ export async function handleStreamEvent(
|
|
|
819
913
|
context.supportsTerminalOutput,
|
|
820
914
|
context.session.cwd,
|
|
821
915
|
context.enrichedReadCache,
|
|
916
|
+
context.session.taskState,
|
|
822
917
|
)) {
|
|
823
918
|
await client.sessionUpdate(notification);
|
|
824
919
|
context.session.notificationHistory.push(notification);
|
|
@@ -837,6 +932,41 @@ function hasLocalCommandStderr(content: AnthropicMessageContent): boolean {
|
|
|
837
932
|
);
|
|
838
933
|
}
|
|
839
934
|
|
|
935
|
+
// SDK-persisted slash command invocations always lead with `<command-name>`.
|
|
936
|
+
// Requiring that anchor keeps user-typed prompts that happen to contain a
|
|
937
|
+
// literal `<local-command-stdout>` tag from being scrubbed on session reload.
|
|
938
|
+
function isSdkLocalCommandMessage(content: AnthropicMessageContent): boolean {
|
|
939
|
+
return (
|
|
940
|
+
typeof content === "string" &&
|
|
941
|
+
content.includes("<command-name>") &&
|
|
942
|
+
(content.includes("<local-command-stdout>") ||
|
|
943
|
+
content.includes("<local-command-stderr>"))
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// The Claude SDK persists local slash command invocations (e.g. `/model`) and
|
|
948
|
+
// their output as user messages wrapping the payload in these XML-like markers
|
|
949
|
+
// that the CLI uses for its own display. The live prompt loop must strip them
|
|
950
|
+
// so they don't leak into the UI, while preserving any real prose mixed in
|
|
951
|
+
// alongside.
|
|
952
|
+
const LOCAL_COMMAND_TAG_PATTERN =
|
|
953
|
+
/<(command-name|command-message|command-args|local-command-stdout|local-command-stderr)>[\s\S]*?<\/\1>/g;
|
|
954
|
+
|
|
955
|
+
function stripMarkerTags(text: string): string {
|
|
956
|
+
return text.replace(LOCAL_COMMAND_TAG_PATTERN, "");
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Returns the string with local-command marker tags removed, or `null` if
|
|
961
|
+
* nothing renderable remains. Used to surface custom slash commands and
|
|
962
|
+
* skill expansions whose bodies arrive wrapped in marker tags, while
|
|
963
|
+
* still no-op'ing for pure-marker payloads like /compact.
|
|
964
|
+
*/
|
|
965
|
+
function stripLocalCommandMetadata(content: string): string | null {
|
|
966
|
+
const stripped = stripMarkerTags(content);
|
|
967
|
+
return stripped.trim() === "" ? null : stripped;
|
|
968
|
+
}
|
|
969
|
+
|
|
840
970
|
function isLoginRequiredMessage(message: AnthropicMessageWithContent): boolean {
|
|
841
971
|
return (
|
|
842
972
|
message.type === "assistant" &&
|
|
@@ -863,8 +993,7 @@ function shouldSkipUserAssistantMessage(
|
|
|
863
993
|
message: AnthropicMessageWithContent,
|
|
864
994
|
): boolean {
|
|
865
995
|
return (
|
|
866
|
-
|
|
867
|
-
hasLocalCommandStderr(message.message.content) ||
|
|
996
|
+
isSdkLocalCommandMessage(message.message.content) ||
|
|
868
997
|
isLoginRequiredMessage(message)
|
|
869
998
|
);
|
|
870
999
|
}
|
|
@@ -900,12 +1029,48 @@ export async function handleUserAssistantMessage(
|
|
|
900
1029
|
const { session, sessionId, client, toolUseCache, fileContentCache, logger } =
|
|
901
1030
|
context;
|
|
902
1031
|
|
|
1032
|
+
// System-role payloads (e.g. SDK-injected reminders) reach the user/assistant
|
|
1033
|
+
// switch but are never user-visible content; skip rendering them entirely.
|
|
1034
|
+
if (message.message.role === "system") {
|
|
1035
|
+
return {};
|
|
1036
|
+
}
|
|
1037
|
+
|
|
903
1038
|
if (shouldSkipUserAssistantMessage(message)) {
|
|
904
1039
|
logSpecialMessages(message, logger);
|
|
905
1040
|
|
|
906
1041
|
if (isLoginRequiredMessage(message)) {
|
|
907
1042
|
return { shouldStop: true, error: RequestError.authRequired() };
|
|
908
1043
|
}
|
|
1044
|
+
|
|
1045
|
+
// Strip local-command marker tags and render whatever real prose remains
|
|
1046
|
+
// so that custom slash commands and skill expansions (whose bodies arrive
|
|
1047
|
+
// wrapped in <command-*> / <local-command-stdout> markers) reach the UI.
|
|
1048
|
+
// Pure-marker payloads (e.g. /compact) still no-op via the `null` branch.
|
|
1049
|
+
const rawContent = message.message.content;
|
|
1050
|
+
if (typeof rawContent === "string") {
|
|
1051
|
+
const stripped = stripLocalCommandMetadata(rawContent);
|
|
1052
|
+
if (stripped !== null) {
|
|
1053
|
+
for (const notification of toAcpNotifications(
|
|
1054
|
+
stripped,
|
|
1055
|
+
message.message.role as Role,
|
|
1056
|
+
sessionId,
|
|
1057
|
+
toolUseCache,
|
|
1058
|
+
fileContentCache,
|
|
1059
|
+
client,
|
|
1060
|
+
logger,
|
|
1061
|
+
undefined,
|
|
1062
|
+
context.registerHooks,
|
|
1063
|
+
context.supportsTerminalOutput,
|
|
1064
|
+
session.cwd,
|
|
1065
|
+
undefined,
|
|
1066
|
+
context.enrichedReadCache,
|
|
1067
|
+
session.taskState,
|
|
1068
|
+
)) {
|
|
1069
|
+
await client.sessionUpdate(notification);
|
|
1070
|
+
session.notificationHistory.push(notification);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
909
1074
|
return {};
|
|
910
1075
|
}
|
|
911
1076
|
|
|
@@ -931,7 +1096,7 @@ export async function handleUserAssistantMessage(
|
|
|
931
1096
|
|
|
932
1097
|
for (const notification of toAcpNotifications(
|
|
933
1098
|
contentToProcess as typeof content,
|
|
934
|
-
message.message.role,
|
|
1099
|
+
message.message.role as Role,
|
|
935
1100
|
sessionId,
|
|
936
1101
|
toolUseCache,
|
|
937
1102
|
fileContentCache,
|
|
@@ -943,6 +1108,7 @@ export async function handleUserAssistantMessage(
|
|
|
943
1108
|
session.cwd,
|
|
944
1109
|
mcpToolUseResult,
|
|
945
1110
|
context.enrichedReadCache,
|
|
1111
|
+
session.taskState,
|
|
946
1112
|
)) {
|
|
947
1113
|
await client.sessionUpdate(notification);
|
|
948
1114
|
session.notificationHistory.push(notification);
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
applyTaskCreate,
|
|
5
|
+
applyTaskUpdate,
|
|
6
|
+
parseTaskCreateOutput,
|
|
7
|
+
rehydrateTaskState,
|
|
8
|
+
type TaskState,
|
|
9
|
+
taskStateToPlanEntries,
|
|
10
|
+
} from "./task-state";
|
|
11
|
+
|
|
12
|
+
function assistantMsg(blocks: unknown[]): SessionMessage {
|
|
13
|
+
return {
|
|
14
|
+
type: "assistant",
|
|
15
|
+
uuid: "u",
|
|
16
|
+
session_id: "s",
|
|
17
|
+
parent_tool_use_id: null,
|
|
18
|
+
message: { role: "assistant", content: blocks },
|
|
19
|
+
} as SessionMessage;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function userMsg(blocks: unknown[]): SessionMessage {
|
|
23
|
+
return {
|
|
24
|
+
type: "user",
|
|
25
|
+
uuid: "u",
|
|
26
|
+
session_id: "s",
|
|
27
|
+
parent_tool_use_id: null,
|
|
28
|
+
message: { role: "user", content: blocks },
|
|
29
|
+
} as SessionMessage;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("parseTaskCreateOutput", () => {
|
|
33
|
+
it("parses a JSON string with task.id", () => {
|
|
34
|
+
const out = parseTaskCreateOutput('{"task":{"id":"t1"}}');
|
|
35
|
+
expect(out?.task?.id).toBe("t1");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns undefined for invalid JSON", () => {
|
|
39
|
+
expect(parseTaskCreateOutput("not json")).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns undefined when task.id is missing", () => {
|
|
43
|
+
expect(parseTaskCreateOutput("{}")).toBeUndefined();
|
|
44
|
+
expect(parseTaskCreateOutput('{"task":{}}')).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("walks array of text blocks and returns the first parseable one", () => {
|
|
48
|
+
const out = parseTaskCreateOutput([
|
|
49
|
+
{ type: "text", text: "garbage" },
|
|
50
|
+
{ type: "text", text: '{"task":{"id":"t2"}}' },
|
|
51
|
+
]);
|
|
52
|
+
expect(out?.task?.id).toBe("t2");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("ignores non-text blocks", () => {
|
|
56
|
+
const out = parseTaskCreateOutput([
|
|
57
|
+
{ type: "image", text: '{"task":{"id":"t3"}}' },
|
|
58
|
+
]);
|
|
59
|
+
expect(out).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns undefined for null/undefined/non-string content", () => {
|
|
63
|
+
expect(parseTaskCreateOutput(null)).toBeUndefined();
|
|
64
|
+
expect(parseTaskCreateOutput(undefined)).toBeUndefined();
|
|
65
|
+
expect(parseTaskCreateOutput(42)).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("applyTaskCreate", () => {
|
|
70
|
+
it("inserts a new entry keyed by output task id", () => {
|
|
71
|
+
const state: TaskState = new Map();
|
|
72
|
+
applyTaskCreate(
|
|
73
|
+
state,
|
|
74
|
+
{
|
|
75
|
+
subject: "Fix bug",
|
|
76
|
+
description: "details",
|
|
77
|
+
activeForm: "Fixing bug",
|
|
78
|
+
},
|
|
79
|
+
{ task: { id: "t1", subject: "Fix bug" } },
|
|
80
|
+
);
|
|
81
|
+
expect(state.get("t1")).toEqual({
|
|
82
|
+
subject: "Fix bug",
|
|
83
|
+
status: "pending",
|
|
84
|
+
activeForm: "Fixing bug",
|
|
85
|
+
description: "details",
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("is a no-op when output has no task id", () => {
|
|
90
|
+
const state: TaskState = new Map();
|
|
91
|
+
applyTaskCreate(state, { subject: "x", description: "y" }, undefined);
|
|
92
|
+
expect(state.size).toBe(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("is a no-op when input is undefined", () => {
|
|
96
|
+
const state: TaskState = new Map();
|
|
97
|
+
applyTaskCreate(state, undefined, { task: { id: "t1", subject: "x" } });
|
|
98
|
+
expect(state.size).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("applyTaskUpdate", () => {
|
|
103
|
+
it("removes the entry when status is deleted", () => {
|
|
104
|
+
const state: TaskState = new Map([
|
|
105
|
+
["t1", { subject: "x", status: "pending" as const }],
|
|
106
|
+
]);
|
|
107
|
+
applyTaskUpdate(state, { taskId: "t1", status: "deleted" });
|
|
108
|
+
expect(state.has("t1")).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("merges partial fields, preserving existing values", () => {
|
|
112
|
+
const state: TaskState = new Map([
|
|
113
|
+
[
|
|
114
|
+
"t1",
|
|
115
|
+
{
|
|
116
|
+
subject: "Existing subject",
|
|
117
|
+
status: "pending" as const,
|
|
118
|
+
activeForm: "Doing",
|
|
119
|
+
description: "Existing description",
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
]);
|
|
123
|
+
applyTaskUpdate(state, { taskId: "t1", status: "in_progress" });
|
|
124
|
+
expect(state.get("t1")).toEqual({
|
|
125
|
+
subject: "Existing subject",
|
|
126
|
+
status: "in_progress",
|
|
127
|
+
activeForm: "Doing",
|
|
128
|
+
description: "Existing description",
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("is a no-op when no existing entry and no subject in input", () => {
|
|
133
|
+
const state: TaskState = new Map();
|
|
134
|
+
applyTaskUpdate(state, { taskId: "t1", status: "completed" });
|
|
135
|
+
expect(state.size).toBe(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("creates a new entry when input provides a subject", () => {
|
|
139
|
+
const state: TaskState = new Map();
|
|
140
|
+
applyTaskUpdate(state, {
|
|
141
|
+
taskId: "t1",
|
|
142
|
+
subject: "Brand new",
|
|
143
|
+
status: "in_progress",
|
|
144
|
+
});
|
|
145
|
+
expect(state.get("t1")?.subject).toBe("Brand new");
|
|
146
|
+
expect(state.get("t1")?.status).toBe("in_progress");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("is a no-op when input has no taskId", () => {
|
|
150
|
+
const state: TaskState = new Map();
|
|
151
|
+
applyTaskUpdate(state, undefined);
|
|
152
|
+
expect(state.size).toBe(0);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("rehydrateTaskState", () => {
|
|
157
|
+
it("rebuilds the map from TaskCreate + TaskUpdate transcripts", () => {
|
|
158
|
+
const state: TaskState = new Map();
|
|
159
|
+
rehydrateTaskState(
|
|
160
|
+
[
|
|
161
|
+
assistantMsg([
|
|
162
|
+
{
|
|
163
|
+
type: "tool_use",
|
|
164
|
+
id: "u1",
|
|
165
|
+
name: "TaskCreate",
|
|
166
|
+
input: { subject: "First", activeForm: "Doing first" },
|
|
167
|
+
},
|
|
168
|
+
]),
|
|
169
|
+
userMsg([
|
|
170
|
+
{
|
|
171
|
+
type: "tool_result",
|
|
172
|
+
tool_use_id: "u1",
|
|
173
|
+
content: '{"task":{"id":"t1","subject":"First"}}',
|
|
174
|
+
},
|
|
175
|
+
]),
|
|
176
|
+
assistantMsg([
|
|
177
|
+
{
|
|
178
|
+
type: "tool_use",
|
|
179
|
+
id: "u2",
|
|
180
|
+
name: "TaskUpdate",
|
|
181
|
+
input: { taskId: "t1", status: "in_progress" },
|
|
182
|
+
},
|
|
183
|
+
]),
|
|
184
|
+
userMsg([
|
|
185
|
+
{
|
|
186
|
+
type: "tool_result",
|
|
187
|
+
tool_use_id: "u2",
|
|
188
|
+
content: "ok",
|
|
189
|
+
},
|
|
190
|
+
]),
|
|
191
|
+
],
|
|
192
|
+
state,
|
|
193
|
+
);
|
|
194
|
+
expect(state.get("t1")).toEqual({
|
|
195
|
+
subject: "First",
|
|
196
|
+
status: "in_progress",
|
|
197
|
+
activeForm: "Doing first",
|
|
198
|
+
description: undefined,
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("ignores tool_result blocks for non-Task tools", () => {
|
|
203
|
+
const state: TaskState = new Map();
|
|
204
|
+
rehydrateTaskState(
|
|
205
|
+
[
|
|
206
|
+
assistantMsg([
|
|
207
|
+
{ type: "tool_use", id: "r1", name: "Read", input: { file: "a" } },
|
|
208
|
+
]),
|
|
209
|
+
userMsg([
|
|
210
|
+
{ type: "tool_result", tool_use_id: "r1", content: "file contents" },
|
|
211
|
+
]),
|
|
212
|
+
],
|
|
213
|
+
state,
|
|
214
|
+
);
|
|
215
|
+
expect(state.size).toBe(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("skips errored Task tool results", () => {
|
|
219
|
+
const state: TaskState = new Map();
|
|
220
|
+
rehydrateTaskState(
|
|
221
|
+
[
|
|
222
|
+
assistantMsg([
|
|
223
|
+
{
|
|
224
|
+
type: "tool_use",
|
|
225
|
+
id: "u1",
|
|
226
|
+
name: "TaskCreate",
|
|
227
|
+
input: { subject: "x" },
|
|
228
|
+
},
|
|
229
|
+
]),
|
|
230
|
+
userMsg([
|
|
231
|
+
{
|
|
232
|
+
type: "tool_result",
|
|
233
|
+
tool_use_id: "u1",
|
|
234
|
+
content: '{"task":{"id":"t1","subject":"x"}}',
|
|
235
|
+
is_error: true,
|
|
236
|
+
},
|
|
237
|
+
]),
|
|
238
|
+
],
|
|
239
|
+
state,
|
|
240
|
+
);
|
|
241
|
+
expect(state.size).toBe(0);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("honors deletes from TaskUpdate", () => {
|
|
245
|
+
const state: TaskState = new Map();
|
|
246
|
+
rehydrateTaskState(
|
|
247
|
+
[
|
|
248
|
+
assistantMsg([
|
|
249
|
+
{
|
|
250
|
+
type: "tool_use",
|
|
251
|
+
id: "u1",
|
|
252
|
+
name: "TaskCreate",
|
|
253
|
+
input: { subject: "x" },
|
|
254
|
+
},
|
|
255
|
+
]),
|
|
256
|
+
userMsg([
|
|
257
|
+
{
|
|
258
|
+
type: "tool_result",
|
|
259
|
+
tool_use_id: "u1",
|
|
260
|
+
content: '{"task":{"id":"t1","subject":"x"}}',
|
|
261
|
+
},
|
|
262
|
+
]),
|
|
263
|
+
assistantMsg([
|
|
264
|
+
{
|
|
265
|
+
type: "tool_use",
|
|
266
|
+
id: "u2",
|
|
267
|
+
name: "TaskUpdate",
|
|
268
|
+
input: { taskId: "t1", status: "deleted" },
|
|
269
|
+
},
|
|
270
|
+
]),
|
|
271
|
+
userMsg([{ type: "tool_result", tool_use_id: "u2", content: "ok" }]),
|
|
272
|
+
],
|
|
273
|
+
state,
|
|
274
|
+
);
|
|
275
|
+
expect(state.has("t1")).toBe(false);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("ignores tool_result without a matching tool_use", () => {
|
|
279
|
+
const state: TaskState = new Map();
|
|
280
|
+
rehydrateTaskState(
|
|
281
|
+
[
|
|
282
|
+
userMsg([
|
|
283
|
+
{
|
|
284
|
+
type: "tool_result",
|
|
285
|
+
tool_use_id: "orphan",
|
|
286
|
+
content: '{"task":{"id":"t9","subject":"x"}}',
|
|
287
|
+
},
|
|
288
|
+
]),
|
|
289
|
+
],
|
|
290
|
+
state,
|
|
291
|
+
);
|
|
292
|
+
expect(state.size).toBe(0);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("ignores messages with non-array content", () => {
|
|
296
|
+
const state: TaskState = new Map();
|
|
297
|
+
rehydrateTaskState(
|
|
298
|
+
[
|
|
299
|
+
{
|
|
300
|
+
type: "user",
|
|
301
|
+
uuid: "u",
|
|
302
|
+
session_id: "s",
|
|
303
|
+
parent_tool_use_id: null,
|
|
304
|
+
message: { role: "user", content: "plain string" },
|
|
305
|
+
} as SessionMessage,
|
|
306
|
+
],
|
|
307
|
+
state,
|
|
308
|
+
);
|
|
309
|
+
expect(state.size).toBe(0);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe("taskStateToPlanEntries", () => {
|
|
314
|
+
it("returns an empty array for an empty state", () => {
|
|
315
|
+
expect(taskStateToPlanEntries(new Map())).toEqual([]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("preserves Map insertion order", () => {
|
|
319
|
+
const state: TaskState = new Map();
|
|
320
|
+
state.set("c", { subject: "third", status: "pending" });
|
|
321
|
+
state.set("a", { subject: "first", status: "in_progress" });
|
|
322
|
+
state.set("b", { subject: "second", status: "completed" });
|
|
323
|
+
const entries = taskStateToPlanEntries(state);
|
|
324
|
+
expect(entries.map((e) => e.content)).toEqual(["third", "first", "second"]);
|
|
325
|
+
expect(entries.map((e) => e.status)).toEqual([
|
|
326
|
+
"pending",
|
|
327
|
+
"in_progress",
|
|
328
|
+
"completed",
|
|
329
|
+
]);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("hardcodes priority to medium", () => {
|
|
333
|
+
const state: TaskState = new Map([
|
|
334
|
+
["t1", { subject: "x", status: "pending" }],
|
|
335
|
+
]);
|
|
336
|
+
expect(taskStateToPlanEntries(state)[0].priority).toBe("medium");
|
|
337
|
+
});
|
|
338
|
+
});
|