@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.
Files changed (62) hide show
  1. package/dist/adapters/claude/conversion/tool-use-to-acp.d.ts +2 -10
  2. package/dist/adapters/claude/conversion/tool-use-to-acp.js +24 -10
  3. package/dist/adapters/claude/conversion/tool-use-to-acp.js.map +1 -1
  4. package/dist/adapters/claude/permissions/permission-options.js +6 -4
  5. package/dist/adapters/claude/permissions/permission-options.js.map +1 -1
  6. package/dist/adapters/claude/session/jsonl-hydration.js +1 -0
  7. package/dist/adapters/claude/session/jsonl-hydration.js.map +1 -1
  8. package/dist/adapters/claude/session/models.js +20 -1
  9. package/dist/adapters/claude/session/models.js.map +1 -1
  10. package/dist/adapters/claude/tools.js +4 -2
  11. package/dist/adapters/claude/tools.js.map +1 -1
  12. package/dist/adapters/reasoning-effort.js +3 -1
  13. package/dist/adapters/reasoning-effort.js.map +1 -1
  14. package/dist/agent.js +524 -72
  15. package/dist/agent.js.map +1 -1
  16. package/dist/posthog-api.js +4 -4
  17. package/dist/posthog-api.js.map +1 -1
  18. package/dist/server/agent-server.js +524 -72
  19. package/dist/server/agent-server.js.map +1 -1
  20. package/dist/server/bin.cjs +524 -72
  21. package/dist/server/bin.cjs.map +1 -1
  22. package/package.json +6 -6
  23. package/src/adapters/claude/UPSTREAM.md +80 -3
  24. package/src/adapters/claude/claude-agent.ts +201 -18
  25. package/src/adapters/claude/conversion/sdk-to-acp.ts +182 -16
  26. package/src/adapters/claude/conversion/task-state.test.ts +338 -0
  27. package/src/adapters/claude/conversion/task-state.ts +178 -0
  28. package/src/adapters/claude/conversion/tool-use-to-acp.ts +29 -19
  29. package/src/adapters/claude/hooks.test.ts +162 -0
  30. package/src/adapters/claude/hooks.ts +44 -3
  31. package/src/adapters/claude/permissions/permission-options.ts +7 -2
  32. package/src/adapters/claude/session/commands.ts +1 -0
  33. package/src/adapters/claude/session/mcp-config.ts +23 -7
  34. package/src/adapters/claude/session/model-config.test.ts +60 -0
  35. package/src/adapters/claude/session/model-config.ts +56 -0
  36. package/src/adapters/claude/session/models.test.ts +119 -0
  37. package/src/adapters/claude/session/models.ts +31 -0
  38. package/src/adapters/claude/session/options.test.ts +1 -0
  39. package/src/adapters/claude/session/options.ts +29 -1
  40. package/src/adapters/claude/session/settings.test.ts +102 -1
  41. package/src/adapters/claude/session/settings.ts +33 -6
  42. package/src/adapters/claude/tools.ts +4 -2
  43. package/src/adapters/claude/types.ts +8 -0
  44. package/src/adapters/codex/codex-agent.ts +2 -2
  45. package/src/server/agent-server.test.ts +173 -0
  46. package/src/test/mocks/claude-sdk.ts +4 -0
  47. package/src/test/native-binary.test.ts +27 -0
  48. package/dist/claude-cli/cli.js +0 -18412
  49. package/dist/claude-cli/vendor/audio-capture/arm64-darwin/audio-capture.node +0 -0
  50. package/dist/claude-cli/vendor/audio-capture/arm64-linux/audio-capture.node +0 -0
  51. package/dist/claude-cli/vendor/audio-capture/arm64-win32/audio-capture.node +0 -0
  52. package/dist/claude-cli/vendor/audio-capture/x64-darwin/audio-capture.node +0 -0
  53. package/dist/claude-cli/vendor/audio-capture/x64-linux/audio-capture.node +0 -0
  54. package/dist/claude-cli/vendor/audio-capture/x64-win32/audio-capture.node +0 -0
  55. package/dist/claude-cli/vendor/ripgrep/COPYING +0 -3
  56. package/dist/claude-cli/vendor/ripgrep/arm64-darwin/rg +0 -0
  57. package/dist/claude-cli/vendor/ripgrep/arm64-linux/rg +0 -0
  58. package/dist/claude-cli/vendor/ripgrep/arm64-win32/rg.exe +0 -0
  59. package/dist/claude-cli/vendor/ripgrep/x64-darwin/rg +0 -0
  60. package/dist/claude-cli/vendor/ripgrep/x64-linux/rg +0 -0
  61. package/dist/claude-cli/vendor/ripgrep/x64-win32/rg.exe +0 -0
  62. 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
- type ClaudePlanEntry,
35
- planEntries,
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
- if (chunk.name === "TodoWrite") {
172
- const input = chunk.input as { todos?: unknown[] };
173
- if (Array.isArray(input.todos)) {
174
- return {
175
- sessionUpdate: "plan",
176
- entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
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 (toolUse.name === "TodoWrite") {
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
- hasLocalCommandStdout(message.message.content) ||
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
+ });