@evandu/super-pi 0.1.39 → 0.1.41

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.
@@ -8,8 +8,8 @@
8
8
  <link rel="alternate icon" href="/favicon.ico" />
9
9
  <link rel="apple-touch-icon" href="/favicon.svg" />
10
10
  <title>Super Pi</title>
11
- <script type="module" crossorigin src="/assets/index-DSJuo0Bi.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-DX1yjXR8.css">
11
+ <script type="module" crossorigin src="/assets/index-Dihs8Ezc.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-DTd4g0h5.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -1,7 +1,7 @@
1
1
  import type { Store } from "../store.js";
2
2
  import type { AgentRuntime } from "../runtime.js";
3
3
  import type { SubagentRunRecorder } from "../subagent-runs.js";
4
- import type { Conversation, Message, MessageImage, PiQueueSnapshot, Workspace } from "../types.js";
4
+ import type { Conversation, Message, MessageImage, PiQueueSnapshot, QuestionAnswer, Workspace } from "../types.js";
5
5
  import type { EventBroadcaster } from "../server-context.js";
6
6
  import type { ExtensionUiService } from "./extension-ui-service.js";
7
7
  import type { WorkflowEngine } from "../workflow-engine.js";
@@ -56,20 +56,36 @@ export declare class RunService {
56
56
  private readonly detached;
57
57
  constructor(store: Store, runtime: AgentRuntime, subagents: SubagentRunRecorder, broadcaster: EventBroadcaster, extensionUiService: ExtensionUiService, agentsDir: string | string[], workflows: WorkflowEngine);
58
58
  setAgentsDir(dir: string | string[]): void;
59
+ /**
60
+ * RECORDS WHAT THE PERSON CHOSE, ON THE `question` CALL THAT ASKED.
61
+ *
62
+ * The call is addressed by its index in the message's `toolCalls`, which is the same addressing every
63
+ * other tool update uses - and the client can only get that index from the row it is already rendering.
64
+ */
59
65
  answerQuestion(command: {
60
66
  conversationId: string;
61
67
  messageId: string;
62
- questionId: string;
63
- selected?: string[];
64
- extraNote?: string;
68
+ callIndex: number;
69
+ answers?: QuestionAnswer[];
65
70
  cancelled?: boolean;
66
71
  }): Promise<void>;
67
- askQuestion(conversationId: string, question: {
68
- question: string;
69
- options: string[];
70
- multiple?: boolean;
71
- extraNote?: boolean;
72
- }): Promise<unknown>;
72
+ /**
73
+ * THE `question` TOOL BLOCKS HERE, ON THE CALL THAT ASKED IT.
74
+ *
75
+ * It used to APPEND A BLOCK TO THE MESSAGE and poll the store for an answer, which meant the question
76
+ * had no position in the answer at all: the card rendered after everything the model said, so the text
77
+ * streamed in response to the answer appeared ABOVE the card it was answering. The call is already on
78
+ * screen, already positioned by its `at`, already persisted, already reloads - so the pending wait is
79
+ * registered against it and nothing else is created.
80
+ *
81
+ * A DISMISSAL FAILS THE CALL. It used to resolve with `{cancelled:true}` and let the run continue, so
82
+ * closing a card handed the model a turn it would answer as though it had been told something. OpenCode
83
+ * is unambiguous about this and is the reference here: rejecting a question fails the deferred, the tool
84
+ * dies and the run is interrupted (`packages/core/src/question.ts:128-141`). Nothing is invented to fill
85
+ * the gap - the same rule the Extension UI path already follows, where a cancelled method is never
86
+ * backfilled with `options[0]`.
87
+ */
88
+ askQuestion(conversationId: string): Promise<unknown>;
73
89
  /**
74
90
  * Resolves the conversation and workspace synchronously for a run.
75
91
  */
@@ -74,30 +74,102 @@ export class RunService {
74
74
  setAgentsDir(dir) {
75
75
  this.agentsDir = dir;
76
76
  }
77
+ /**
78
+ * RECORDS WHAT THE PERSON CHOSE, ON THE `question` CALL THAT ASKED.
79
+ *
80
+ * The call is addressed by its index in the message's `toolCalls`, which is the same addressing every
81
+ * other tool update uses - and the client can only get that index from the row it is already rendering.
82
+ */
77
83
  async answerQuestion(command) {
78
- const blocks = await this.store.answerQuestion(command.conversationId, command.messageId, command.questionId, command);
79
- this.broadcaster.broadcast({ type: "message.questions", conversationId: command.conversationId, messageId: command.messageId, questionBlocks: blocks });
84
+ const existing = this.store.snapshot().conversations.find((item) => item.id === command.conversationId)?.messages.find((item) => item.id === command.messageId)?.toolCalls?.[command.callIndex]?.question;
85
+ if (!existing)
86
+ throw new Error("这个工具调用不是一个问题");
87
+ const calls = await this.store.updateToolCall(command.conversationId, command.messageId, command.callIndex, {
88
+ question: {
89
+ items: existing.items,
90
+ status: command.cancelled ? "cancelled" : "answered",
91
+ ...(command.cancelled ? {} : { answers: command.answers ?? [] }),
92
+ },
93
+ });
94
+ this.broadcaster.broadcast({ type: "message.tools", conversationId: command.conversationId, messageId: command.messageId, toolCalls: calls });
80
95
  }
81
- async askQuestion(conversationId, question) {
96
+ /**
97
+ * THE `question` TOOL BLOCKS HERE, ON THE CALL THAT ASKED IT.
98
+ *
99
+ * It used to APPEND A BLOCK TO THE MESSAGE and poll the store for an answer, which meant the question
100
+ * had no position in the answer at all: the card rendered after everything the model said, so the text
101
+ * streamed in response to the answer appeared ABOVE the card it was answering. The call is already on
102
+ * screen, already positioned by its `at`, already persisted, already reloads - so the pending wait is
103
+ * registered against it and nothing else is created.
104
+ *
105
+ * A DISMISSAL FAILS THE CALL. It used to resolve with `{cancelled:true}` and let the run continue, so
106
+ * closing a card handed the model a turn it would answer as though it had been told something. OpenCode
107
+ * is unambiguous about this and is the reference here: rejecting a question fails the deferred, the tool
108
+ * dies and the run is interrupted (`packages/core/src/question.ts:128-141`). Nothing is invented to fill
109
+ * the gap - the same rule the Extension UI path already follows, where a cancelled method is never
110
+ * backfilled with `options[0]`.
111
+ */
112
+ async askQuestion(conversationId) {
113
+ // THE ROW ARRIVES AFTER THE REQUEST, AND THAT IS A RACE, NOT A BUG IN THE CALLER.
114
+ //
115
+ // The extension lives INSIDE the Pi process. When Pi starts running the tool it writes
116
+ // `tool_execution_start` to stdout AND calls the tool - so the tool's HTTP request can reach this
117
+ // server while the transcript drain loop is still reading that very record off the pipe. The endpoint
118
+ // therefore asked for a tool call that had not been stored yet, and answered 「这个会话里没有正在
119
+ // 等待回答的 question 工具调用」 - which is a refusal, so the tool FAILED, and the person was never
120
+ // shown the question at all.
121
+ //
122
+ // So the lookup WAITS. A few hundred milliseconds of polling covers the drain, and a question that
123
+ // genuinely has no pending call still fails - just not instantly.
124
+ const findCall = () => {
125
+ const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
126
+ const message = conversation?.messages.slice().reverse().find((item) => item.role === "assistant");
127
+ if (!message)
128
+ return undefined;
129
+ for (let i = (message.toolCalls?.length ?? 0) - 1; i >= 0; i -= 1) {
130
+ const call = message.toolCalls?.[i];
131
+ if (call?.label === "question" && call.question?.status === "pending")
132
+ return { messageId: message.id, index: i };
133
+ }
134
+ return undefined;
135
+ };
136
+ let found;
137
+ for (let attempt = 0; attempt < 50 && !found; attempt += 1) {
138
+ found = findCall();
139
+ if (found)
140
+ break;
141
+ await new Promise((resolve) => setTimeout(resolve, 100));
142
+ }
82
143
  const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
83
- const message = conversation?.messages.slice().reverse().find((item) => item.role === "assistant");
84
- if (!message)
144
+ if (!conversation?.messages.length)
85
145
  throw new Error("No active assistant message");
86
- const block = await this.store.appendQuestion(conversationId, message.id, { question: question.question, options: question.options, multiple: question.multiple === true });
87
- this.broadcaster.broadcast({ type: "message.questions", conversationId, messageId: message.id, questionBlocks: [...(message.questionBlocks ?? []), block] });
146
+ if (!found)
147
+ throw new Error("这个会话里没有正在等待回答的 question 工具调用");
148
+ const { messageId, index } = found;
88
149
  return new Promise((resolve, reject) => {
89
- const timer = setInterval(() => {
90
- const current = this.store.snapshot().conversations.find((item) => item.id === conversationId)?.messages.find((item) => item.id === message.id)?.questionBlocks?.find((item) => item.id === block.id);
91
- if (current?.status === "answered") {
92
- clearInterval(timer);
93
- resolve({ selected: current.selected ?? [], extraNote: current.extraNote ?? "" });
94
- }
95
- else if (current?.status === "cancelled") {
96
- clearInterval(timer);
97
- resolve({ cancelled: true });
98
- }
150
+ const finish = (settle) => { clearInterval(poll); clearTimeout(deadline); settle(); };
151
+ const poll = setInterval(() => {
152
+ const current = this.store.snapshot().conversations.find((item) => item.id === conversationId)?.messages.find((item) => item.id === messageId)?.toolCalls?.[index]?.question;
153
+ if (current?.status === "answered")
154
+ finish(() => resolve(answerPayload(current)));
155
+ // CANCELLED IS NOT AN ANSWER. Rejecting is what makes the tool call fail, which is what stops the
156
+ // run; resolving here would let the model carry on from a question the person refused to answer.
157
+ else if (current?.status === "cancelled")
158
+ finish(() => reject(new Error("用户没有回答这个问题")));
99
159
  }, 250);
100
- setTimeout(() => { clearInterval(timer); reject(new Error("Question timed out")); }, 30 * 60 * 1000);
160
+ const deadline = setTimeout(() => finish(() => reject(new Error("等待回答超时"))), 30 * 60 * 1000);
161
+ // WHAT GOES BACK TO THE MODEL IS WRITTEN FOR THE MODEL. The answers pair to their questions by id,
162
+ // which is what stops a person answering the second of two being recorded against the first - but an
163
+ // id is a UUID, and a model handed `{answers:[{id:"671b0f0b-…", selected:["发"]}]}` has to guess at
164
+ // what that means. It guessed, and reported a real answer as a cancellation. So the answer goes back
165
+ // as its questions and its choices, with a sentence naming the outcome.
166
+ const answerPayload = (current) => {
167
+ const answers = (current.answers ?? []).map((answer) => {
168
+ const asked = current.items.find((item) => item.id === answer.id);
169
+ return { question: asked?.question, selected: answer.selected, ...(answer.note ? { note: answer.note } : {}) };
170
+ });
171
+ return { answers, summary: `用户回答:${answers.map((answer) => answer.selected.join("、")).join(";")}` };
172
+ };
101
173
  });
102
174
  }
103
175
  /**
@@ -317,13 +389,13 @@ export class RunService {
317
389
  // that never settles and one that belongs to a different call.
318
390
  if (call.id)
319
391
  rowByToolCallId.set(call.id, row);
320
- enqueueTools("a tool call", () => this.store.updateToolCall(conversation.id, assistant.id, row, { label: call.name, detail: call.detail, args: call.args, live: "", preparing: false }));
392
+ enqueueTools("a tool call", () => this.store.updateToolCall(conversation.id, assistant.id, row, { label: call.name, detail: call.detail, args: call.args, live: "", preparing: false, ...(call.question ? { question: call.question } : {}) }));
321
393
  return;
322
394
  }
323
395
  const row = rows++;
324
396
  if (call.id)
325
397
  rowByToolCallId.set(call.id, row);
326
- enqueueTools("a tool call", () => this.store.appendToolCall(conversation.id, assistant.id, { label: call.name, detail: call.detail, args: call.args, at }));
398
+ enqueueTools("a tool call", () => this.store.appendToolCall(conversation.id, assistant.id, { label: call.name, detail: call.detail, args: call.args, at, ...(call.question ? { question: call.question } : {}) }));
327
399
  };
328
400
  const onToolUpdate = (call) => {
329
401
  const index = call.id ? rowByToolCallId.get(call.id) : undefined;
@@ -349,7 +421,13 @@ export class RunService {
349
421
  onToolPreparing,
350
422
  onToolUpdate,
351
423
  onToolEnd,
352
- onThinking: (delta) => this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true }),
424
+ // BROADCAST AND KEEP. It used to only broadcast, into an event no one handled - so the reasoning
425
+ // reached nobody, live or afterwards. Both halves now happen: the browser draws the delta as it
426
+ // arrives, and the message keeps it so the finished turn has something to go back to.
427
+ onThinking: (delta) => {
428
+ this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true });
429
+ void this.store.appendThinkingDelta(conversation.id, assistant.id, delta);
430
+ },
353
431
  images,
354
432
  })) {
355
433
  await this.store.appendAssistantDelta(conversation.id, assistant.id, delta);
@@ -396,7 +396,7 @@ export function createHttpServer(options) {
396
396
  const body = JSON.parse(raw);
397
397
  if (!body.conversationId || !body.question || !Array.isArray(body.options))
398
398
  throw new Error("invalid question");
399
- const result = await runService.askQuestion(body.conversationId, { question: body.question, options: body.options, multiple: body.multiple });
399
+ const result = await runService.askQuestion(body.conversationId);
400
400
  response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(result));
401
401
  }
402
402
  catch (error) {
@@ -9,7 +9,7 @@ import { resolvePiCommand, shellQuote } from "./pi-command.js";
9
9
  import { TASK_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_ROSTER_ENV, TASK_TOOL_NAME, TODO_ENDPOINT_ENV, TODO_TOOL_NAME, WORKFLOW_ENDPOINT_ENV, WORKFLOW_TOOL_NAME, rosterPayload, } from "./task-tool.js";
10
10
  import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
11
11
  import { loadConversationImages } from "./upload.js";
12
- import { describeToolCall, describeToolCallArgs, describeToolCallInProgress, extractToolResultText } from "./tool-calls.js";
12
+ import { describeToolCall, describeToolCallArgs, describeToolCallInProgress, extractToolResultText, readQuestionCall } from "./tool-calls.js";
13
13
  /** Deterministic offline runtime used by tests and local UI development. */
14
14
  export class WelcomeRuntime {
15
15
  async *answer({ prompt, signal }) {
@@ -956,8 +956,12 @@ export class PiRpcRuntime {
956
956
  // word, because an `edit`'s arguments routinely hold a whole file's replacement text. See
957
957
  // tool-calls.ts for the argument-vs-transcript line.
958
958
  const name = record.toolName ?? "unknown";
959
+ // A `question` CALL is read here, at `tool_execution_start`, rather than assembled somewhere
960
+ // else when the answer arrives - so the card on screen is the tool call the model is actually
961
+ // blocked in, with the same identity, the same `at`, and the same lifecycle. `args` stays the
962
+ // display reduction; this is the one call whose arguments are also the transcript's data.
959
963
  try {
960
- onToolCall?.({ id: record.toolCallId ?? "", name, detail: describeToolCall(name, record.args), args: describeToolCallArgs(record.args) });
964
+ onToolCall?.({ id: record.toolCallId ?? "", name, detail: describeToolCall(name, record.args), args: describeToolCallArgs(record.args), ...(() => { const question = readQuestionCall(name, record.args); return question ? { question } : {}; })() });
961
965
  }
962
966
  catch (error) {
963
967
  console.warn(`PiRpcRuntime: the tool call handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
@@ -1,7 +1,7 @@
1
1
  import type { AppStateRepository } from "./persistence/app-state-repository.js";
2
2
  import { defaultJsonStatePath } from "./persistence/json-state-repository.js";
3
3
  import type { WorkflowRun } from "./workflow.js";
4
- import type { AppState, Conversation, Message, MessageImage, ModelSelection, QueuedMessage, SubagentRun, SubagentRunUpdate, TodoItem, ToolCallArg, ToolCallBlock, ToolResultSummary, Workspace } from "./types.js";
4
+ import type { AppState, Conversation, Message, MessageImage, ModelSelection, QueuedMessage, SubagentRun, SubagentRunUpdate, TodoItem, ToolCallQuestion, ToolCallArg, ToolCallBlock, ToolResultSummary, Workspace } from "./types.js";
5
5
  /** Mints the id a forked conversation will have, BEFORE its Pi transcript exists.
6
6
  *
7
7
  * Exported because the ordering demands it and no other arrangement provides it. Pi's copied file's header
@@ -211,6 +211,17 @@ export declare class Store {
211
211
  }): Promise<Conversation>;
212
212
  beginAssistantMessage(conversationId: string, agentIdentity?: Message["agentIdentity"]): Promise<Message>;
213
213
  appendAssistantDelta(conversationId: string, messageId: string, delta: string): Promise<void>;
214
+ /**
215
+ * THE MODEL'S REASONING, APPENDED TO THE MESSAGE IT BELONGS TO.
216
+ *
217
+ * It used to be broadcast and nothing more: the server emitted `message.thinking` on every delta and no
218
+ * code anywhere read it, so the reasoning reached no one - not the screen while it streamed, and not
219
+ * the transcript afterwards. The field, the event and the stylesheet were all already there; the two
220
+ * halves that would have used them were missing, which is why a finished turn had no visible reasoning
221
+ * to go back to. Persisting here rather than only in the browser is the point of its living on the
222
+ * message: a reasoning stream that vanishes on reload was never a record of anything.
223
+ */
224
+ appendThinkingDelta(conversationId: string, messageId: string, delta: string): Promise<void>;
214
225
  /**
215
226
  * Appends one tool call to the message it belongs to, and returns the message's whole `toolCalls`
216
227
  * array so the caller can broadcast it as one snapshot.
@@ -232,12 +243,6 @@ export declare class Store {
232
243
  * instead of as a field it does not understand.
233
244
  */
234
245
  appendToolCall(conversationId: string, messageId: string, call: ToolCallBlock): Promise<ToolCallBlock[]>;
235
- appendQuestion(conversationId: string, messageId: string, question: Omit<import("./types.js").QuestionBlock, "id" | "status">): Promise<import("./types.js").QuestionBlock>;
236
- answerQuestion(conversationId: string, messageId: string, questionId: string, answer: {
237
- selected?: string[];
238
- extraNote?: string;
239
- cancelled?: boolean;
240
- }): Promise<import("./types.js").QuestionBlock[]>;
241
246
  /**
242
247
  * Fills in the outcome of the call at `index`, or returns the array UNCHANGED when there is no such
243
248
  * index.
@@ -275,6 +280,7 @@ export declare class Store {
275
280
  args?: ToolCallArg[];
276
281
  live?: string;
277
282
  preparing?: boolean;
283
+ question?: ToolCallQuestion;
278
284
  }): Promise<ToolCallBlock[]>;
279
285
  setStatus(conversationId: string, status: Conversation["status"]): Promise<void>;
280
286
  /** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
@@ -864,6 +864,25 @@ export class Store {
864
864
  conversation.updatedAt = now();
865
865
  await this.persistTransaction({ kind: "message.replace", conversationId, message: structuredClone(message), updatedAt: conversation.updatedAt });
866
866
  }
867
+ /**
868
+ * THE MODEL'S REASONING, APPENDED TO THE MESSAGE IT BELONGS TO.
869
+ *
870
+ * It used to be broadcast and nothing more: the server emitted `message.thinking` on every delta and no
871
+ * code anywhere read it, so the reasoning reached no one - not the screen while it streamed, and not
872
+ * the transcript afterwards. The field, the event and the stylesheet were all already there; the two
873
+ * halves that would have used them were missing, which is why a finished turn had no visible reasoning
874
+ * to go back to. Persisting here rather than only in the browser is the point of its living on the
875
+ * message: a reasoning stream that vanishes on reload was never a record of anything.
876
+ */
877
+ async appendThinkingDelta(conversationId, messageId, delta) {
878
+ const conversation = this.requireConversation(conversationId);
879
+ const message = conversation.messages.find((item) => item.id === messageId);
880
+ if (!message)
881
+ throw new Error("Assistant message not found");
882
+ message.thinking = `${message.thinking ?? ""}${delta}`;
883
+ conversation.updatedAt = now();
884
+ await this.persistTransaction({ kind: "message.replace", conversationId, message: structuredClone(message), updatedAt: conversation.updatedAt });
885
+ }
867
886
  /**
868
887
  * Appends one tool call to the message it belongs to, and returns the message's whole `toolCalls`
869
888
  * array so the caller can broadcast it as one snapshot.
@@ -899,37 +918,6 @@ export class Store {
899
918
  await this.persist();
900
919
  return structuredClone(calls);
901
920
  }
902
- async appendQuestion(conversationId, messageId, question) {
903
- const message = this.requireConversation(conversationId).messages.find((item) => item.id === messageId);
904
- if (!message)
905
- throw new Error("Assistant message not found");
906
- // `at` is WHERE IN THE ANSWER THE QUESTION WAS ASKED - the same character offset a tool call carries,
907
- // and for the same reason. Without it a question is a list item with no position, so it renders after
908
- // the whole message; and because the question BLOCKS, everything the model says after the answer
909
- // lands in the text above it. Measured on a real turn: the model said nothing, asked, waited, and on
910
- // being answered streamed 「已经调用 question 工具问完,停下来等你回答」 - which rendered ABOVE the card
911
- // and read as though it had been said before the question was ever asked.
912
- const block = { ...structuredClone(question), id: id("question"), status: "pending", at: message.content.length };
913
- (message.questionBlocks ??= []).push(block);
914
- await this.persistTransaction({ kind: "message.replace", conversationId, message: structuredClone(message), updatedAt: now() });
915
- return structuredClone(block);
916
- }
917
- async answerQuestion(conversationId, messageId, questionId, answer) {
918
- const conversation = this.requireConversation(conversationId);
919
- const message = conversation.messages.find((item) => item.id === messageId);
920
- if (!message?.questionBlocks)
921
- throw new Error("Question not found");
922
- const block = message.questionBlocks.find((item) => item.id === questionId);
923
- if (!block || block.status !== "pending")
924
- throw new Error("Question is no longer pending");
925
- block.status = answer.cancelled ? "cancelled" : "answered";
926
- if (!answer.cancelled) {
927
- block.selected = [...(answer.selected ?? [])];
928
- block.extraNote = answer.extraNote ?? "";
929
- }
930
- await this.persistTransaction({ kind: "message.replace", conversationId, message: structuredClone(message), updatedAt: now() });
931
- return structuredClone(message.questionBlocks);
932
- }
933
921
  /**
934
922
  * Fills in the outcome of the call at `index`, or returns the array UNCHANGED when there is no such
935
923
  * index.
@@ -1010,6 +998,9 @@ export class Store {
1010
998
  }
1011
999
  if (patch.live !== undefined)
1012
1000
  call.live = patch.live;
1001
+ // The question rides on the call rather than beside it, so this is the only write it needs.
1002
+ if (patch.question !== undefined)
1003
+ call.question = patch.question;
1013
1004
  conversation.updatedAt = now();
1014
1005
  await this.persist();
1015
1006
  return structuredClone(calls);
@@ -1,4 +1,4 @@
1
- import type { ToolCallArg, ToolResultSummary } from "./types.js";
1
+ import type { ToolCallArg, ToolCallQuestion, ToolResultSummary } from "./types.js";
2
2
  /**
3
3
  * Turning one Pi tool call into something a human reads, and nothing more.
4
4
  *
@@ -104,3 +104,18 @@ export declare function describeToolCallInProgress(name: string, argumentsSoFar:
104
104
  * no parameter section rather than an empty one.
105
105
  */
106
106
  export declare function describeToolCallArgs(args: unknown): ToolCallArg[] | undefined;
107
+ /**
108
+ * READS A `question` CALL'S ARGUMENTS INTO THE SHAPE THE TRANSCRIPT RENDERS, OR NOTHING.
109
+ *
110
+ * Both shapes are accepted because the two references differ and both are in the wild: OpenCode sends
111
+ * `questions: [{ question, header, options: [{label, description}], multiple }]`
112
+ * (`packages/core/src/tool/question.ts:25`) and this project's own extension sends
113
+ * `{ question, options: string[], multiple?, extraNote? }`. A question with no options, or options that
114
+ * are not a list of strings, is not a question - so it returns nothing and the call renders as the
115
+ * ordinary tool call it is, rather than as a card with nothing in it.
116
+ *
117
+ * Ids are MINTED HERE when the caller did not send one, and that is what makes the answer pair by
118
+ * identity rather than by position: a person answering question 2 must not be recorded as answering
119
+ * question 1 because one of them was dropped.
120
+ */
121
+ export declare function readQuestionCall(name: string, args: unknown): ToolCallQuestion | undefined;
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  /**
2
3
  * Turning one Pi tool call into something a human reads, and nothing more.
3
4
  *
@@ -139,6 +140,15 @@ const oneLine = (value) => {
139
140
  * shape can be regression-tested at all.
140
141
  */
141
142
  export function describeToolCall(name, args) {
143
+ // A QUESTION NAMES ITSELF. The generic path reads a target-ish string out of the arguments and a
144
+ // question has none, so it fell through to the preparing placeholder - which left every settled
145
+ // question row headed 「正在生成参数…」 above the question itself. The row is the decision; the header
146
+ // should say which one.
147
+ if (name.trim().toLowerCase() === "question") {
148
+ const asked = readQuestionCall(name, args);
149
+ if (asked?.items.length)
150
+ return oneLine(asked.items.length === 1 ? asked.items[0].question : `${asked.items.length} 个问题`);
151
+ }
142
152
  if (args === null || typeof args !== "object" || Array.isArray(args))
143
153
  return undefined;
144
154
  const record = args;
@@ -252,3 +262,51 @@ export function describeToolCallArgs(args) {
252
262
  }
253
263
  return listed.length ? listed : undefined;
254
264
  }
265
+ /**
266
+ * READS A `question` CALL'S ARGUMENTS INTO THE SHAPE THE TRANSCRIPT RENDERS, OR NOTHING.
267
+ *
268
+ * Both shapes are accepted because the two references differ and both are in the wild: OpenCode sends
269
+ * `questions: [{ question, header, options: [{label, description}], multiple }]`
270
+ * (`packages/core/src/tool/question.ts:25`) and this project's own extension sends
271
+ * `{ question, options: string[], multiple?, extraNote? }`. A question with no options, or options that
272
+ * are not a list of strings, is not a question - so it returns nothing and the call renders as the
273
+ * ordinary tool call it is, rather than as a card with nothing in it.
274
+ *
275
+ * Ids are MINTED HERE when the caller did not send one, and that is what makes the answer pair by
276
+ * identity rather than by position: a person answering question 2 must not be recorded as answering
277
+ * question 1 because one of them was dropped.
278
+ */
279
+ export function readQuestionCall(name, args) {
280
+ if (name !== "question")
281
+ return undefined;
282
+ const raw = args;
283
+ if (!raw || typeof raw !== "object")
284
+ return undefined;
285
+ const source = Array.isArray(raw.questions) ? raw.questions : [raw];
286
+ const items = [];
287
+ for (const entry of source) {
288
+ if (!entry || typeof entry !== "object")
289
+ return undefined;
290
+ const item = entry;
291
+ if (typeof item.question !== "string" || !item.question.trim())
292
+ return undefined;
293
+ if (!Array.isArray(item.options))
294
+ return undefined;
295
+ const options = item.options.map((option) => (typeof option === "string" ? option : option?.label)).filter((option) => typeof option === "string" && option.trim().length > 0);
296
+ // NO OPTIONS IS NOT A QUESTION. A `question` call with an empty list would render a card the person
297
+ // cannot answer, and the tool would then block for thirty minutes on it.
298
+ if (!options.length)
299
+ return undefined;
300
+ items.push({
301
+ id: typeof item.id === "string" && item.id ? item.id : randomUUID(),
302
+ ...(typeof item.header === "string" && item.header ? { header: item.header } : {}),
303
+ question: item.question,
304
+ options,
305
+ multiple: item.multiple === true,
306
+ // Defaults ON, matching the extension's own schema: a question that asked for an optional note and
307
+ // did not get one should show the field, not silently drop it.
308
+ extraNote: item.extraNote !== false,
309
+ });
310
+ }
311
+ return items.length ? { items, status: "pending" } : undefined;
312
+ }
@@ -55,15 +55,36 @@ export type MessageImage = {
55
55
  width?: number;
56
56
  height?: number;
57
57
  };
58
- export type QuestionBlock = {
58
+ /** ONE QUESTION AS THE TOOL ASKED IT, before any answer exists.
59
+ * Mirrors OpenCode's `QuestionV2.Prompt`: a stable id so the answer pairs by identity rather than by
60
+ * position, a short header for a dock, and options with their own text. */
61
+ export type QuestionItem = {
59
62
  id: string;
63
+ header?: string;
60
64
  question: string;
61
65
  options: string[];
62
66
  multiple: boolean;
67
+ extraNote: boolean;
68
+ };
69
+ /** WHAT THE PERSON CHOSE, paired to a `QuestionItem` by id. */
70
+ export type QuestionAnswer = {
71
+ id: string;
72
+ selected: string[];
73
+ note?: string;
74
+ };
75
+ /**
76
+ * A QUESTION IS A TOOL CALL, NOT A SECOND KIND OF BLOCK.
77
+ *
78
+ * It used to be a `questionBlocks` array hung off the message, which meant the question had no place
79
+ * in the answer: it rendered after everything the model said, so the text streamed in response to the
80
+ * answer appeared ABOVE the card it was answering. The tool-call row already carries an `at` offset, is
81
+ * already persisted, already reloads, and already expands - so the question lives there too, and the
82
+ * ordering, the persistence and the replay all come from the mechanism that was already correct.
83
+ */
84
+ export type ToolCallQuestion = {
85
+ items: QuestionItem[];
63
86
  status: "pending" | "answered" | "cancelled";
64
- selected?: string[];
65
- extraNote?: string;
66
- at?: number;
87
+ answers?: QuestionAnswer[];
67
88
  };
68
89
  export type Message = {
69
90
  id: string;
@@ -72,7 +93,6 @@ export type Message = {
72
93
  createdAt: string;
73
94
  piEntryId?: string;
74
95
  toolCalls?: ToolCallBlock[];
75
- questionBlocks?: QuestionBlock[];
76
96
  thinking?: string;
77
97
  images?: MessageImage[];
78
98
  agentIdentity?: {
@@ -148,6 +168,7 @@ export type ToolCallBlock = {
148
168
  * replacement text, and without this the screen is blank until the last token arrives. */
149
169
  preparing?: boolean;
150
170
  at?: number;
171
+ question?: ToolCallQuestion;
151
172
  };
152
173
  /**
153
174
  * ONE TOOL CALL'S OUTPUT, reduced to the part a person reads and bounded so it cannot become the
@@ -178,6 +199,7 @@ export type ToolCallNotice = {
178
199
  args?: ToolCallArg[];
179
200
  status?: "ok" | "error";
180
201
  durationMs?: number;
202
+ question?: ToolCallQuestion;
181
203
  result?: ToolResultSummary;
182
204
  live?: string;
183
205
  preparing?: boolean;
@@ -692,13 +714,21 @@ export type ExtensionUiAnswer = {
692
714
  export type ClientCommand = ClientCommandBody & {
693
715
  operationRequestId?: string;
694
716
  };
695
- export type ClientCommandBody = {
717
+ export type ClientCommandBody =
718
+ /**
719
+ * THE ANSWER TO A `question` CALL, ADDRESSED BY THE CALL'S ROW.
720
+ *
721
+ * It used to name a `questionId` on a block hung off the message. It now names `callIndex` - the same
722
+ * index every other tool update uses - because the question IS that call, and the row being rendered is
723
+ * the only place the browser can learn the index from. Answers pair to questions by `id`, not position,
724
+ * so a person answering the second of two is not recorded as answering the first.
725
+ */
726
+ {
696
727
  type: "question.answer";
697
728
  conversationId: string;
698
729
  messageId: string;
699
- questionId: string;
700
- selected?: string[];
701
- extraNote?: string;
730
+ callIndex: number;
731
+ answers?: QuestionAnswer[];
702
732
  cancelled?: boolean;
703
733
  }
704
734
  /**
@@ -1051,11 +1081,6 @@ export type ServerEvent = {
1051
1081
  conversationId: string;
1052
1082
  messageId: string;
1053
1083
  toolCalls: ToolCallBlock[];
1054
- } | {
1055
- type: "message.questions";
1056
- conversationId: string;
1057
- messageId: string;
1058
- questionBlocks: QuestionBlock[];
1059
1084
  } | {
1060
1085
  type: "error";
1061
1086
  message: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",