@evandu/super-pi 0.1.39 → 0.1.40
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/agents/super-pi-task/index.ts +12 -1
- package/dist/client/assets/{index-DX1yjXR8.css → index-DuqJLo6R.css} +1 -1
- package/dist/client/assets/{index-DSJuo0Bi.js → index-Dxm2Vtis.js} +2 -1
- package/dist/client/index.html +2 -2
- package/dist/server/application/run-service.d.ts +26 -10
- package/dist/server/application/run-service.js +59 -18
- package/dist/server/http-server.js +1 -1
- package/dist/server/runtime.js +6 -2
- package/dist/server/store.d.ts +2 -7
- package/dist/server/store.js +3 -31
- package/dist/server/tool-calls.d.ts +16 -1
- package/dist/server/tool-calls.js +49 -0
- package/dist/server/types.d.ts +39 -14
- package/package.json +1 -1
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-Dxm2Vtis.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DuqJLo6R.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
|
-
|
|
63
|
-
|
|
64
|
-
extraNote?: string;
|
|
68
|
+
callIndex: number;
|
|
69
|
+
answers?: QuestionAnswer[];
|
|
65
70
|
cancelled?: boolean;
|
|
66
71
|
}): Promise<void>;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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,71 @@ 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
|
|
79
|
-
|
|
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
|
-
|
|
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) {
|
|
82
113
|
const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
|
|
83
114
|
const message = conversation?.messages.slice().reverse().find((item) => item.role === "assistant");
|
|
84
115
|
if (!message)
|
|
85
116
|
throw new Error("No active assistant message");
|
|
86
|
-
|
|
87
|
-
|
|
117
|
+
// THE CALL, NOT THE MESSAGE. The newest `question` call still marked pending is the one the model is
|
|
118
|
+
// blocked inside; anything else would attach an answer to a question nobody is waiting on.
|
|
119
|
+
let index = -1;
|
|
120
|
+
for (let i = (message.toolCalls?.length ?? 0) - 1; i >= 0; i -= 1) {
|
|
121
|
+
const call = message.toolCalls?.[i];
|
|
122
|
+
if (call?.label === "question" && call.question?.status === "pending") {
|
|
123
|
+
index = i;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (index < 0)
|
|
128
|
+
throw new Error("这个会话里没有正在等待回答的 question 工具调用");
|
|
129
|
+
const messageId = message.id;
|
|
88
130
|
return new Promise((resolve, reject) => {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
resolve({
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
131
|
+
const finish = (settle) => { clearInterval(poll); clearTimeout(deadline); settle(); };
|
|
132
|
+
const poll = setInterval(() => {
|
|
133
|
+
const current = this.store.snapshot().conversations.find((item) => item.id === conversationId)?.messages.find((item) => item.id === messageId)?.toolCalls?.[index]?.question;
|
|
134
|
+
if (current?.status === "answered")
|
|
135
|
+
finish(() => resolve({ answers: current.answers ?? [] }));
|
|
136
|
+
// CANCELLED IS NOT AN ANSWER. Rejecting is what makes the tool call fail, which is what stops the
|
|
137
|
+
// run; resolving here would let the model carry on from a question the person refused to answer.
|
|
138
|
+
else if (current?.status === "cancelled")
|
|
139
|
+
finish(() => reject(new Error("用户没有回答这个问题")));
|
|
99
140
|
}, 250);
|
|
100
|
-
setTimeout(() =>
|
|
141
|
+
const deadline = setTimeout(() => finish(() => reject(new Error("等待回答超时"))), 30 * 60 * 1000);
|
|
101
142
|
});
|
|
102
143
|
}
|
|
103
144
|
/**
|
|
@@ -317,13 +358,13 @@ export class RunService {
|
|
|
317
358
|
// that never settles and one that belongs to a different call.
|
|
318
359
|
if (call.id)
|
|
319
360
|
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 }));
|
|
361
|
+
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
362
|
return;
|
|
322
363
|
}
|
|
323
364
|
const row = rows++;
|
|
324
365
|
if (call.id)
|
|
325
366
|
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 }));
|
|
367
|
+
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
368
|
};
|
|
328
369
|
const onToolUpdate = (call) => {
|
|
329
370
|
const index = call.id ? rowByToolCallId.get(call.id) : undefined;
|
|
@@ -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
|
|
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) {
|
package/dist/server/runtime.js
CHANGED
|
@@ -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"}`);
|
package/dist/server/store.d.ts
CHANGED
|
@@ -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
|
|
@@ -232,12 +232,6 @@ export declare class Store {
|
|
|
232
232
|
* instead of as a field it does not understand.
|
|
233
233
|
*/
|
|
234
234
|
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
235
|
/**
|
|
242
236
|
* Fills in the outcome of the call at `index`, or returns the array UNCHANGED when there is no such
|
|
243
237
|
* index.
|
|
@@ -275,6 +269,7 @@ export declare class Store {
|
|
|
275
269
|
args?: ToolCallArg[];
|
|
276
270
|
live?: string;
|
|
277
271
|
preparing?: boolean;
|
|
272
|
+
question?: ToolCallQuestion;
|
|
278
273
|
}): Promise<ToolCallBlock[]>;
|
|
279
274
|
setStatus(conversationId: string, status: Conversation["status"]): Promise<void>;
|
|
280
275
|
/** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
|
package/dist/server/store.js
CHANGED
|
@@ -899,37 +899,6 @@ export class Store {
|
|
|
899
899
|
await this.persist();
|
|
900
900
|
return structuredClone(calls);
|
|
901
901
|
}
|
|
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
902
|
/**
|
|
934
903
|
* Fills in the outcome of the call at `index`, or returns the array UNCHANGED when there is no such
|
|
935
904
|
* index.
|
|
@@ -1010,6 +979,9 @@ export class Store {
|
|
|
1010
979
|
}
|
|
1011
980
|
if (patch.live !== undefined)
|
|
1012
981
|
call.live = patch.live;
|
|
982
|
+
// The question rides on the call rather than beside it, so this is the only write it needs.
|
|
983
|
+
if (patch.question !== undefined)
|
|
984
|
+
call.question = patch.question;
|
|
1013
985
|
conversation.updatedAt = now();
|
|
1014
986
|
await this.persist();
|
|
1015
987
|
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
|
*
|
|
@@ -252,3 +253,51 @@ export function describeToolCallArgs(args) {
|
|
|
252
253
|
}
|
|
253
254
|
return listed.length ? listed : undefined;
|
|
254
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* READS A `question` CALL'S ARGUMENTS INTO THE SHAPE THE TRANSCRIPT RENDERS, OR NOTHING.
|
|
258
|
+
*
|
|
259
|
+
* Both shapes are accepted because the two references differ and both are in the wild: OpenCode sends
|
|
260
|
+
* `questions: [{ question, header, options: [{label, description}], multiple }]`
|
|
261
|
+
* (`packages/core/src/tool/question.ts:25`) and this project's own extension sends
|
|
262
|
+
* `{ question, options: string[], multiple?, extraNote? }`. A question with no options, or options that
|
|
263
|
+
* are not a list of strings, is not a question - so it returns nothing and the call renders as the
|
|
264
|
+
* ordinary tool call it is, rather than as a card with nothing in it.
|
|
265
|
+
*
|
|
266
|
+
* Ids are MINTED HERE when the caller did not send one, and that is what makes the answer pair by
|
|
267
|
+
* identity rather than by position: a person answering question 2 must not be recorded as answering
|
|
268
|
+
* question 1 because one of them was dropped.
|
|
269
|
+
*/
|
|
270
|
+
export function readQuestionCall(name, args) {
|
|
271
|
+
if (name !== "question")
|
|
272
|
+
return undefined;
|
|
273
|
+
const raw = args;
|
|
274
|
+
if (!raw || typeof raw !== "object")
|
|
275
|
+
return undefined;
|
|
276
|
+
const source = Array.isArray(raw.questions) ? raw.questions : [raw];
|
|
277
|
+
const items = [];
|
|
278
|
+
for (const entry of source) {
|
|
279
|
+
if (!entry || typeof entry !== "object")
|
|
280
|
+
return undefined;
|
|
281
|
+
const item = entry;
|
|
282
|
+
if (typeof item.question !== "string" || !item.question.trim())
|
|
283
|
+
return undefined;
|
|
284
|
+
if (!Array.isArray(item.options))
|
|
285
|
+
return undefined;
|
|
286
|
+
const options = item.options.map((option) => (typeof option === "string" ? option : option?.label)).filter((option) => typeof option === "string" && option.trim().length > 0);
|
|
287
|
+
// NO OPTIONS IS NOT A QUESTION. A `question` call with an empty list would render a card the person
|
|
288
|
+
// cannot answer, and the tool would then block for thirty minutes on it.
|
|
289
|
+
if (!options.length)
|
|
290
|
+
return undefined;
|
|
291
|
+
items.push({
|
|
292
|
+
id: typeof item.id === "string" && item.id ? item.id : randomUUID(),
|
|
293
|
+
...(typeof item.header === "string" && item.header ? { header: item.header } : {}),
|
|
294
|
+
question: item.question,
|
|
295
|
+
options,
|
|
296
|
+
multiple: item.multiple === true,
|
|
297
|
+
// Defaults ON, matching the extension's own schema: a question that asked for an optional note and
|
|
298
|
+
// did not get one should show the field, not silently drop it.
|
|
299
|
+
extraNote: item.extraNote !== false,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return items.length ? { items, status: "pending" } : undefined;
|
|
303
|
+
}
|
package/dist/server/types.d.ts
CHANGED
|
@@ -55,15 +55,36 @@ export type MessageImage = {
|
|
|
55
55
|
width?: number;
|
|
56
56
|
height?: number;
|
|
57
57
|
};
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
700
|
-
|
|
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;
|