@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.
- package/agents/super-pi-task/index.ts +29 -1
- package/dist/client/assets/{index-DX1yjXR8.css → index-DTd4g0h5.css} +1 -1
- package/dist/client/assets/{index-DSJuo0Bi.js → index-Dihs8Ezc.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 +99 -21
- package/dist/server/http-server.js +1 -1
- package/dist/server/runtime.js +6 -2
- package/dist/server/store.d.ts +13 -7
- package/dist/server/store.js +22 -31
- package/dist/server/tool-calls.d.ts +16 -1
- package/dist/server/tool-calls.js +58 -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-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
|
-
|
|
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,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
|
|
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) {
|
|
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
|
-
|
|
84
|
-
if (!message)
|
|
144
|
+
if (!conversation?.messages.length)
|
|
85
145
|
throw new Error("No active assistant message");
|
|
86
|
-
|
|
87
|
-
|
|
146
|
+
if (!found)
|
|
147
|
+
throw new Error("这个会话里没有正在等待回答的 question 工具调用");
|
|
148
|
+
const { messageId, index } = found;
|
|
88
149
|
return new Promise((resolve, reject) => {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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(() =>
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -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. */
|
package/dist/server/store.js
CHANGED
|
@@ -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
|
+
}
|
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;
|