@pi9/ask 0.1.0 → 0.3.0

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/src/index.ts CHANGED
@@ -1,20 +1,25 @@
1
1
  import type { ExtensionAPI, SessionEntry, Theme } from "@earendil-works/pi-coding-agent";
2
- import { Text } from "@earendil-works/pi-tui";
2
+ import { Text, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
3
3
 
4
- import { rewriteAskContext } from "./context.js";
5
- import { resolveTimeoutMs } from "./config.js";
6
4
  import { createDeadlineSignal } from "./deadline.js";
5
+ import {
6
+ AskParamsSchema,
7
+ buildAskResponse,
8
+ normalizeAsk,
9
+ parseAskReplayDetails,
10
+ type AskAnswer,
11
+ type AskParams,
12
+ type AskToolDetails,
13
+ } from "./domain.js";
7
14
  import { CHECKED_BOX, CHECKED_CIRCLE, EMPTY_BOX, EMPTY_CIRCLE } from "./glyphs.js";
8
15
  import { launchQuestionnaire } from "./questionnaire.js";
9
- import { renderAskReanswerMessage } from "./replay-renderer.js";
10
- import { ASK_REPLAY_CUSTOM_TYPE, buildAskReplayMessage, parseAskReplayDetails, resolveAskReplayTarget } from "./replay.js";
11
- import { buildAnsweredResponse, buildCancelledResponse, buildUiUnavailableResponse, buildUnansweredResponse } from "./response.js";
12
16
  import { askWithRpc } from "./rpc.js";
13
- import { AskParamsSchema } from "./schema.js";
14
- import type { AskAnswer, AskParams, AskToolDetails } from "./types.js";
15
- import { validateAskParams } from "./validation.js";
16
-
17
- const TREE_EDITOR_GUARD = "\u200B";
17
+ import {
18
+ ASK_REPLAY_CUSTOM_TYPE,
19
+ buildAskReplayMessage,
20
+ resolveAskReplayTarget,
21
+ rewriteAskContext,
22
+ } from "./session.js";
18
23
 
19
24
  type AskRendererState = {
20
25
  callComponent?: Text;
@@ -23,16 +28,11 @@ type AskRendererState = {
23
28
  };
24
29
 
25
30
  type AskReplayState =
26
- | { status: "idle" }
27
- | { status: "prompting" }
28
- | {
29
- status: "dispatched";
30
- details: ReturnType<typeof buildAskReplayMessage>["details"];
31
- clearEditor: boolean;
32
- };
31
+ | { status: "idle" | "prompting" }
32
+ | { status: "dispatched"; details: ReturnType<typeof buildAskReplayMessage>["details"] };
33
33
 
34
34
  function renderAskCall(args: AskParams, theme: Theme, state: AskRendererState): string {
35
- const questionColor = state.status === "answered" ? "text" : "dim";
35
+ const questionColor = state.status === "answered" ? "text" : "muted";
36
36
  const title = `${theme.fg("toolTitle", "ask")} ${theme.fg(questionColor, args.question)}`;
37
37
  if (state.status) return title;
38
38
 
@@ -41,7 +41,7 @@ function renderAskCall(args: AskParams, theme: Theme, state: AskRendererState):
41
41
  const timeout = args.timeout !== undefined && args.timeout > 0
42
42
  ? ` · timeout:${formatTimeout(args.timeout)}`
43
43
  : "";
44
- return `${title}\n${theme.fg("dim", `╰ ${mode}options:${optionCount}${timeout}`)}`;
44
+ return `${title}\n${theme.fg("muted", `╰ ${mode}options:${optionCount}${timeout}`)}`;
45
45
  }
46
46
 
47
47
  function formatTimeout(timeoutMs: number): string {
@@ -50,26 +50,55 @@ function formatTimeout(timeoutMs: number): string {
50
50
  return `${Number.isInteger(seconds) ? seconds : Number(seconds.toFixed(2))}s`;
51
51
  }
52
52
 
53
- function renderAnsweredOptions(args: AskParams, answer: AskAnswer, theme: Theme): string {
54
- const selections = new Map(answer.selections.map(selection => [selection.label, selection]));
55
- const checkedGlyph = args.allowMultiple === true ? CHECKED_BOX : CHECKED_CIRCLE;
56
- const emptyGlyph = args.allowMultiple === true ? EMPTY_BOX : EMPTY_CIRCLE;
57
- const lines = args.options.map(option => {
58
- const label = option.label.trim();
59
- const selection = selections.get(label);
60
- const comment = selection?.comment ? ` (${selection.comment})` : "";
61
- const text = `${label}${comment}`;
62
- return selection
63
- ? `${theme.fg("success", checkedGlyph)} ${theme.fg("text", text)}`
64
- : theme.fg("dim", `${emptyGlyph} ${text}`);
65
- });
66
- if (answer.freeform) lines.push(`${theme.fg("success", checkedGlyph)} ${theme.fg("text", answer.freeform)}`);
67
- return lines.map((line, index) => `${index === 0 ? `${theme.fg("dim", "╰")} ` : " "}${line}`).join("\n");
53
+ class AnsweredOptions implements Component {
54
+ constructor(
55
+ private readonly args: AskParams,
56
+ private readonly answer: AskAnswer,
57
+ private readonly theme: Theme,
58
+ ) {}
59
+
60
+ invalidate(): void {}
61
+
62
+ render(width: number): string[] {
63
+ const selections = new Map(this.answer.selections.map(selection => [selection.option, selection]));
64
+ const checkedGlyph = this.args.allowMultiple === true ? CHECKED_BOX : CHECKED_CIRCLE;
65
+ const emptyGlyph = this.args.allowMultiple === true ? EMPTY_BOX : EMPTY_CIRCLE;
66
+ const rows = this.args.options.map((option, index) => {
67
+ const label = option.label.trim();
68
+ const selection = selections.get(index);
69
+ const comment = selection?.comment ? ` (${selection.comment})` : "";
70
+ return {
71
+ marker: this.theme.fg(selection ? "success" : "muted", selection ? checkedGlyph : emptyGlyph),
72
+ text: this.theme.fg(selection ? "text" : "muted", `${label}${comment}`),
73
+ };
74
+ });
75
+ if (this.answer.freeform) {
76
+ rows.push({
77
+ marker: this.theme.fg("success", checkedGlyph),
78
+ text: this.theme.fg("text", this.answer.freeform),
79
+ });
80
+ }
81
+
82
+ return rows.flatMap((row, index) => wrapAnsweredRow(
83
+ `${index === 0 ? `${this.theme.fg("muted", "╰")} ` : " "}${row.marker} `,
84
+ row.text,
85
+ width,
86
+ ));
87
+ }
88
+ }
89
+
90
+ function wrapAnsweredRow(prefix: string, text: string, width: number): string[] {
91
+ const safeWidth = Math.max(1, Number.isFinite(width) ? Math.floor(width) : 1);
92
+ const prefixWidth = visibleWidth(prefix);
93
+ if (prefixWidth >= safeWidth) return wrapTextWithAnsi(`${prefix}${text}`, safeWidth);
94
+
95
+ const wrapped = wrapTextWithAnsi(text, safeWidth - prefixWidth);
96
+ const continuation = " ".repeat(prefixWidth);
97
+ return wrapped.map((line, index) => `${index === 0 ? prefix : continuation}${line}`);
68
98
  }
69
99
 
70
100
  export default function askExtension(pi: ExtensionAPI) {
71
101
  let replayState: AskReplayState = { status: "idle" };
72
- let replayTreeSelection = false;
73
102
  const revisedAnswers = new Map<string, AskAnswer>();
74
103
  const rendererStates = new Map<string, AskRendererState>();
75
104
 
@@ -100,29 +129,17 @@ export default function askExtension(pi: ExtensionAPI) {
100
129
  });
101
130
  pi.on("before_agent_start", (_event, ctx) => reconcileAskTool(ctx.hasUI));
102
131
  pi.on("context", (event) => ({ messages: rewriteAskContext(event.messages) }));
103
- pi.on("agent_settled", (_event, ctx) => {
132
+ pi.on("agent_settled", () => {
104
133
  if (replayState.status !== "dispatched") return;
105
- if (replayState.clearEditor) {
106
- replayState = { ...replayState, clearEditor: false };
107
- setTimeout(() => ctx.ui.setEditorText(""), 0);
108
- }
109
134
  pi.events.emit("ask:reanswered", replayState.details);
110
135
  replayState = { status: "idle" };
111
136
  });
112
137
  pi.on("session_shutdown", () => {
113
138
  replayState = { status: "idle" };
114
- replayTreeSelection = false;
115
139
  revisedAnswers.clear();
116
140
  rendererStates.clear();
117
141
  });
118
- pi.registerMessageRenderer(ASK_REPLAY_CUSTOM_TYPE, renderAskReanswerMessage);
119
- pi.on("session_before_tree", (event, ctx) => {
120
- const target = ctx.sessionManager.getEntry(event.preparation.targetId);
121
- replayTreeSelection = target?.type === "custom_message" && target.customType === ASK_REPLAY_CUSTOM_TYPE;
122
- });
123
142
  pi.on("session_tree", async (event, ctx) => {
124
- const suppressEditorRestore = replayTreeSelection;
125
- replayTreeSelection = false;
126
143
  if (ctx.mode !== "tui" || replayState.status !== "idle") return;
127
144
 
128
145
  const entries = ctx.sessionManager.getBranch();
@@ -137,30 +154,18 @@ export default function askExtension(pi: ExtensionAPI) {
137
154
  return;
138
155
  }
139
156
 
140
- // Pi restores selected custom-message content after this hook returns. Keep
141
- // the editor temporarily non-empty until the replay turn settles so the
142
- // selected marker text cannot replace the guard.
143
- const guardEditor = suppressEditorRestore && !ctx.ui.getEditorText().trim();
144
- if (guardEditor) ctx.ui.setEditorText(TREE_EDITOR_GUARD);
145
-
146
157
  replayState = { status: "prompting" };
147
- const deadline = createDeadlineSignal(
148
- undefined,
149
- resolveTimeoutMs(resolution.params.timeout, process.env),
150
- );
158
+ const deadline = createDeadlineSignal(undefined, resolution.ask.timeout, process.env);
151
159
  try {
152
- const answer = await launchQuestionnaire(ctx, resolution.params, deadline.signal);
160
+ const answer = await launchQuestionnaire(ctx, resolution.ask, deadline.signal);
153
161
  if (!answer) return;
154
- const message = buildAskReplayMessage(resolution.toolCallId, resolution.params, answer);
162
+ const message = buildAskReplayMessage(resolution.toolCallId, answer);
155
163
  pi.sendMessage(message, { triggerTurn: true, deliverAs: "followUp" });
156
164
  applyRevision(message.details.toolCallId, message.details.answer);
157
- replayState = { status: "dispatched", details: message.details, clearEditor: guardEditor };
165
+ replayState = { status: "dispatched", details: message.details };
158
166
  } finally {
159
167
  deadline.dispose();
160
- if (replayState.status !== "dispatched") {
161
- replayState = { status: "idle" };
162
- if (guardEditor) setTimeout(() => ctx.ui.setEditorText(""), 0);
163
- }
168
+ if (replayState.status !== "dispatched") replayState = { status: "idle" };
164
169
  }
165
170
  });
166
171
 
@@ -177,29 +182,26 @@ export default function askExtension(pi: ExtensionAPI) {
177
182
  executionMode: "sequential",
178
183
 
179
184
  async execute(_toolCallId, rawParams, signal, _onUpdate, ctx) {
180
- const params = validateAskParams(rawParams as AskParams);
181
- if (!ctx.hasUI) return buildUiUnavailableResponse(params.question);
185
+ const params = normalizeAsk(rawParams as AskParams);
186
+ if (!ctx.hasUI) return buildAskResponse(params, { status: "ui_unavailable" });
182
187
 
183
- const deadline = createDeadlineSignal(
184
- signal,
185
- resolveTimeoutMs(params.timeout, process.env),
186
- );
188
+ const deadline = createDeadlineSignal(signal, params.timeout, process.env);
187
189
  try {
188
190
  const answer = ctx.mode === "tui"
189
191
  ? await launchQuestionnaire(ctx, params, deadline.signal)
190
192
  : await askWithRpc(ctx.ui, params, deadline.signal);
191
193
  if (answer === null) {
192
194
  if (deadline.timedOut) {
193
- const result = buildUnansweredResponse(params.question);
195
+ const result = buildAskResponse(params, { status: "unanswered" });
194
196
  pi.events.emit("ask:unanswered", result.details);
195
197
  return result;
196
198
  }
197
- const result = buildCancelledResponse(params.question);
199
+ const result = buildAskResponse(params, { status: "cancelled" });
198
200
  pi.events.emit("ask:cancelled", result.details);
199
201
  return result;
200
202
  }
201
203
 
202
- const result = buildAnsweredResponse(params.question, answer);
204
+ const result = buildAskResponse(params, { status: "answered", answer });
203
205
  pi.events.emit("ask:answered", result.details);
204
206
  return result;
205
207
  } finally {
@@ -220,7 +222,7 @@ export default function askExtension(pi: ExtensionAPI) {
220
222
  ?? (result.details?.status === "answered" ? result.details.answer : undefined);
221
223
  context.state.status = answer === undefined ? "settled" : "answered";
222
224
  context.state.callComponent?.setText(renderAskCall(context.args, theme, context.state));
223
- if (answer) return new Text(renderAnsweredOptions(context.args, answer, theme), 0, 0);
225
+ if (answer) return new AnsweredOptions(context.args, answer, theme);
224
226
  const text = result.content.find((item) => item.type === "text")?.text ?? "Ask completed.";
225
227
  const color = result.details?.status === "cancelled" || result.details?.status === "unanswered"
226
228
  ? "muted"
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import { AskComponent } from "./component.js";
4
- import type { AskAnswer, ValidatedAskParams } from "./types.js";
4
+ import type { Ask, AskAnswer } from "./domain.js";
5
5
 
6
6
  interface QuestionnaireLaunchContext {
7
7
  ui: Pick<ExtensionUIContext, "custom">;
@@ -9,7 +9,7 @@ interface QuestionnaireLaunchContext {
9
9
 
10
10
  export async function launchQuestionnaire(
11
11
  ctx: QuestionnaireLaunchContext,
12
- params: ValidatedAskParams,
12
+ params: Ask,
13
13
  signal?: AbortSignal,
14
14
  ): Promise<AskAnswer | null> {
15
15
  let abortListener: (() => void) | undefined;
package/src/rpc.ts CHANGED
@@ -1,101 +1,93 @@
1
- import type { AskAnswer, AskOption, ValidatedAskParams } from "./types.js";
1
+ import type { Ask, AskAnswer, AskOption, AskSelection } from "./domain.js";
2
2
 
3
3
  export interface AskDialogUI {
4
4
  select(title: string, options: string[], dialogOptions?: { signal?: AbortSignal }): Promise<string | undefined>;
5
5
  input(title: string, placeholder?: string, dialogOptions?: { signal?: AbortSignal }): Promise<string | undefined>;
6
6
  }
7
7
 
8
- type AskSelection = AskAnswer["selections"][number];
9
-
10
8
  const FREEFORM_CHOICE = "Type a response…";
11
9
 
12
10
  export async function askWithRpc(
13
11
  ui: AskDialogUI,
14
- params: ValidatedAskParams,
12
+ ask: Ask,
15
13
  signal?: AbortSignal,
16
14
  ): Promise<AskAnswer | null> {
17
- const prompt = params.context ? `${params.context}\n\n${params.question}` : params.question;
15
+ const prompt = ask.context ? `${ask.context}\n\n${ask.question}` : ask.question;
18
16
  if (signal?.aborted) return null;
19
17
 
20
- return params.allowMultiple
21
- ? runMultiSelect(ui, prompt, params.options, params.allowFreeform, signal)
22
- : runSingleSelect(ui, prompt, params.options, params.allowFreeform, signal);
18
+ return ask.allowMultiple
19
+ ? runMultiSelect(ui, prompt, ask, signal)
20
+ : runSingleSelect(ui, prompt, ask, signal);
23
21
  }
24
22
 
25
23
  async function runSingleSelect(
26
24
  ui: AskDialogUI,
27
25
  prompt: string,
28
- options: AskOption[],
29
- allowFreeform: boolean,
26
+ ask: Ask,
30
27
  signal?: AbortSignal,
31
28
  ): Promise<AskAnswer | null> {
32
- const choices = options.map((option, index) => `${index + 1}. ${formatOption(option)}`);
33
- if (allowFreeform) choices.push(`${options.length + 1}. ${FREEFORM_CHOICE}`);
29
+ const choices = ask.options.map((option, index) => `${index + 1}. ${formatOption(option)}`);
30
+ if (ask.allowFreeform) choices.push(`${ask.options.length + 1}. ${FREEFORM_CHOICE}`);
34
31
 
35
32
  const selected = await selectDialog(ui, prompt, choices, signal);
36
33
  if (selected == null || signal?.aborted) return null;
37
34
 
38
35
  const selectedIndex = choices.indexOf(selected);
39
36
  if (selectedIndex === -1) return null;
40
- if (selectedIndex === options.length) {
37
+ if (selectedIndex === ask.options.length) {
41
38
  const freeform = await readInput(ui, prompt, signal);
42
39
  return freeform === null ? null : makeAnswer([], freeform);
43
40
  }
44
41
 
45
- const selectedOption = options[selectedIndex];
46
- if (!selectedOption) return null;
47
- const selections = await collectComments(ui, prompt, [selectedOption], signal);
42
+ const selections = await collectComments(ui, prompt, ask.options, [selectedIndex], signal);
48
43
  return selections === null ? null : makeAnswer(selections);
49
44
  }
50
45
 
51
46
  async function runMultiSelect(
52
47
  ui: AskDialogUI,
53
48
  prompt: string,
54
- options: AskOption[],
55
- allowFreeform: boolean,
49
+ ask: Ask,
56
50
  signal?: AbortSignal,
57
51
  ): Promise<AskAnswer | null> {
58
- const numberedOptions = options
52
+ const numberedOptions = ask.options
59
53
  .map((option, index) => `${index + 1}. ${formatOption(option)}`)
60
54
  .join("\n");
61
55
  const selectionPrompt = `${prompt}\n\n${numberedOptions}\n\nEnter option numbers separated by commas:`;
62
56
  const rawSelection = await readInput(ui, selectionPrompt, signal);
63
57
  if (rawSelection === null) return null;
64
58
 
65
- const selectedOptions = parseSelections(rawSelection, options);
66
- const freeform = allowFreeform
59
+ const selected = parseSelections(rawSelection, ask.options.length);
60
+ const freeform = ask.allowFreeform
67
61
  ? await readInput(ui, `${prompt}\n\nAdditional response (optional):`, signal)
68
62
  : undefined;
69
63
  if (freeform === null) return null;
70
64
 
71
- const selections = await collectComments(ui, prompt, selectedOptions, signal);
65
+ const selections = await collectComments(ui, prompt, ask.options, selected, signal);
72
66
  return selections === null ? null : makeAnswer(selections, freeform);
73
67
  }
74
68
 
75
- function parseSelections(raw: string, options: AskOption[]): AskOption[] {
76
- const indices = new Set<number>();
69
+ function parseSelections(raw: string, optionCount: number): number[] {
70
+ const selected = new Set<number>();
77
71
  for (const token of raw.split(",")) {
78
72
  const value = token.trim();
79
73
  if (!/^\d+$/.test(value)) continue;
80
74
 
81
75
  const index = Number(value) - 1;
82
- if (Number.isSafeInteger(index) && index >= 0 && index < options.length) indices.add(index);
76
+ if (Number.isSafeInteger(index) && index >= 0 && index < optionCount) selected.add(index);
83
77
  }
84
- return options.filter((_option, index) => indices.has(index));
78
+ return [...selected].sort((a, b) => a - b);
85
79
  }
86
80
 
87
81
  async function collectComments(
88
82
  ui: AskDialogUI,
89
83
  prompt: string,
90
84
  options: AskOption[],
85
+ selected: number[],
91
86
  signal?: AbortSignal,
92
87
  ): Promise<AskSelection[] | null> {
93
- const selections: AskSelection[] = options.map(toSelection);
94
- for (let index = 0; index < options.length; index++) {
95
- const option = options[index];
96
- const selection = selections[index];
97
- if (!option || !selection) continue;
98
-
88
+ const selections: AskSelection[] = selected.map(option => ({ option }));
89
+ for (const selection of selections) {
90
+ const option = options[selection.option]!;
99
91
  const comment = await readInput(ui, `${prompt}\n\nComment for \"${option.label}\" (optional):`, signal);
100
92
  if (comment === null) return null;
101
93
  if (comment) selection.comment = comment;
@@ -110,12 +102,6 @@ async function readInput(ui: AskDialogUI, title: string, signal?: AbortSignal):
110
102
  return value.trim();
111
103
  }
112
104
 
113
- function toSelection(option: AskOption): AskSelection {
114
- const selection: AskSelection = { label: option.label };
115
- if (option.description !== undefined) selection.description = option.description;
116
- return selection;
117
- }
118
-
119
105
  function makeAnswer(selections: AskSelection[], freeform?: string): AskAnswer {
120
106
  return freeform ? { selections, freeform } : { selections };
121
107
  }
package/src/session.ts ADDED
@@ -0,0 +1,254 @@
1
+ import type {
2
+ ContextEvent,
3
+ SessionEntry,
4
+ SessionTreeEvent,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { Check } from "typebox/value";
7
+
8
+ import {
9
+ AskAnsweredDetailsSchema,
10
+ AskParamsSchema,
11
+ answerMatchesAsk,
12
+ normalizeAsk,
13
+ parseAskReplayDetails,
14
+ type Ask,
15
+ type AskAnswer,
16
+ type AskParams,
17
+ type AskReplayDetails,
18
+ } from "./domain.js";
19
+
20
+ export const ASK_REPLAY_CUSTOM_TYPE = "ask:reanswer" as const;
21
+ const ASK_SUMMARY_CUSTOM_TYPE = "ask:summary" as const;
22
+
23
+ type AgentMessage = ContextEvent["messages"][number];
24
+ type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
25
+ type ToolResultMessage = Extract<AgentMessage, { role: "toolResult" }>;
26
+ type CustomMessage = Extract<AgentMessage, { role: "custom" }>;
27
+
28
+ type AskCall = {
29
+ ask: Ask;
30
+ message: AssistantMessage;
31
+ };
32
+
33
+ type ReplayRecord = {
34
+ message: CustomMessage;
35
+ details?: AskReplayDetails;
36
+ };
37
+
38
+ type AskSummaryPayload = {
39
+ type: "ask_response";
40
+ question: string;
41
+ context?: string;
42
+ selectionMode: "single" | "multi";
43
+ answer: {
44
+ selections: Array<{ label: string; description?: string; comment?: string }>;
45
+ freeform?: string;
46
+ };
47
+ };
48
+
49
+ export type AskReplayMessage = {
50
+ customType: typeof ASK_REPLAY_CUSTOM_TYPE;
51
+ content: "";
52
+ display: false;
53
+ details: AskReplayDetails;
54
+ };
55
+
56
+ export type AskReplayResolution =
57
+ | { status: "resolved"; toolCallId: string; ask: Ask }
58
+ | {
59
+ status: "not-replayable";
60
+ reason: "no-entry" | "not-assistant" | "not-ask" | "multiple-tool-calls" | "mixed-tools" | "invalid-arguments";
61
+ };
62
+
63
+ export function buildAskReplayMessage(toolCallId: string, answer: AskAnswer): AskReplayMessage {
64
+ return {
65
+ customType: ASK_REPLAY_CUSTOM_TYPE,
66
+ content: "",
67
+ display: false,
68
+ details: { toolCallId, answer },
69
+ };
70
+ }
71
+
72
+ /** Resolve only the selected leaf, or its immediate parent when Pi created a branch summary. */
73
+ export function resolveAskReplayTarget(
74
+ event: Pick<SessionTreeEvent, "newLeafId" | "summaryEntry">,
75
+ getEntry: (id: string) => SessionEntry | undefined | null,
76
+ ): AskReplayResolution {
77
+ if (!event.newLeafId) return rejected("no-entry");
78
+
79
+ let entry = getEntry(event.newLeafId);
80
+ const summary = event.summaryEntry?.id === event.newLeafId
81
+ ? event.summaryEntry
82
+ : entry?.type === "branch_summary" ? entry : undefined;
83
+ if (summary) entry = summary.parentId ? getEntry(summary.parentId) : undefined;
84
+ if (!entry) return rejected("no-entry");
85
+
86
+ let selectedToolCallId: string | undefined;
87
+ if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "ask") {
88
+ selectedToolCallId = entry.message.toolCallId;
89
+ entry = entry.parentId ? getEntry(entry.parentId) : undefined;
90
+ }
91
+ if (!entry) return rejected("no-entry");
92
+ if (entry.type !== "message" || entry.message.role !== "assistant") return rejected("not-assistant");
93
+
94
+ const calls = entry.message.content.filter(item => item.type === "toolCall");
95
+ const askCalls = calls.filter(call => call.name === "ask");
96
+ if (calls.length !== 1) {
97
+ if (askCalls.length > 0 && askCalls.length < calls.length) return rejected("mixed-tools");
98
+ if (askCalls.length > 1) return rejected("multiple-tool-calls");
99
+ return rejected("not-ask");
100
+ }
101
+
102
+ const call = calls[0]!;
103
+ if (call.name !== "ask" || (selectedToolCallId !== undefined && call.id !== selectedToolCallId)) {
104
+ return rejected("not-ask");
105
+ }
106
+
107
+ const ask = parseStoredAsk(call.arguments);
108
+ if (!ask) return rejected("invalid-arguments");
109
+ return { status: "resolved", toolCallId: call.id, ask };
110
+ }
111
+
112
+ export function parseStoredAsk(value: unknown): Ask | undefined {
113
+ if (!Check(AskParamsSchema, value)) return undefined;
114
+ try {
115
+ return normalizeAsk(value as AskParams);
116
+ } catch {
117
+ return undefined;
118
+ }
119
+ }
120
+
121
+ /** Replace completed standalone Ask exchanges with their decision-focused answer. */
122
+ export function rewriteAskContext(messages: readonly AgentMessage[]): AgentMessage[] {
123
+ const calls = collectCalls(messages);
124
+ const nativeResults = collectNativeResults(messages);
125
+ const replayRecords = collectReplayRecords(messages);
126
+
127
+ const summaries = new Map<AgentMessage, AgentMessage>();
128
+ const removals = new Set<AgentMessage>();
129
+ for (const [toolCallId, call] of calls) {
130
+ if (!call) continue;
131
+
132
+ const results = nativeResults.get(toolCallId) ?? [];
133
+ if (results.length > 1) continue;
134
+ const result = results[0];
135
+
136
+ const replays = replayRecords.get(toolCallId) ?? [];
137
+ let answer: AskAnswer | undefined;
138
+ let replay: ReplayRecord | undefined;
139
+ if (replays.length > 0) {
140
+ if (replays.length !== 1) continue;
141
+ const candidate = replays[0]!;
142
+ if (!candidate.details || !answerMatchesAsk(candidate.details.answer, call.ask)) continue;
143
+ replay = candidate;
144
+ answer = candidate.details.answer;
145
+ }
146
+
147
+ if (result) {
148
+ if (result.toolName !== "ask") continue;
149
+ if (!replay) {
150
+ if (result.isError) continue;
151
+ const details = parseNativeDetails(result.details);
152
+ if (!details || !answerMatchesAsk(details.answer, call.ask)) continue;
153
+ answer = details.answer;
154
+ }
155
+ } else if (!replay) {
156
+ continue;
157
+ }
158
+
159
+ if (!answer) continue;
160
+ const timestamp = replay ? replay.message.timestamp : result!.timestamp;
161
+ summaries.set(call.message, makeSummary(call, answer, timestamp));
162
+ removals.add(call.message);
163
+ if (result) removals.add(result);
164
+ if (replay) removals.add(replay.message);
165
+ }
166
+
167
+ return messages.flatMap((message) => {
168
+ const summary = summaries.get(message);
169
+ if (summary) return [summary];
170
+ return removals.has(message) ? [] : [message];
171
+ });
172
+ }
173
+
174
+ function collectCalls(messages: readonly AgentMessage[]): Map<string, AskCall | undefined> {
175
+ const calls = new Map<string, AskCall | undefined>();
176
+ for (const message of messages) {
177
+ if (message.role !== "assistant") continue;
178
+ const toolCalls = message.content.filter(block => block.type === "toolCall");
179
+ for (const block of toolCalls) {
180
+ if (block.name !== "ask") continue;
181
+ const ask = toolCalls.length === 1 ? parseStoredAsk(block.arguments) : undefined;
182
+ const call = ask ? { ask, message } : undefined;
183
+ calls.set(block.id, calls.has(block.id) ? undefined : call);
184
+ }
185
+ }
186
+ return calls;
187
+ }
188
+
189
+ function collectNativeResults(messages: readonly AgentMessage[]): Map<string, ToolResultMessage[]> {
190
+ const results = new Map<string, ToolResultMessage[]>();
191
+ for (const message of messages) {
192
+ if (message.role !== "toolResult") continue;
193
+ const matching = results.get(message.toolCallId) ?? [];
194
+ matching.push(message);
195
+ results.set(message.toolCallId, matching);
196
+ }
197
+ return results;
198
+ }
199
+
200
+ function collectReplayRecords(messages: readonly AgentMessage[]): Map<string, ReplayRecord[]> {
201
+ const records = new Map<string, ReplayRecord[]>();
202
+ for (const message of messages) {
203
+ if (message.role !== "custom" || message.customType !== ASK_REPLAY_CUSTOM_TYPE) continue;
204
+ const rawToolCallId = isRecord(message.details) && typeof message.details.toolCallId === "string"
205
+ ? message.details.toolCallId
206
+ : undefined;
207
+ const details = parseAskReplayDetails(message.details);
208
+ const toolCallId = details?.toolCallId ?? rawToolCallId;
209
+ if (toolCallId === undefined) continue;
210
+ const matching = records.get(toolCallId) ?? [];
211
+ matching.push({ message, ...(details ? { details } : {}) });
212
+ records.set(toolCallId, matching);
213
+ }
214
+ return records;
215
+ }
216
+
217
+ function parseNativeDetails(value: unknown): { status: "answered"; answer: AskAnswer } | undefined {
218
+ return Check(AskAnsweredDetailsSchema, value) ? value : undefined;
219
+ }
220
+
221
+ function makeSummary(call: AskCall, answer: AskAnswer, timestamp: number): AgentMessage {
222
+ const payload: AskSummaryPayload = {
223
+ type: "ask_response",
224
+ question: call.ask.question,
225
+ ...(call.ask.context !== undefined ? { context: call.ask.context } : {}),
226
+ selectionMode: call.ask.allowMultiple ? "multi" : "single",
227
+ answer: {
228
+ selections: answer.selections.map((selection) => {
229
+ const option = call.ask.options[selection.option]!;
230
+ return {
231
+ label: option.label,
232
+ ...(option.description !== undefined ? { description: option.description } : {}),
233
+ ...(selection.comment !== undefined ? { comment: selection.comment } : {}),
234
+ };
235
+ }),
236
+ ...(answer.freeform !== undefined ? { freeform: answer.freeform } : {}),
237
+ },
238
+ };
239
+ return {
240
+ role: "custom",
241
+ customType: ASK_SUMMARY_CUSTOM_TYPE,
242
+ display: false,
243
+ content: JSON.stringify(payload),
244
+ timestamp,
245
+ };
246
+ }
247
+
248
+ function rejected(reason: Exclude<AskReplayResolution, { status: "resolved" }>["reason"]): AskReplayResolution {
249
+ return { status: "not-replayable", reason };
250
+ }
251
+
252
+ function isRecord(value: unknown): value is Record<string, unknown> {
253
+ return typeof value === "object" && value !== null && !Array.isArray(value);
254
+ }