@evandu/super-pi 0.1.103 → 0.1.105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,8 +8,8 @@
8
8
  <link rel="alternate icon" href="/favicon.ico" />
9
9
  <link rel="apple-touch-icon" href="/favicon.svg" />
10
10
  <title>Super Pi</title>
11
- <script type="module" crossorigin src="/assets/index-mAu39Bra.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-DDkbPYLz.css">
11
+ <script type="module" crossorigin src="/assets/index-tBfBUY_8.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-UiqTcZca.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -37,6 +37,15 @@ export declare class CatalogService {
37
37
  * Broadcasts the model catalog to connected clients.
38
38
  */
39
39
  broadcastCatalog(selection?: ModelSelection, conversationId?: string): Promise<void>;
40
+ /**
41
+ * Broadcasts the UNFILTERED catalog, for the settings window's model panel.
42
+ *
43
+ * A SEPARATE EVENT FROM `model.catalog`, and the reason is the same one that made
44
+ * `model.catalog.all` a separate event in the first place: a `model.catalog` without a
45
+ * conversation is the unfiltered list, and a client that received it as its conversation's list
46
+ * would be offered the models that workspace's rules forbid. The panel gets its own event.
47
+ */
48
+ broadcastAllCatalog(): Promise<void>;
40
49
  setModelRules(rules: import("../types.js").ModelFilterRule[]): Promise<void>;
41
50
  /**
42
51
  * Reads one catalog filtered by mode at source.
@@ -90,6 +90,17 @@ export class CatalogService {
90
90
  const catalog = await this.getCatalog(selection, conversationId);
91
91
  this.broadcaster.broadcast({ type: "model.catalog", catalog, conversationId });
92
92
  }
93
+ /**
94
+ * Broadcasts the UNFILTERED catalog, for the settings window's model panel.
95
+ *
96
+ * A SEPARATE EVENT FROM `model.catalog`, and the reason is the same one that made
97
+ * `model.catalog.all` a separate event in the first place: a `model.catalog` without a
98
+ * conversation is the unfiltered list, and a client that received it as its conversation's list
99
+ * would be offered the models that workspace's rules forbid. The panel gets its own event.
100
+ */
101
+ async broadcastAllCatalog() {
102
+ this.broadcaster.broadcast({ type: "model.catalog.all", catalog: await this.getAllCatalog() });
103
+ }
93
104
  async setModelRules(rules) {
94
105
  if (!this.store)
95
106
  throw new Error("Store unavailable");
@@ -1,6 +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 { ModelSelection } from "../types.js";
4
5
  import type { EventBroadcaster } from "../server-context.js";
5
6
  import type { CatalogService } from "./catalog-service.js";
6
7
  import type { RunService } from "./run-service.js";
@@ -45,6 +46,14 @@ export declare class ConversationService {
45
46
  * a log and in a retry.
46
47
  */
47
48
  autoRenameConversation(conversationId: string): Promise<string>;
49
+ /**
50
+ * The model a conversation has selected, or undefined when it follows Pi's default.
51
+ *
52
+ * THIS IS WHAT THE CATALOG'S THINKING LEVELS DEPEND ON. Pi computes the levels from the model
53
+ * itself, so the levels of the model in force are the levels that conversation gets. A catalog read
54
+ * without this is a catalog of whatever Pi would default to.
55
+ */
56
+ modelSelectionFor(conversationId: string | undefined): ModelSelection | undefined;
48
57
  /**
49
58
  * Selects a conversation and broadcasts the catalog for that conversation's model.
50
59
  */
@@ -107,6 +107,19 @@ export class ConversationService {
107
107
  throw new Error("当前没有可用的 Pi 进程,无法生成标题");
108
108
  return this.runtime.summarizeConversation(conversation, workspace.path);
109
109
  }
110
+ /**
111
+ * The model a conversation has selected, or undefined when it follows Pi's default.
112
+ *
113
+ * THIS IS WHAT THE CATALOG'S THINKING LEVELS DEPEND ON. Pi computes the levels from the model
114
+ * itself, so the levels of the model in force are the levels that conversation gets. A catalog read
115
+ * without this is a catalog of whatever Pi would default to.
116
+ */
117
+ modelSelectionFor(conversationId) {
118
+ if (!conversationId)
119
+ return undefined;
120
+ const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
121
+ return conversation?.model;
122
+ }
110
123
  /**
111
124
  * Selects a conversation and broadcasts the catalog for that conversation's model.
112
125
  */
@@ -0,0 +1,63 @@
1
+ import { ProviderRegistry } from "../providers.js";
2
+ import type { AgentRuntime } from "../runtime.js";
3
+ import type { Store } from "../store.js";
4
+ import type { PublicProvider } from "../types.js";
5
+ import type { EventBroadcaster } from "../server-context.js";
6
+ import type { CatalogService } from "./catalog-service.js";
7
+ /**
8
+ * THE PROVIDERS A PERSON ADDED, as a service.
9
+ *
10
+ * The service owns the sequence, and the sequence is the whole point: a provider is not "saved"
11
+ * when it is in Super Pi's registry, it is saved when Pi can use it. So every change goes through
12
+ * the same four steps, in this order:
13
+ *
14
+ * 1. WRITE PI'S FILES. The definitions go into Pi's `models.json` and the key into Pi's
15
+ * `auth.json`, merged into whatever is already there.
16
+ * 2. WRITE SUPER PI'S REGISTRY. It is the truth source, and it is written AFTER Pi's files so
17
+ * that a failure to write them leaves Super Pi claiming nothing it has not done.
18
+ * 3. INVALIDATE THE CATALOG. Pi's model list is cached - the SDK's is remembered for the life of
19
+ * the process and the runtime's has a ten-minute TTL - so a provider added now would not
20
+ * appear in the picker until the cache expired. Ten minutes of a picker that cannot see the
21
+ * model the person just added is exactly the "decorative control" this project refuses to ship.
22
+ * 4. INVALIDATE THE LIVE SESSIONS. A session holds its own `ModelRuntime`, created when the
23
+ * session was built, and that runtime does not know a provider added a minute ago. Pi's
24
+ * refusal to switch to an unknown model is swallowed by the session pool into a warning - the
25
+ * turn then runs on the OLD model while the picker says the new one. So the sessions are made
26
+ * stale instead, and each one is rebuilt at its next turn, which is a moment when throwing the
27
+ * session away costs nothing.
28
+ */
29
+ export declare class ProviderService {
30
+ private readonly registry;
31
+ private readonly runtime;
32
+ private readonly broadcaster;
33
+ private readonly catalogService;
34
+ private readonly store;
35
+ constructor(registry: ProviderRegistry, runtime: AgentRuntime, broadcaster: EventBroadcaster, catalogService: CatalogService, store: Store);
36
+ /** The providers Super Pi manages, as the browser may see them: no keys, only the fact of one. */
37
+ list(): Promise<PublicProvider[]>;
38
+ /**
39
+ * Adds a provider, or adds a model to one, or updates one.
40
+ *
41
+ * ONE CALL, THREE THINGS, because the form collects a provider and a model together and a person
42
+ * saving a second model under the same provider should not have to retype the API address. The
43
+ * merge is by id: the provider's fields are replaced by what the form said, and the model with
44
+ * the same id is replaced while the provider's other models stay.
45
+ *
46
+ * THE KEY IS LEFT ALONE UNLESS THE FORM SAYS OTHERWISE. An empty key field means "I did not type
47
+ * one", not "remove the key": the key is never shown again once it is stored, so an empty field
48
+ * is the normal state of a form for a provider that already has one. `clearKey` is how a person
49
+ * asks for it to go.
50
+ */
51
+ set(input: unknown): Promise<PublicProvider[]>;
52
+ /** Removes a provider Super Pi manages, and only one it manages. */
53
+ remove(providerId: string): Promise<PublicProvider[]>;
54
+ /**
55
+ * What has to happen after the files are written, for the change to be REAL rather than pending.
56
+ *
57
+ * The catalog is re-read for every live conversation - the same loop the model rules use - rather
58
+ * than broadcast once without a conversation, because a catalog without a conversation is the
59
+ * UNFILTERED one, and handing that to a conversation that has model rules would offer it models
60
+ * those rules forbid.
61
+ */
62
+ private afterChange;
63
+ }
@@ -0,0 +1,136 @@
1
+ import { KNOWN_APIS, publicProviders, validateProvider } from "../providers.js";
2
+ /**
3
+ * THE PROVIDERS A PERSON ADDED, as a service.
4
+ *
5
+ * The service owns the sequence, and the sequence is the whole point: a provider is not "saved"
6
+ * when it is in Super Pi's registry, it is saved when Pi can use it. So every change goes through
7
+ * the same four steps, in this order:
8
+ *
9
+ * 1. WRITE PI'S FILES. The definitions go into Pi's `models.json` and the key into Pi's
10
+ * `auth.json`, merged into whatever is already there.
11
+ * 2. WRITE SUPER PI'S REGISTRY. It is the truth source, and it is written AFTER Pi's files so
12
+ * that a failure to write them leaves Super Pi claiming nothing it has not done.
13
+ * 3. INVALIDATE THE CATALOG. Pi's model list is cached - the SDK's is remembered for the life of
14
+ * the process and the runtime's has a ten-minute TTL - so a provider added now would not
15
+ * appear in the picker until the cache expired. Ten minutes of a picker that cannot see the
16
+ * model the person just added is exactly the "decorative control" this project refuses to ship.
17
+ * 4. INVALIDATE THE LIVE SESSIONS. A session holds its own `ModelRuntime`, created when the
18
+ * session was built, and that runtime does not know a provider added a minute ago. Pi's
19
+ * refusal to switch to an unknown model is swallowed by the session pool into a warning - the
20
+ * turn then runs on the OLD model while the picker says the new one. So the sessions are made
21
+ * stale instead, and each one is rebuilt at its next turn, which is a moment when throwing the
22
+ * session away costs nothing.
23
+ */
24
+ export class ProviderService {
25
+ registry;
26
+ runtime;
27
+ broadcaster;
28
+ catalogService;
29
+ store;
30
+ constructor(registry, runtime, broadcaster, catalogService, store) {
31
+ this.registry = registry;
32
+ this.runtime = runtime;
33
+ this.broadcaster = broadcaster;
34
+ this.catalogService = catalogService;
35
+ this.store = store;
36
+ }
37
+ /** The providers Super Pi manages, as the browser may see them: no keys, only the fact of one. */
38
+ async list() {
39
+ return publicProviders(await this.registry.load());
40
+ }
41
+ /**
42
+ * Adds a provider, or adds a model to one, or updates one.
43
+ *
44
+ * ONE CALL, THREE THINGS, because the form collects a provider and a model together and a person
45
+ * saving a second model under the same provider should not have to retype the API address. The
46
+ * merge is by id: the provider's fields are replaced by what the form said, and the model with
47
+ * the same id is replaced while the provider's other models stay.
48
+ *
49
+ * THE KEY IS LEFT ALONE UNLESS THE FORM SAYS OTHERWISE. An empty key field means "I did not type
50
+ * one", not "remove the key": the key is never shown again once it is stored, so an empty field
51
+ * is the normal state of a form for a provider that already has one. `clearKey` is how a person
52
+ * asks for it to go.
53
+ */
54
+ async set(input) {
55
+ const requested = validateProvider(input);
56
+ const raw = (input ?? {});
57
+ const previous = await this.registry.load();
58
+ const existing = previous.find((provider) => provider.id === requested.id);
59
+ const apiKey = requested.apiKey
60
+ ? requested.apiKey
61
+ : (raw.clearKey === true ? undefined : existing?.apiKey);
62
+ // A MODEL WITH THE SAME ID IS REPLACED WHERE IT STANDS, not moved to the end: the list a person
63
+ // sees in the panel is the list they added, and re-ordering it because they edited its first
64
+ // entry would be a change nobody asked for. A model with a NEW id is appended.
65
+ const incoming = requested.models[0];
66
+ const models = !existing
67
+ ? requested.models
68
+ : (existing.models.some((model) => model.id === incoming.id)
69
+ ? existing.models.map((model) => (model.id === incoming.id ? incoming : model))
70
+ : [...existing.models, incoming]);
71
+ const merged = {
72
+ id: requested.id,
73
+ ...(requested.name ? { name: requested.name } : {}),
74
+ baseUrl: requested.baseUrl,
75
+ api: requested.api,
76
+ ...(apiKey ? { apiKey } : {}),
77
+ models,
78
+ };
79
+ const next = existing
80
+ ? [...previous.filter((provider) => provider.id !== requested.id), merged]
81
+ : [...previous, merged];
82
+ await this.registry.applyToPi(previous, next);
83
+ try {
84
+ await this.registry.save(next);
85
+ }
86
+ catch (error) {
87
+ // The registry is the truth source, so a failure to write it means Super Pi no longer knows
88
+ // what it put into Pi's files. Pi's files are put back the way they were, and the failure is
89
+ // reported rather than swallowed: a registry that disagrees with Pi's files is a state the
90
+ // next write would trip over.
91
+ await this.registry.applyToPi(next, previous).catch(() => undefined);
92
+ throw error;
93
+ }
94
+ await this.afterChange();
95
+ return publicProviders(next);
96
+ }
97
+ /** Removes a provider Super Pi manages, and only one it manages. */
98
+ async remove(providerId) {
99
+ const previous = await this.registry.load();
100
+ const existing = previous.find((provider) => provider.id === providerId);
101
+ if (!existing)
102
+ throw new Error(`Super Pi 没有管理供应商「${providerId}」`);
103
+ const next = previous.filter((provider) => provider.id !== providerId);
104
+ await this.registry.applyToPi(previous, next);
105
+ await this.registry.save(next);
106
+ await this.afterChange();
107
+ return publicProviders(next);
108
+ }
109
+ /**
110
+ * What has to happen after the files are written, for the change to be REAL rather than pending.
111
+ *
112
+ * The catalog is re-read for every live conversation - the same loop the model rules use - rather
113
+ * than broadcast once without a conversation, because a catalog without a conversation is the
114
+ * UNFILTERED one, and handing that to a conversation that has model rules would offer it models
115
+ * those rules forbid.
116
+ */
117
+ async afterChange() {
118
+ this.runtime.invalidateCatalog?.();
119
+ this.runtime.bumpModelEpoch?.();
120
+ for (const conversation of this.store.snapshot().conversations) {
121
+ if (conversation.archivedAt)
122
+ continue;
123
+ try {
124
+ await this.catalogService.broadcastCatalog(conversation.model, conversation.id);
125
+ }
126
+ catch (error) {
127
+ console.warn(`供应商已保存,刷新会话 ${conversation.id} 的模型目录失败:${String(error)}`);
128
+ }
129
+ }
130
+ // The settings panel's OWN list, which is unfiltered and therefore has to be asked for
131
+ // separately. Without this the person who just added a provider would not see their own model
132
+ // in the default-model dropdown until they closed the window and opened it again.
133
+ await this.catalogService.broadcastAllCatalog?.();
134
+ this.broadcaster.broadcast({ type: "providers.list", providers: await this.list(), apis: [...KNOWN_APIS] });
135
+ }
136
+ }
@@ -4,6 +4,7 @@ import type { ConversationService } from "./application/conversation-service.js"
4
4
  import type { RunService } from "./application/run-service.js";
5
5
  import type { ExtensionUiService } from "./application/extension-ui-service.js";
6
6
  import type { CatalogService } from "./application/catalog-service.js";
7
+ import type { ProviderService } from "./application/provider-service.js";
7
8
  import type { TerminalService } from "./terminal.js";
8
9
  export type SocketSender = (event: ServerEvent) => void;
9
10
  /**
@@ -17,6 +18,13 @@ export declare class CommandHandler {
17
18
  private readonly catalogService;
18
19
  /** Path to the Super Pi repository root, used to spawn the restart script. */
19
20
  private readonly rootDir?;
21
+ /**
22
+ * The providers a person added through the settings window.
23
+ *
24
+ * Optional because the command handler is constructed by tests that do not care about providers,
25
+ * and a missing service has to say so rather than take the whole handler down.
26
+ */
27
+ private readonly providerService?;
20
28
  /**
21
29
  * The built-in terminal service.
22
30
  *
@@ -25,7 +33,14 @@ export declare class CommandHandler {
25
33
  private readonly terminalService?;
26
34
  constructor(workspaceService: WorkspaceService, conversationService: ConversationService, runService: RunService, extensionUiService: ExtensionUiService, catalogService: CatalogService, terminalService?: TerminalService,
27
35
  /** Path to the Super Pi repository root, used to spawn the restart script. */
28
- rootDir?: string | undefined);
36
+ rootDir?: string | undefined,
37
+ /**
38
+ * The providers a person added through the settings window.
39
+ *
40
+ * Optional because the command handler is constructed by tests that do not care about providers,
41
+ * and a missing service has to say so rather than take the whole handler down.
42
+ */
43
+ providerService?: ProviderService | undefined);
29
44
  /**
30
45
  * Processes a client command.
31
46
  *
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { join } from "node:path";
3
+ import { KNOWN_APIS } from "./providers.js";
3
4
  import { packageUpdateArgs } from "./update.js";
4
5
  /**
5
6
  * Routes client commands to appropriate application services and converts results or errors.
@@ -11,6 +12,7 @@ export class CommandHandler {
11
12
  extensionUiService;
12
13
  catalogService;
13
14
  rootDir;
15
+ providerService;
14
16
  /**
15
17
  * The built-in terminal service.
16
18
  *
@@ -19,13 +21,21 @@ export class CommandHandler {
19
21
  terminalService;
20
22
  constructor(workspaceService, conversationService, runService, extensionUiService, catalogService, terminalService,
21
23
  /** Path to the Super Pi repository root, used to spawn the restart script. */
22
- rootDir) {
24
+ rootDir,
25
+ /**
26
+ * The providers a person added through the settings window.
27
+ *
28
+ * Optional because the command handler is constructed by tests that do not care about providers,
29
+ * and a missing service has to say so rather than take the whole handler down.
30
+ */
31
+ providerService) {
23
32
  this.workspaceService = workspaceService;
24
33
  this.conversationService = conversationService;
25
34
  this.runService = runService;
26
35
  this.extensionUiService = extensionUiService;
27
36
  this.catalogService = catalogService;
28
37
  this.rootDir = rootDir;
38
+ this.providerService = providerService;
29
39
  this.terminalService = terminalService;
30
40
  }
31
41
  /**
@@ -69,6 +79,29 @@ export class CommandHandler {
69
79
  await this.catalogService.setDefaultModel(command.model);
70
80
  return;
71
81
  }
82
+ // THE PROVIDERS A PERSON ADDED. The list is answered to the ASKING socket only - it is a panel's
83
+ // private list, and broadcasting it would put a provider's API address in every other tab's state
84
+ // for no gain. The set and the remove broadcast the new list themselves, because everybody who
85
+ // has the settings window open has to see the change.
86
+ if (command.type === "providers.set") {
87
+ if (!this.providerService)
88
+ throw new Error("供应商功能不可用");
89
+ await this.providerService.set(command.provider);
90
+ return;
91
+ }
92
+ if (command.type === "providers.remove") {
93
+ if (!this.providerService)
94
+ throw new Error("供应商功能不可用");
95
+ await this.providerService.remove(command.providerId);
96
+ return;
97
+ }
98
+ if (command.type === "providers.fetch") {
99
+ if (!this.providerService)
100
+ throw new Error("供应商功能不可用");
101
+ if (sendToSocket)
102
+ sendToSocket({ type: "providers.list", providers: await this.providerService.list(), apis: [...KNOWN_APIS] });
103
+ return;
104
+ }
72
105
  if (command.type === "workspace.create") {
73
106
  return await this.workspaceService.createWorkspace(command.name, command.path);
74
107
  }
@@ -237,10 +270,21 @@ export class CommandHandler {
237
270
  return;
238
271
  }
239
272
  if (command.type === "catalog.fetch") {
273
+ // THE CATALOG OF THE CONVERSATION BEING SHOWN, not of whatever Pi would default to.
274
+ //
275
+ // The THINKING LEVELS belong to the model in force: Pi computes them from the model itself, and a
276
+ // model that cannot reason offers only "off". This command used to ask for the catalog with NO
277
+ // selection, so the levels were always the DEFAULT model's - which means the picker offered the
278
+ // levels of a model the person was not using, and hid the ones they were. The model list is the
279
+ // same either way, so only the levels were wrong; the levels are the part a person reads.
280
+ //
281
+ // `conversation.select` already asked for it this way; this is the same answer, for the other
282
+ // way a client asks for it.
283
+ const selection = this.conversationService.modelSelectionFor(command.conversationId);
240
284
  if (sendToSocket)
241
- sendToSocket({ type: "model.catalog", conversationId: command.conversationId, catalog: await this.catalogService.getCatalog(undefined, command.conversationId) });
285
+ sendToSocket({ type: "model.catalog", conversationId: command.conversationId, catalog: await this.catalogService.getCatalog(selection, command.conversationId) });
242
286
  else
243
- await this.catalogService.broadcastCatalog(undefined, command.conversationId);
287
+ await this.catalogService.broadcastCatalog(selection, command.conversationId);
244
288
  return;
245
289
  }
246
290
  // The unfiltered catalog, for the settings window's model panel. Sent to the ASKING socket only:
@@ -16,6 +16,8 @@ import { SubagentRunRecorder } from "./subagent-runs.js";
16
16
  import { WorkflowEngine } from "./workflow-engine.js";
17
17
  import { ExtensionUiService } from "./application/extension-ui-service.js";
18
18
  import { CatalogService } from "./application/catalog-service.js";
19
+ import { ProviderService } from "./application/provider-service.js";
20
+ import { ProviderRegistry } from "./providers.js";
19
21
  import { RunService } from "./application/run-service.js";
20
22
  import { ConversationService } from "./application/conversation-service.js";
21
23
  import { WorkspaceService } from "./application/workspace-service.js";
@@ -178,6 +180,15 @@ const onDirsChanged = (dirs) => {
178
180
  workspaceService.setAgentsDir(dirs);
179
181
  };
180
182
  const catalogService = new CatalogService(runtime, broadcaster, initialAgentDirs, store, rootDir, onDirsChanged);
183
+ /**
184
+ * The providers a person added through the settings window.
185
+ *
186
+ * The registry lives in Super Pi's own data directory and holds the API keys; Pi's `models.json` and
187
+ * `auth.json` are written from it, so a provider added here is a provider Pi can use - in a session,
188
+ * in a spawned Pi, and in the terminal.
189
+ */
190
+ const providerRegistry = new ProviderRegistry(dataDir);
191
+ const providerService = new ProviderService(providerRegistry, runtime, broadcaster, catalogService, store);
181
192
  const runService = new RunService(store, runtime, subagents, broadcaster, extensionUiService, initialAgentDirs, workflows);
182
193
  runService.setMemory(memory);
183
194
  const conversationService = new ConversationService(store, runtime, subagents, broadcaster, catalogService, runService, extensionUiService, initialAgentDirs);
@@ -216,6 +227,7 @@ const serverContext = {
216
227
  runService,
217
228
  extensionUiService,
218
229
  catalogService,
230
+ providerService,
219
231
  execService,
220
232
  toolRegistry,
221
233
  toolRunner,
@@ -223,7 +235,7 @@ const serverContext = {
223
235
  terminalEnabled: () => terminalService.enabled,
224
236
  terminalDisabledReason: () => terminalDisabledReason(),
225
237
  };
226
- const commandHandler = new CommandHandler(workspaceService, conversationService, runService, extensionUiService, catalogService, terminalService, rootDir);
238
+ const commandHandler = new CommandHandler(workspaceService, conversationService, runService, extensionUiService, catalogService, terminalService, rootDir, providerService);
227
239
  // ------------------------------------------------------------- Lifecycle & shutdown
228
240
  let shutdownPromise;
229
241
  async function closeHttpServer() {
@@ -73,6 +73,37 @@ export interface TurnEventHandlers {
73
73
  followUp: string[];
74
74
  }): void;
75
75
  }
76
+ /**
77
+ * The failure Pi reported in the message that ended the turn, if it reported one.
78
+ *
79
+ * WHY IT IS HELD AND NOT THROWN ON THE SPOT. Pi retries on its own: an assistant message that ends
80
+ * with `stopReason: "error"` may be followed by another assistant message, and only the LAST one
81
+ * decides whether the turn failed. So the failure is remembered when it arrives and raised when Pi
82
+ * SETTLES - which is the moment Pi has stopped working on its own and the answer is final. A failure
83
+ * raised at the moment it arrives would abort a turn Pi was about to retry, and the retry would then
84
+ * look like the user's problem.
85
+ *
86
+ * THE SAME RULES AS THE ONE-SHOT PATH, which already got this right (`runtime.ts`: the RPC drain loop
87
+ * reads the terminal outcome off `agent_end` and throws at `agent_settled`). A provider failure is a
88
+ * failure; a response that hit the length limit is an INCOMPLETE answer and is one too. An `"aborted"`
89
+ * is NOT a failure here: the person stopped the turn, and a stopped turn is a turn that ended - the
90
+ * one-shot path can tell the difference because it knows whether the signal was aborted, and the
91
+ * session path does not need to: an abort is the user's own action and the run service already
92
+ * records it as stopped.
93
+ */
94
+ export interface TurnFailure {
95
+ /** The message Pi reported, in its own words. */
96
+ message: string;
97
+ /** The raw reason, kept so a caller can tell a provider failure from anything else. */
98
+ stopReason: string;
99
+ }
100
+ /**
101
+ * Reads the failure out of a `message_end` record, or undefined when it is not a failed one.
102
+ *
103
+ * ONLY THE ASSISTANT'S MESSAGE COUNTS. `message_end` also fires for user messages, tool results and
104
+ * custom messages, and none of those is a turn failing.
105
+ */
106
+ export declare function describeMessageFailure(record: unknown): TurnFailure | undefined;
76
107
  /**
77
108
  * A queue of turn items, with the waiters that are blocked on it.
78
109
  *
@@ -108,4 +139,6 @@ export declare class TurnQueue {
108
139
  * then counted - the same policy the RPC loop applied, and for the same reason: an event type nobody
109
140
  * understands is worth saying out loud exactly once.
110
141
  */
111
- export declare function handleSessionEvent(event: unknown, queue: TurnQueue, handlers: TurnEventHandlers, report: (key: string) => void): boolean;
142
+ export declare function handleSessionEvent(event: unknown, queue: TurnQueue, handlers: TurnEventHandlers, report: (key: string) => void, failure?: {
143
+ current: TurnFailure | undefined;
144
+ }): boolean;
@@ -62,6 +62,31 @@ export function describeDeliveredUserMessage(record) {
62
62
  .filter((part) => part !== undefined);
63
63
  return parts.length ? parts.join("\n") : undefined;
64
64
  }
65
+ /**
66
+ * Reads the failure out of a `message_end` record, or undefined when it is not a failed one.
67
+ *
68
+ * ONLY THE ASSISTANT'S MESSAGE COUNTS. `message_end` also fires for user messages, tool results and
69
+ * custom messages, and none of those is a turn failing.
70
+ */
71
+ export function describeMessageFailure(record) {
72
+ const event = record;
73
+ if (event?.type !== "message_end")
74
+ return undefined;
75
+ const message = event.message;
76
+ if (!message || typeof message !== "object" || message.role !== "assistant")
77
+ return undefined;
78
+ const text = typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
79
+ if (message.stopReason === "error") {
80
+ return { message: text || "Pi 报告模型或服务提供方错误。", stopReason: "error" };
81
+ }
82
+ if (message.stopReason === "length") {
83
+ // A response that hit the length limit is an INCOMPLETE answer. Pi compacts and continues after a
84
+ // recoverable one, so a turn that SETTLES with this reason is one whose answer is missing its
85
+ // end - and saying so is better than a turn that looks finished.
86
+ return { message: text || "模型输出达到本轮长度上限,回答未完成。", stopReason: "length" };
87
+ }
88
+ return undefined;
89
+ }
65
90
  /**
66
91
  * A queue of turn items, with the waiters that are blocked on it.
67
92
  *
@@ -149,11 +174,53 @@ export class TurnQueue {
149
174
  * then counted - the same policy the RPC loop applied, and for the same reason: an event type nobody
150
175
  * understands is worth saying out loud exactly once.
151
176
  */
152
- export function handleSessionEvent(event, queue, handlers, report) {
177
+ export function handleSessionEvent(event, queue, handlers, report, failure) {
153
178
  const record = event;
154
179
  if (!record || typeof record.type !== "string")
155
180
  return false;
156
181
  switch (record.type) {
182
+ case "message_end": {
183
+ // THE AUTHORITATIVE END OF AN ASSISTANT MESSAGE. `message_end` is the completed message, and its
184
+ // `stopReason` is the only place Pi says whether the model actually answered. The bridge used to
185
+ // ignore this event, which meant a provider that failed was indistinguishable from one that
186
+ // finished: the turn settled, the status became "completed", and the reader saw a turn that
187
+ // looked fine and had no answer in it.
188
+ //
189
+ // A failure is HELD, not thrown, because Pi retries on its own - the message that failed may be
190
+ // followed by another one. The retry starts a new assistant message, and THAT clears the held
191
+ // failure, so only the last message of the turn decides.
192
+ const reported = describeMessageFailure(record);
193
+ if (reported && failure)
194
+ failure.current = reported;
195
+ return true;
196
+ }
197
+ case "message_start": {
198
+ // A NEW ASSISTANT MESSAGE MEANS A NEW ATTEMPT. Pi's retry starts one, and the failure of the
199
+ // attempt before it is no longer the failure of this turn.
200
+ if (record.message?.role === "assistant" && failure)
201
+ failure.current = undefined;
202
+ // A STEER THIS TURN DELIVERED. Pi announces the moment a queued message stops waiting and starts
203
+ // being answered by emitting `message_start` with the user message it picked up, immediately after
204
+ // the `queue_update` above that shows the entry gone. The host uses it to split the turn so the
205
+ // answer lands under the message it answers.
206
+ //
207
+ // A NON-user `message_start` is not a delivery: it gets the same once-per-type report every other
208
+ // unrouted event gets, which is the same policy the RPC drain loop applies.
209
+ const delivered = describeDeliveredUserMessage(record);
210
+ if (delivered === undefined) {
211
+ if (record.message?.role === "assistant")
212
+ return true;
213
+ report(`type ${JSON.stringify(record.type)}`);
214
+ return true;
215
+ }
216
+ try {
217
+ handlers.onSteerDelivered?.(delivered);
218
+ }
219
+ catch (error) {
220
+ console.warn(`PiRpcRuntime: the steer delivery handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
221
+ }
222
+ return true;
223
+ }
157
224
  case "message_update": {
158
225
  const assistant = record.assistantMessageEvent;
159
226
  if (!assistant)
@@ -240,28 +307,19 @@ export function handleSessionEvent(event, queue, handlers, report) {
240
307
  }
241
308
  return true;
242
309
  }
243
- case "message_start": {
244
- // A STEER THIS TURN DELIVERED. Pi announces the moment a queued message stops waiting and starts
245
- // being answered by emitting `message_start` with the user message it picked up, immediately after
246
- // the `queue_update` above that shows the entry gone. The host uses it to split the turn so the
247
- // answer lands under the message it answers.
310
+ case "agent_settled": {
311
+ // THE TURN IS OVER, AND IT MAY HAVE FAILED. Pi settling is the end of the turn, and a turn that
312
+ // ended because the provider failed has to say so: a failure that is not raised is a failed turn
313
+ // that the reader sees as a completed one, with no answer in it and no banner above it.
248
314
  //
249
- // A NON-user `message_start` is not a delivery: it gets the same once-per-type report every other
250
- // unrouted event gets, which is the same policy the RPC drain loop applies.
251
- const delivered = describeDeliveredUserMessage(record);
252
- if (delivered === undefined) {
253
- report(`type ${JSON.stringify(record.type)}`);
315
+ // The failure is raised HERE and not when it arrived, because Pi retries on its own - a message
316
+ // that failed may be followed by another one, and only the last one decides.
317
+ const held = failure?.current;
318
+ if (held) {
319
+ failure.current = undefined;
320
+ queue.push({ kind: "error", error: new Error(held.message) });
254
321
  return true;
255
322
  }
256
- try {
257
- handlers.onSteerDelivered?.(delivered);
258
- }
259
- catch (error) {
260
- console.warn(`PiRpcRuntime: the steer delivery handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
261
- }
262
- return true;
263
- }
264
- case "agent_settled": {
265
323
  queue.push({ kind: "end" });
266
324
  return true;
267
325
  }