@evandu/super-pi 0.1.89 → 0.1.91
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/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +5 -5
- package/dist/client/assets/index-BcHIMxNF.css +1 -0
- package/dist/client/assets/index-DYjBTgl4.js +73 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/conversation-service.js +29 -3
- package/dist/server/application/run-service.d.ts +27 -0
- package/dist/server/application/run-service.js +157 -28
- package/dist/server/command-handler.js +4 -0
- package/dist/server/store.d.ts +24 -22
- package/dist/server/store.js +74 -36
- package/dist/server/types.d.ts +19 -1
- package/package.json +1 -1
- package/dist/client/assets/index-Bpy8UeU1.js +0 -73
- package/dist/client/assets/index-CC-Q5gNQ.css +0 -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-DYjBTgl4.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BcHIMxNF.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
|
@@ -37,9 +37,35 @@ export class ConversationService {
|
|
|
37
37
|
const workspace = this.store.snapshot().workspaces.find(item => item.id === workspaceId);
|
|
38
38
|
if (!workspace)
|
|
39
39
|
throw new Error("Workspace not found");
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
// Workspace defaults are copied at birth, but Pi's model list can change after the
|
|
41
|
+
// setting was saved (for example when a model is removed from Pi's config). Do not
|
|
42
|
+
// stamp a dead selection onto every new conversation: the scoped catalog probe would
|
|
43
|
+
// fail and leave the model/thinking controls unavailable. A missing or invalid default
|
|
44
|
+
// means "follow Pi's current default" for this newly-created conversation.
|
|
45
|
+
let model = workspace.defaultModel;
|
|
46
|
+
let thinkingLevel = workspace.defaultThinkingLevel;
|
|
47
|
+
const catalog = model || thinkingLevel ? await this.runtime.catalog() : undefined;
|
|
48
|
+
if (catalog?.source === "pi" && model) {
|
|
49
|
+
const option = catalog.models.find(item => item.provider === model?.provider && item.modelId === model?.modelId);
|
|
50
|
+
if (!option) {
|
|
51
|
+
model = undefined;
|
|
52
|
+
thinkingLevel = undefined;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
model = { provider: option.provider, modelId: option.modelId, label: option.label };
|
|
56
|
+
if (thinkingLevel) {
|
|
57
|
+
const scoped = await this.runtime.catalog(model);
|
|
58
|
+
if (scoped.source === "pi" && !scoped.levels.includes(thinkingLevel))
|
|
59
|
+
thinkingLevel = undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const resolved = workspace.defaultAgentId
|
|
64
|
+
? await this.resolveAgentConfiguration({ id: workspaceId, model, thinkingLevel }, workspace.defaultAgentId, workspaceId)
|
|
65
|
+
: { model, thinkingLevel };
|
|
66
|
+
// `null` is intentional here: Store uses it to remove a workspace value that it
|
|
67
|
+
// would otherwise have copied before applying the resolved configuration.
|
|
68
|
+
const defaults = { ...resolved, model: resolved.model ?? null, thinkingLevel: resolved.thinkingLevel ?? null };
|
|
43
69
|
const created = await this.store.createConversation(workspaceId, defaults);
|
|
44
70
|
this.broadcaster.broadcastState();
|
|
45
71
|
return created.id;
|
|
@@ -99,6 +99,12 @@ export declare class RunService {
|
|
|
99
99
|
/**
|
|
100
100
|
* Called by the exec completion handler in index.ts when a background exec session ends.
|
|
101
101
|
* Injects the result into the conversation and cleans up the background task record.
|
|
102
|
+
*
|
|
103
|
+
* The result is now a REAL queue item, not a one-way notice the model never gets to read: it lands
|
|
104
|
+
* with `delivery: "waiting"` (so the dock shows it), at the front of the waiting set (so any pending
|
|
105
|
+
* user sends wait behind it), and the conversation auto-starts a turn for the agent to handle it
|
|
106
|
+
* when nothing else is busy. The render stays as a 后台回传 block throughout - in dock and in
|
|
107
|
+
* transcript - so a `role: "user"` is never impersonated.
|
|
102
108
|
*/
|
|
103
109
|
enqueueExecResult(sessionId: string, exitCode: number, stdout: string, stderr: string): void;
|
|
104
110
|
/** Per-conversation command lane. All queue mutations and run claims use the same lane, so
|
|
@@ -233,6 +239,27 @@ export declare class RunService {
|
|
|
233
239
|
steer(conversationId: string, messageId: string): Promise<void>;
|
|
234
240
|
/** Starts a chosen waiting message as the next turn, only when the conversation is idle. */
|
|
235
241
|
sendQueued(conversationId: string, messageId: string): Promise<void>;
|
|
242
|
+
/**
|
|
243
|
+
* Stops the current turn and immediately gives one waiting message its own fresh turn.
|
|
244
|
+
*
|
|
245
|
+
* `steer` cannot provide this meaning: Pi only reads its steering queue after the current tool batch
|
|
246
|
+
* finishes. Stopping first is what makes a long-running tool give way now. `sendQueued` already owns
|
|
247
|
+
* the hand-off window after a stop, so it waits until the old run has released its slot before opening
|
|
248
|
+
* the selected message. That preserves the one-Pi-per-conversation invariant.
|
|
249
|
+
*/
|
|
250
|
+
interruptQueued(conversationId: string, messageId: string): Promise<void>;
|
|
251
|
+
/**
|
|
252
|
+
* The lower-level "wait for run slot, open the turn on a specific waiting message, run it" sequence.
|
|
253
|
+
*
|
|
254
|
+
* Called from two places: the user-facing `sendQueued` (which adds a stop-clearing grace window
|
|
255
|
+
* before this) and the auto-trigger paths in `enqueueExecResult` and `dispatchTask`'s background
|
|
256
|
+
* branch (which only call it when `!this.activeRuns.has(conversationId)`). The two callers differ
|
|
257
|
+
* only in who arrived first; the open-turn-and-run contract is the same.
|
|
258
|
+
*
|
|
259
|
+
* `composePrompt` is what makes the `role: "tool"` path real - the `<task>` envelope stays in the
|
|
260
|
+
* transcript, but the prose form of the same fact is what reaches Pi.
|
|
261
|
+
*/
|
|
262
|
+
private processWaitingMessage;
|
|
236
263
|
private steerUnsafe;
|
|
237
264
|
/**
|
|
238
265
|
* Withdraws a waiting message.
|
|
@@ -98,6 +98,35 @@ async function expandAtReferences(store, workspace, content) {
|
|
|
98
98
|
}
|
|
99
99
|
return blocks.length ? `${content}\n${blocks.join("\n")}` : content;
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* The text the model is handed when a waiting message becomes its turn.
|
|
103
|
+
*
|
|
104
|
+
* `role: "user"` is the user's own words - pass through verbatim. `role: "tool"` is a 后台回传 block
|
|
105
|
+
* whose `message.content` is the `<task>` envelope the transcript and dock render from; handing the raw
|
|
106
|
+
* XML to Pi asks it to parse the format itself, which is the "未定义行为" path that 0.1.66 worked around
|
|
107
|
+
* by simply not triggering a turn. We DO trigger a turn now (queue semantics), so we synthesise prose
|
|
108
|
+
* the model can read like any other user message.
|
|
109
|
+
*
|
|
110
|
+
* The envelope and the prose are NOT redundant: the envelope is what the reader sees in the transcript
|
|
111
|
+
* column, what they cancel or reorder; the prose is what leaves the server for Pi.
|
|
112
|
+
*/
|
|
113
|
+
function composePrompt(message) {
|
|
114
|
+
if (message.role !== "tool")
|
|
115
|
+
return message.content;
|
|
116
|
+
const content = message.content;
|
|
117
|
+
const summaryMatch = /<summary>([\s\S]*?)<\/summary>/.exec(content);
|
|
118
|
+
const summary = summaryMatch ? summaryMatch[1].trim() : "后台任务";
|
|
119
|
+
const stateMatch = /<task id="[^"]+" state="([^"]+)"/.exec(content);
|
|
120
|
+
const state = stateMatch ? stateMatch[1] : "completed";
|
|
121
|
+
const bodyMatch = /<task_(?:result|error)>\n?([\s\S]*?)\n?<\/(?:task_result|task_error)>/.exec(content);
|
|
122
|
+
const body = bodyMatch ? bodyMatch[1] : content;
|
|
123
|
+
const stateLabel = state === "completed" ? "已完成" : state === "error" ? "失败" : state;
|
|
124
|
+
// The marker `@bgdelivery:` is at the end so the visible prose is what the model sees (it can ignore
|
|
125
|
+
// a trailing `@`-token, which is what its other tools do too). The fake-pi fixture recognises the
|
|
126
|
+
// marker and settles immediately so test paths through this turn do not hang on the BLOCKING
|
|
127
|
+
// extension-UI default. A real model treats the marker as part of the user's prose.
|
|
128
|
+
return `[后台任务「${summary}」${stateLabel}]\n\n${body}\n\n@bgdelivery:${state}`;
|
|
129
|
+
}
|
|
101
130
|
/**
|
|
102
131
|
* Service managing execution of conversation turns, message queueing,
|
|
103
132
|
* follow-ups, and sub-agent dispatching.
|
|
@@ -187,6 +216,12 @@ export class RunService {
|
|
|
187
216
|
/**
|
|
188
217
|
* Called by the exec completion handler in index.ts when a background exec session ends.
|
|
189
218
|
* Injects the result into the conversation and cleans up the background task record.
|
|
219
|
+
*
|
|
220
|
+
* The result is now a REAL queue item, not a one-way notice the model never gets to read: it lands
|
|
221
|
+
* with `delivery: "waiting"` (so the dock shows it), at the front of the waiting set (so any pending
|
|
222
|
+
* user sends wait behind it), and the conversation auto-starts a turn for the agent to handle it
|
|
223
|
+
* when nothing else is busy. The render stays as a 后台回传 block throughout - in dock and in
|
|
224
|
+
* transcript - so a `role: "user"` is never impersonated.
|
|
190
225
|
*/
|
|
191
226
|
enqueueExecResult(sessionId, exitCode, stdout, stderr) {
|
|
192
227
|
const tasks = this.store.snapshot().backgroundTasks;
|
|
@@ -195,10 +230,6 @@ export class RunService {
|
|
|
195
230
|
return;
|
|
196
231
|
const tag = exitCode === 0 ? "task_result" : "task_error";
|
|
197
232
|
const body = exitCode === 0 ? stdout : (stderr || `Command exited with code ${exitCode}`);
|
|
198
|
-
// The result becomes a `role: "tool"` message in the conversation, not a user prompt for a fresh
|
|
199
|
-
// turn. The transcript now has a 「后台回传」 column the client renders, and the model sees the
|
|
200
|
-
// result only when something else starts a turn that includes it. Asking the model to consume a
|
|
201
|
-
// `<task>` envelope as user prose is the "undefined behaviour" path the user reported.
|
|
202
233
|
const message = `<task id="${sessionId}" state="${exitCode === 0 ? "completed" : "error"}" kind="exec">\n<summary>${task.title}</summary>\n<${tag}>\n${body}</${tag}>\n</task>`;
|
|
203
234
|
void this.trackDetached((async () => {
|
|
204
235
|
try {
|
|
@@ -211,8 +242,31 @@ export class RunService {
|
|
|
211
242
|
this.broadcaster.broadcast({ type: "message.tools", conversationId: task.conversationId, messageId: task.originMessageId, toolCalls });
|
|
212
243
|
}
|
|
213
244
|
this.broadcaster.broadcast({ type: "exec.exit", sessionId, exitCode, stdout, stderr });
|
|
214
|
-
|
|
245
|
+
// QUEUE ITEM, NOT A NOTICE. `delivery: "waiting"` is what makes the row show in the dock; without
|
|
246
|
+
// it the message is a transcript entry only and `takeNextWaitingMessage` (which `runConversationTurns`
|
|
247
|
+
// calls after every turn) never picks it up.
|
|
248
|
+
const queued = await this.store.addToolMessage(task.conversationId, message, { delivery: "waiting" });
|
|
215
249
|
this.broadcaster.broadcastState();
|
|
250
|
+
// STEER PRIORITY. If anything else is already waiting, jump this one to the front of the queue
|
|
251
|
+
// so a 后台回传 cuts ahead of user sends - matching "按插队逻辑来处理".
|
|
252
|
+
const waiting = this.store.listWaitingMessages(task.conversationId);
|
|
253
|
+
if (waiting.length > 1) {
|
|
254
|
+
await this.store.reorderWaitingMessage(task.conversationId, queued.id, 0);
|
|
255
|
+
this.broadcaster.broadcastState();
|
|
256
|
+
}
|
|
257
|
+
// AUTO-TRIGGER WHEN IDLE. The conversation may already be busy (running turn, taking the next
|
|
258
|
+
// waiting message in its own follow-up loop); if so, the existing loop picks this one up and
|
|
259
|
+
// this branch is a no-op. If idle, kick off a fresh turn so the model processes the result
|
|
260
|
+
// without the user having to send anything - the failure case is intentionally included: the
|
|
261
|
+
// model seeing "[后台任务 X 失败]" is part of the same "after current AI finishes, automatically
|
|
262
|
+
// processes the result" contract, and the auto-triggered turn goes through the same
|
|
263
|
+
// `composePrompt` path the success case does (so a helper failure surfaces as `state="error"`
|
|
264
|
+
// prose the agent can react to).
|
|
265
|
+
if (!this.activeRuns.has(task.conversationId)) {
|
|
266
|
+
void this.processWaitingMessage(task.conversationId, queued.id).catch((error) => {
|
|
267
|
+
console.warn(`Super Pi: auto-trigger for background exec result failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
216
270
|
}
|
|
217
271
|
catch (error) {
|
|
218
272
|
console.warn(`Super Pi: exec result could not be appended as a tool message: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
@@ -488,6 +542,22 @@ export class RunService {
|
|
|
488
542
|
let assistant = assistantMessage;
|
|
489
543
|
let toolCalls = [];
|
|
490
544
|
let pendingTools = Promise.resolve();
|
|
545
|
+
// A TURN MOVES FROM `waiting` TO `running` ONCE THE MODEL HAS PRODUCED ANY SIGNAL: a text delta
|
|
546
|
+
// OR a tool-call notice. The `running` state means "we have data flowing", and that is true the
|
|
547
|
+
// moment Pi yields anything back, regardless of shape. Without this, a pure tool-call turn would
|
|
548
|
+
// stay in `waiting` until the very end and the reader would see a stuck turn for the whole of a
|
|
549
|
+
// long tool execution. The flag is the only way to make the flip one-shot: calling `setStatus` on
|
|
550
|
+
// every delta would be a per-token status write.
|
|
551
|
+
let firstSignal = true;
|
|
552
|
+
const promoteToRunning = async () => {
|
|
553
|
+
if (!firstSignal)
|
|
554
|
+
return;
|
|
555
|
+
firstSignal = false;
|
|
556
|
+
if (this.store.snapshot().conversations.find((item) => item.id === conversation.id)?.status === "waiting") {
|
|
557
|
+
await this.store.setStatus(conversation.id, "running");
|
|
558
|
+
this.broadcaster.broadcastState();
|
|
559
|
+
}
|
|
560
|
+
};
|
|
491
561
|
// Live events are independent from durable checkpoints. Persisting every token made the stdout
|
|
492
562
|
// reader wait for one disk transaction per token; over a remote browser connection that backlog
|
|
493
563
|
// looked exactly like a slow model. Keep the in-memory transcript current, publish immediately, and
|
|
@@ -516,6 +586,12 @@ export class RunService {
|
|
|
516
586
|
};
|
|
517
587
|
const enqueueTools = (what, work) => {
|
|
518
588
|
pendingTools = pendingTools.then(async () => {
|
|
589
|
+
// A TOOL CALL IS A SIGNAL THE MODEL IS ALIVE. Promote `waiting` → `running` on the same
|
|
590
|
+
// path as text deltas: a tool row appearing on screen is the same "we have data" event
|
|
591
|
+
// the status needs to reflect. Doing it BEFORE the tool write keeps the order
|
|
592
|
+
// honest - a client that saw the new tool row in the same frame also saw the running
|
|
593
|
+
// status, instead of flashing back to a still-spinning waiting mark for the next paint.
|
|
594
|
+
await promoteToRunning();
|
|
519
595
|
toolCalls = await work();
|
|
520
596
|
this.broadcaster.broadcast({ type: "message.tools", conversationId: conversation.id, messageId: assistant.id, toolCalls });
|
|
521
597
|
}).catch((error) => {
|
|
@@ -799,6 +875,12 @@ export class RunService {
|
|
|
799
875
|
// publish a new message underneath it. One `await` at the top covers both, and costs nothing:
|
|
800
876
|
// the lane is empty most of the time.
|
|
801
877
|
await pendingTools;
|
|
878
|
+
// `waiting` → `running` ON THE FIRST DELTA, before the live write. The conversation was
|
|
879
|
+
// opened in `waiting` (see `openTurn` / `openTurnOnSpecificMessage` / `openTurnOnMessage`),
|
|
880
|
+
// and the first response token is the observable fact that says the LLM is actually
|
|
881
|
+
// answering rather than hanging on auth / quota / network. `promoteToRunning` is a no-op
|
|
882
|
+
// after the first signal so this stays a one-shot status write, not a per-token one.
|
|
883
|
+
await promoteToRunning();
|
|
802
884
|
this.store.appendAssistantDeltaLive(conversation.id, assistant.id, delta);
|
|
803
885
|
spoken += delta.length;
|
|
804
886
|
// Tool events are structural transcript boundaries. Publish any boundary Pi announced before
|
|
@@ -870,7 +952,9 @@ export class RunService {
|
|
|
870
952
|
const followUp = await this.store.openTurnOnMessage(conversation.id, next.id, identity);
|
|
871
953
|
this.broadcaster.broadcastState();
|
|
872
954
|
const current = this.turnContext(conversation.id).conversation;
|
|
873
|
-
|
|
955
|
+
// `composePrompt` synthesises the prose for a 后台回传 (the `<task>` envelope is what the transcript
|
|
956
|
+
// column reads from; the prose is what leaves the server for Pi). For user messages it is a no-op.
|
|
957
|
+
await this.streamTurn(current, workspace, composePrompt(next), followUp.assistant, controller, followUp.user.id, next.images, identity);
|
|
874
958
|
}
|
|
875
959
|
}
|
|
876
960
|
finally {
|
|
@@ -1024,22 +1108,42 @@ export class RunService {
|
|
|
1024
1108
|
}
|
|
1025
1109
|
/** Starts a chosen waiting message as the next turn, only when the conversation is idle. */
|
|
1026
1110
|
async sendQueued(conversationId, messageId) {
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1111
|
+
// A stop broadcasts `stopped` before the previous run has finished its finally block. Allow that
|
|
1112
|
+
// short cleanup window; a click on the newly visible button must not race the old run slot.
|
|
1113
|
+
const deadline = Date.now() + 15_000;
|
|
1114
|
+
while (this.activeRuns.has(conversationId) && this.store.snapshot().conversations.find((item) => item.id === conversationId)?.status === "stopped" && Date.now() < deadline) {
|
|
1115
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1116
|
+
}
|
|
1117
|
+
await this.processWaitingMessage(conversationId, messageId);
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Stops the current turn and immediately gives one waiting message its own fresh turn.
|
|
1121
|
+
*
|
|
1122
|
+
* `steer` cannot provide this meaning: Pi only reads its steering queue after the current tool batch
|
|
1123
|
+
* finishes. Stopping first is what makes a long-running tool give way now. `sendQueued` already owns
|
|
1124
|
+
* the hand-off window after a stop, so it waits until the old run has released its slot before opening
|
|
1125
|
+
* the selected message. That preserves the one-Pi-per-conversation invariant.
|
|
1126
|
+
*/
|
|
1127
|
+
async interruptQueued(conversationId, messageId) {
|
|
1128
|
+
if (this.activeRuns.has(conversationId))
|
|
1129
|
+
await this.stopRun(conversationId);
|
|
1130
|
+
await this.sendQueued(conversationId, messageId);
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* The lower-level "wait for run slot, open the turn on a specific waiting message, run it" sequence.
|
|
1134
|
+
*
|
|
1135
|
+
* Called from two places: the user-facing `sendQueued` (which adds a stop-clearing grace window
|
|
1136
|
+
* before this) and the auto-trigger paths in `enqueueExecResult` and `dispatchTask`'s background
|
|
1137
|
+
* branch (which only call it when `!this.activeRuns.has(conversationId)`). The two callers differ
|
|
1138
|
+
* only in who arrived first; the open-turn-and-run contract is the same.
|
|
1139
|
+
*
|
|
1140
|
+
* `composePrompt` is what makes the `role: "tool"` path real - the `<task>` envelope stays in the
|
|
1141
|
+
* transcript, but the prose form of the same fact is what reaches Pi.
|
|
1142
|
+
*/
|
|
1143
|
+
async processWaitingMessage(conversationId, messageId) {
|
|
1036
1144
|
return this.serializeConversation(conversationId, async () => {
|
|
1037
1145
|
if (this.activeRuns.has(conversationId))
|
|
1038
1146
|
throw new Error("当前轮仍在运行;停止或等待它结束后再发送这条消息");
|
|
1039
|
-
// Claim the run slot BEFORE releasing the command lane, then start the turn from the message that
|
|
1040
|
-
// is already in the transcript. It is not re-sent through `enqueueAndRun`, which would write a
|
|
1041
|
-
// SECOND copy of these words: the whole point of this action is that the user is sending a message
|
|
1042
|
-
// they can already see sitting in their conversation.
|
|
1043
1147
|
const controller = new AbortController();
|
|
1044
1148
|
this.activeRuns.set(conversationId, controller);
|
|
1045
1149
|
try {
|
|
@@ -1048,14 +1152,14 @@ export class RunService {
|
|
|
1048
1152
|
const identity = agent ? { id: agent.id, name: agent.name } : { name: "Pi" };
|
|
1049
1153
|
const opened = await this.store.openTurnOnSpecificMessage(conversationId, messageId, identity);
|
|
1050
1154
|
this.broadcaster.broadcastState();
|
|
1051
|
-
await this.runConversationTurns(conversation, workspace, opened.user
|
|
1155
|
+
await this.runConversationTurns(conversation, workspace, composePrompt(opened.user), controller, opened.user.id, opened.user.images);
|
|
1052
1156
|
await this.store.setStatus(conversationId, controller.signal.aborted ? "stopped" : "completed");
|
|
1053
1157
|
}
|
|
1054
1158
|
catch (error) {
|
|
1055
1159
|
await this.store.setStatus(conversationId, "failed");
|
|
1056
|
-
// A preflight failure must put the
|
|
1057
|
-
// duplicate the
|
|
1058
|
-
// there - which is what makes "the send failed,
|
|
1160
|
+
// A preflight failure must put the message BACK where the reader can see it. Re-adding it would
|
|
1161
|
+
// duplicate the row that is still in the transcript, so this only re-marks the one that is
|
|
1162
|
+
// there - which is what makes "the send failed, this row is still in the queue" a true statement.
|
|
1059
1163
|
await this.store.releaseWaitingMessage(conversationId, messageId).catch(() => undefined);
|
|
1060
1164
|
throw error;
|
|
1061
1165
|
}
|
|
@@ -1097,7 +1201,10 @@ export class RunService {
|
|
|
1097
1201
|
const claimed = await this.store.claimWaitingMessage(conversationId, messageId);
|
|
1098
1202
|
this.broadcaster.broadcastState();
|
|
1099
1203
|
try {
|
|
1100
|
-
|
|
1204
|
+
// `composePrompt` turns a 后台回传 into the prose the model can read as a user message; for a
|
|
1205
|
+
// user waiting message it is the no-op identity. `claimed.images` is always undefined for a
|
|
1206
|
+
// `role: "tool"` row, and that is correct - 后台回传 carries no images today.
|
|
1207
|
+
await this.runtime.steer(conversationId, composePrompt(claimed), claimed.images);
|
|
1101
1208
|
}
|
|
1102
1209
|
catch (error) {
|
|
1103
1210
|
// Back to waiting in every failure case, including the "Pi is gone" one. It is still at the front of
|
|
@@ -1223,11 +1330,24 @@ export class RunService {
|
|
|
1223
1330
|
this.memory?.review(this.turnContext(conversationId).conversation, { task: body.prompt, result: run.output });
|
|
1224
1331
|
const result = run.status === "completed" ? run.output ?? "" : `Background task failed: ${run.error ?? run.status}`;
|
|
1225
1332
|
const tag = run.status === "completed" ? "task_result" : "task_error";
|
|
1226
|
-
//
|
|
1227
|
-
//
|
|
1333
|
+
// A 后台回传 that the agent will actually get to read: same `<task>` envelope the transcript and
|
|
1334
|
+
// dock render from, but with `delivery: "waiting"` so it lives in the queue, jumps to the
|
|
1335
|
+
// front, and auto-triggers a turn when the conversation is idle. The render side is unchanged -
|
|
1336
|
+
// still the 后台回传 column.
|
|
1228
1337
|
const message = `<task id="${run.id}" state="${run.status === "completed" ? "completed" : "error"}" kind="subagent">\n<summary>Background task ${run.status === "completed" ? "completed" : "failed"}: ${body.description?.trim() || subagentType}</summary>\n<${tag}>\n${result}</${tag}>\n</task>`;
|
|
1229
1338
|
try {
|
|
1230
|
-
await this.store.addToolMessage(conversation.id, message);
|
|
1339
|
+
const queued = await this.store.addToolMessage(conversation.id, message, { delivery: "waiting" });
|
|
1340
|
+
this.broadcaster.broadcastState();
|
|
1341
|
+
const waiting = this.store.listWaitingMessages(conversation.id);
|
|
1342
|
+
if (waiting.length > 1) {
|
|
1343
|
+
await this.store.reorderWaitingMessage(conversation.id, queued.id, 0);
|
|
1344
|
+
this.broadcaster.broadcastState();
|
|
1345
|
+
}
|
|
1346
|
+
if (!this.activeRuns.has(conversation.id)) {
|
|
1347
|
+
void this.processWaitingMessage(conversation.id, queued.id).catch((error) => {
|
|
1348
|
+
console.warn(`Super Pi: auto-trigger for background sub-agent result failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1231
1351
|
}
|
|
1232
1352
|
catch (error) {
|
|
1233
1353
|
console.warn(`Super Pi: sub-agent result could not be appended as a tool message: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
@@ -1237,7 +1357,16 @@ export class RunService {
|
|
|
1237
1357
|
}).catch(async (error) => {
|
|
1238
1358
|
const message = `<task id="${backgroundId}" state="error" kind="subagent">\n<summary>Background task failed: ${body.description?.trim() || subagentType}</summary>\n<task_error>Background task failed before completion: ${error instanceof Error ? error.message : "unknown error"}</task_error>\n</task>`;
|
|
1239
1359
|
try {
|
|
1240
|
-
await this.store.addToolMessage(conversationId, message);
|
|
1360
|
+
const queued = await this.store.addToolMessage(conversationId, message, { delivery: "waiting" });
|
|
1361
|
+
this.broadcaster.broadcastState();
|
|
1362
|
+
const waiting = this.store.listWaitingMessages(conversationId);
|
|
1363
|
+
if (waiting.length > 1) {
|
|
1364
|
+
await this.store.reorderWaitingMessage(conversationId, queued.id, 0);
|
|
1365
|
+
this.broadcaster.broadcastState();
|
|
1366
|
+
}
|
|
1367
|
+
// No auto-trigger on this failure branch (the result envelope says `state="error"` and the
|
|
1368
|
+
// conversation is by construction not in a turn that asked for it). The row is visible in the
|
|
1369
|
+
// dock for the reader to send-now or cancel.
|
|
1241
1370
|
}
|
|
1242
1371
|
catch (innerError) {
|
|
1243
1372
|
console.warn(`Super Pi: sub-agent failure could not be appended as a tool message: ${innerError instanceof Error ? innerError.message : "unknown error"}`);
|
|
@@ -169,6 +169,10 @@ export class CommandHandler {
|
|
|
169
169
|
await this.runService.steer(command.conversationId, command.messageId);
|
|
170
170
|
return;
|
|
171
171
|
}
|
|
172
|
+
if (command.type === "message.interrupt") {
|
|
173
|
+
await this.runService.interruptQueued(command.conversationId, command.messageId);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
172
176
|
if (command.type === "message.sendQueued") {
|
|
173
177
|
await this.runService.sendQueued(command.conversationId, command.messageId);
|
|
174
178
|
return;
|
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, SubagentRun, SubagentRunUpdate, TodoItem, ToolCallQuestion, ToolCallArg, ToolCallBlock, ToolResultSummary, Workspace } from "./types.js";
|
|
4
|
+
import type { AppState, Conversation, Message, MessageDelivery, MessageImage, ModelSelection, 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
|
|
@@ -107,8 +107,8 @@ export declare class Store {
|
|
|
107
107
|
deleteWorkspace(workspaceId: string): Promise<string[]>;
|
|
108
108
|
createConversation(workspaceId: string, configuration?: {
|
|
109
109
|
agentId?: string;
|
|
110
|
-
model?: ModelSelection;
|
|
111
|
-
thinkingLevel?: string;
|
|
110
|
+
model?: ModelSelection | null;
|
|
111
|
+
thinkingLevel?: string | null;
|
|
112
112
|
}): Promise<Conversation>;
|
|
113
113
|
/**
|
|
114
114
|
* Copies a conversation into a NEW one, under an id the caller has already minted.
|
|
@@ -220,18 +220,18 @@ export declare class Store {
|
|
|
220
220
|
*/
|
|
221
221
|
private claimClientMessageId;
|
|
222
222
|
/**
|
|
223
|
-
* Appends one background-result message to a conversation
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
* `role: "tool"` message that the client renders with its own column and its own block UI, so the
|
|
229
|
-
* transcript shows the result AND nothing pretends the user said it.
|
|
223
|
+
* Appends one background-result message to a conversation. The render column is the same as before
|
|
224
|
+
* (a `role: "tool"` row, never impersonating a user bubble) - what this method now ALSO supports is
|
|
225
|
+
* putting the message straight into the dock by passing `delivery: "waiting"`, which is what exec and
|
|
226
|
+
* sub-agent background completions do once a 后台回传 is treated as a queue item with steer priority
|
|
227
|
+
* rather than a one-way notice the model never gets to read.
|
|
230
228
|
*
|
|
231
|
-
* NO `turnContext` claim, NO `status: "running"`. The message sits in the transcript
|
|
232
|
-
*
|
|
229
|
+
* NO `turnContext` claim, NO `status: "running"`. The message sits in the transcript; with a delivery
|
|
230
|
+
* state it also enters `waitingMessages` and `runConversationTurns` will pick it up next.
|
|
233
231
|
*/
|
|
234
|
-
addToolMessage(conversationId: string, content: string
|
|
232
|
+
addToolMessage(conversationId: string, content: string, options?: {
|
|
233
|
+
delivery?: MessageDelivery;
|
|
234
|
+
}): Promise<Message>;
|
|
235
235
|
/**
|
|
236
236
|
* Records the model a conversation runs on. `updatedAt` is deliberately NOT touched, for the same
|
|
237
237
|
* reason rename does not touch it: it is the sidebar's recency key, and switching model is not
|
|
@@ -421,7 +421,11 @@ export declare class Store {
|
|
|
421
421
|
* browsers and the array is mutated in place by the very next send.
|
|
422
422
|
*/
|
|
423
423
|
listWaitingMessages(conversationId: string): Message[];
|
|
424
|
-
/** The same list by reference, for callers that only read a field and must not pay for a clone.
|
|
424
|
+
/** The same list by reference, for callers that only read a field and must not pay for a clone.
|
|
425
|
+
*
|
|
426
|
+
* Includes `role: "tool"` messages now that a 后台回传 is a real queue item: same dock, same ordering,
|
|
427
|
+
* same delivery state machine, same steer/claim semantics - the only thing different is the render
|
|
428
|
+
* (kept as a 后台回传 block) and what the agent is told when its turn comes (see `composePrompt`). */
|
|
425
429
|
private waitingMessages;
|
|
426
430
|
private requireWaitingMessage;
|
|
427
431
|
private persistMessages;
|
|
@@ -491,12 +495,9 @@ export declare class Store {
|
|
|
491
495
|
*/
|
|
492
496
|
cancelWaitingMessage(conversationId: string, messageId: string): Promise<Message | undefined>;
|
|
493
497
|
/**
|
|
494
|
-
* Rewrites the words of one waiting message.
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
* changing it is changing the whole truth of the message - there is no second copy anywhere that could
|
|
498
|
-
* fall out of step. `sending` means Pi already holds it, and a message the model is about to answer is
|
|
499
|
-
* not one the user gets to rewrite after the fact.
|
|
498
|
+
* Rewrites the words of one waiting message. User sends only - a 后台回传 carries a `<task>` envelope
|
|
499
|
+
* the model parses for state, summary and body, and editing it would change a fact the agent has
|
|
500
|
+
* already started reasoning from. The client dock shows no 「改字」 button for `role: "tool"` rows.
|
|
500
501
|
*/
|
|
501
502
|
editWaitingMessage(conversationId: string, messageId: string, content: string): Promise<Message>;
|
|
502
503
|
/**
|
|
@@ -548,9 +549,10 @@ export declare class Store {
|
|
|
548
549
|
* question that is still waiting - a transcript lying about its own order. Splicing at
|
|
549
550
|
* `indexOf(user) + 1` keeps every waiting message below the reply that belongs to the one above it.
|
|
550
551
|
*
|
|
551
|
-
* The side effects - title, `updatedAt`, `status: "
|
|
552
|
+
* The side effects - title, `updatedAt`, `status: "waiting"`, clearing a stale failure - all describe a
|
|
552
553
|
* TURN rather than a message, which is why `addUserMessage` skips them for a message that is only
|
|
553
|
-
* waiting. They belong here, at the moment the turn genuinely begins
|
|
554
|
+
* waiting. They belong here, at the moment the turn genuinely begins - the model still has not
|
|
555
|
+
* returned a token, and that is the shape the row carries until `streamTurn` sees the first delta.
|
|
554
556
|
*/
|
|
555
557
|
openTurnOnMessage(conversationId: string, messageId: string, agentIdentity?: Message["agentIdentity"]): Promise<{
|
|
556
558
|
user: Message;
|