@zetaloop/chappie 0.3.1 → 0.4.0

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/src/config.ts CHANGED
@@ -5,8 +5,8 @@ import * as z from "zod";
5
5
  const configSchema = z.object({
6
6
  ask: z.boolean().optional(),
7
7
  connect: z.string().min(1).optional(),
8
- latestWorkflow: z.boolean().optional(),
9
8
  listen: z.union([z.boolean(), z.number().int().min(1).max(65535)]).optional(),
9
+ sync: z.boolean().optional(),
10
10
  });
11
11
 
12
12
  export async function readConfig(agentDir: string) {
package/src/delivery.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import type { ToolResultMessage } from "@earendil-works/pi-ai";
2
+ import type { Source } from "./activity.ts";
2
3
  import {
3
4
  contentWithImageReferences,
4
5
  resourceDescriptors,
5
6
  } from "./resources.ts";
6
7
 
7
- export interface DeliveryRecord {
8
+ export interface DeliveryRecord extends Source {
8
9
  id: string;
9
- chatId: string;
10
10
  sessionId: string;
11
11
  cwd: string;
12
12
  toolResults: ToolResultMessage[];
@@ -43,6 +43,7 @@ export function deliveryContent(deliveries: DeliveryRecord[]) {
43
43
  type: "text" as const,
44
44
  text: JSON.stringify({
45
45
  deferredResult: delivery.id,
46
+ requestId: delivery.requestId,
46
47
  sessionId: delivery.sessionId,
47
48
  cwd: delivery.cwd,
48
49
  error: delivery.error,
package/src/history.ts ADDED
@@ -0,0 +1,123 @@
1
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import * as z from "zod";
3
+ import type { Source } from "./activity.ts";
4
+ import { toolResultsContent } from "./delivery.ts";
5
+ import type { ProviderOutput } from "./provider.ts";
6
+ import { contentWithImageReferences, rememberImages } from "./resources.ts";
7
+
8
+ export const historyInput = z.object({
9
+ limit: z
10
+ .number()
11
+ .int()
12
+ .min(1)
13
+ .default(20)
14
+ .describe("Maximum number of history entries"),
15
+ before: z
16
+ .string()
17
+ .min(1)
18
+ .optional()
19
+ .describe("Read entries before this entry ID"),
20
+ after: z
21
+ .string()
22
+ .min(1)
23
+ .optional()
24
+ .describe("Read entries after this entry ID, starting with the earliest"),
25
+ });
26
+
27
+ export type HistoryRange = z.infer<typeof historyInput>;
28
+ export type HistoryResult = ReturnType<typeof historyResult>;
29
+
30
+ export const historyInstructions =
31
+ "When resuming work, read recent history to recover progress, then continue from the current request.";
32
+
33
+ export function historyResult(
34
+ branch: SessionEntry[],
35
+ sessionId: string,
36
+ { limit, before, after }: HistoryRange,
37
+ ) {
38
+ const start = after ? entryIndex(branch, after) + 1 : 0;
39
+ const end = before ? entryIndex(branch, before) : branch.length;
40
+ if (start > end) throw new Error("History after must precede before");
41
+ const entries = branch.slice(start, end).filter((entry) => {
42
+ switch (entry.type) {
43
+ case "message":
44
+ return (
45
+ entry.message.role !== "custom" ||
46
+ entry.message.customType !== "chappie.request"
47
+ );
48
+ case "custom_message":
49
+ return entry.customType !== "chappie.request";
50
+ case "custom":
51
+ return entry.customType === "chappie.notice";
52
+ case "compaction":
53
+ case "branch_summary":
54
+ return true;
55
+ default:
56
+ return false;
57
+ }
58
+ });
59
+ const selected = after ? entries.slice(0, limit) : entries.slice(-limit);
60
+ const sources = new Map<string, Source>();
61
+ for (const entry of branch) {
62
+ if (entry.type !== "message" || entry.message.role !== "assistant")
63
+ continue;
64
+ const message = entry.message as ProviderOutput["message"];
65
+ if (!message.chappie) continue;
66
+ for (const block of message.content) {
67
+ if (block.type === "toolCall") sources.set(block.id, message.chappie);
68
+ }
69
+ }
70
+ return {
71
+ count: selected.length,
72
+ hasMore: entries.length > selected.length,
73
+ content: selected.flatMap((entry) =>
74
+ entryContent(entry, sessionId, sources),
75
+ ),
76
+ };
77
+ }
78
+
79
+ function entryIndex(entries: SessionEntry[], id: string): number {
80
+ const index = entries.findIndex((entry) => entry.id === id);
81
+ if (index === -1)
82
+ throw new Error(`History entry ${id} is not on the current branch`);
83
+ return index;
84
+ }
85
+
86
+ function entryContent(
87
+ entry: SessionEntry,
88
+ sessionId: string,
89
+ sources: Map<string, Source>,
90
+ ): ReturnType<typeof toolResultsContent> {
91
+ const record: Record<string, unknown> = { ...entry };
92
+ const message = entry.type === "message" ? entry.message : entry;
93
+ if (!("content" in message)) {
94
+ return [{ type: "text", text: JSON.stringify(record) }];
95
+ }
96
+ const { content, ...metadata } = message;
97
+ if (entry.type === "message") {
98
+ const source =
99
+ entry.message.role === "toolResult"
100
+ ? sources.get(entry.message.toolCallId)
101
+ : undefined;
102
+ record.message = { ...metadata, ...(source ? { chappie: source } : {}) };
103
+ } else delete record.content;
104
+ const header = { type: "text" as const, text: JSON.stringify(record) };
105
+ if (entry.type === "message" && entry.message.role === "toolResult") {
106
+ rememberImages(sessionId, entry.message.content);
107
+ return [header, ...toolResultsContent([entry.message], sessionId)];
108
+ }
109
+ const blocks =
110
+ typeof content === "string"
111
+ ? [{ type: "text" as const, text: content }]
112
+ : content;
113
+ return [
114
+ header,
115
+ ...blocks.flatMap((block): ReturnType<typeof toolResultsContent> => {
116
+ if (block.type === "text" || block.type === "image") {
117
+ rememberImages(sessionId, [block]);
118
+ return contentWithImageReferences(sessionId, [block]);
119
+ }
120
+ return [{ type: "text", text: JSON.stringify(block) }];
121
+ }),
122
+ ];
123
+ }
@@ -1,6 +1,6 @@
1
1
  Chappie connects this ChatGPT conversation to Pi sessions on one or more devices. Resume work with init using the task's Pi sessionId, including in a new chat or branch. When only a project or session name is known, find its device/cwd/name in sessions, then call init. If the target is absent or ambiguous, ask the user to resolve it. Pi sessions appear while the chappie/chatgpt provider is selected.
2
2
 
3
- init sets this chat's default Pi session. For a task without a specified target, omitting sessionId reuses that default or selects the first online session with no saved bindings. These sessions may already contain work. Defaults survive broker restarts, and several chats can share one Pi session. Other tools' sessionId affects only that call.
3
+ init sets this chat's default Pi session. An execution tool also establishes a default on first use: sessionId selects its target, or omitting it selects the first online session with no saved bindings. These sessions may already contain work. Once a default exists, another tool's sessionId selects only that call's target. Defaults survive broker restarts, and several chats can share one Pi session. When resuming work after explicit or implicit initialization, read recent history to recover progress, then continue from the current request.
4
4
 
5
5
  The active model is this existing ChatGPT conversation. A Pi tool that starts another chappie/chatgpt agent has no ChatGPT conversation to attach to and will wait indefinitely. Subagents targeting another configured model keep that provider's normal behavior.
6
6
 
@@ -13,3 +13,5 @@ Use chat for assistant messages in Pi, including progress, explanations, and res
13
13
  transfer pairs files from ChatGPT with Pi destination paths in order. Omit files to return resource links for Pi paths or chappie:// image references. Relative paths use the Pi working directory; overwrite: true replaces existing targets. The host may request confirmation when retrieving exported bytes.
14
14
 
15
15
  Tool results identify the executing Pi sessionId and cwd. A shell command can access another directory without changing its Pi session. structuredContent.text includes the complete text, new Pi input, webAnswer, and deferred results; images and resources are native content blocks. Continue from received results rather than repeating work. Host deadlines include queueing and execution; use local persistent processes for longer work.
16
+
17
+ Use history to read the current Pi branch with original entry IDs and timestamps, including chat activity records. Assistant messages and tool results expose their originating chatId and requestId in message.chappie; activity records include the same fields. Request IDs and the workflow suffix in notices describe recorded requests, and duplicate executions can share a workflow ID. It defaults to the last 20 entries; before pages backward and after pages forward. History is a record of past work, separate from new Pi input. It leaves a reading notice and returns its content only to the caller.
package/src/ipc.ts CHANGED
@@ -17,7 +17,9 @@ import type {
17
17
  SlashCommandInfo,
18
18
  ToolInfo,
19
19
  } from "@earendil-works/pi-coding-agent";
20
+ import type { Activity } from "./activity.ts";
20
21
  import type { DeliveryRecord } from "./delivery.ts";
22
+ import type { HistoryRange, HistoryResult } from "./history.ts";
21
23
  import type { ResourceData } from "./resources.ts";
22
24
 
23
25
  const defaultPort = 24274;
@@ -57,6 +59,7 @@ export type SessionResult =
57
59
  toolResults: ToolResultMessage[];
58
60
  inputs: SessionInput[];
59
61
  }
62
+ | { history: HistoryResult; cwd: string }
60
63
  | { resource: ResourceData }
61
64
  | { error: string };
62
65
 
@@ -69,12 +72,21 @@ export type SessionMessage =
69
72
  export type BrokerMessage =
70
73
  | { type: "synced"; id: number; sessionId: string }
71
74
  | { type: "stored"; id: string }
72
- | { type: "notice"; sessionId: string; message: string }
75
+ | { type: "notice"; sessionId: string; message: string; activity?: Activity }
73
76
  | { type: "inspect"; id: number; sessionId: string }
77
+ | {
78
+ type: "history";
79
+ id: number;
80
+ sessionId: string;
81
+ range: HistoryRange;
82
+ chatId: string;
83
+ requestId?: string;
84
+ }
74
85
  | {
75
86
  type: "chat";
76
87
  id: number;
77
88
  chatId: string;
89
+ requestId?: string;
78
90
  sessionId: string;
79
91
  text: string;
80
92
  }
@@ -82,6 +94,7 @@ export type BrokerMessage =
82
94
  type: "call";
83
95
  id: number;
84
96
  chatId: string;
97
+ requestId?: string;
85
98
  sessionId: string;
86
99
  calls: ToolCall[];
87
100
  }
package/src/provider.ts CHANGED
@@ -9,10 +9,11 @@ import {
9
9
  type StreamOptions,
10
10
  type ToolCall,
11
11
  } from "@earendil-works/pi-ai";
12
+ import type { Source } from "./activity.ts";
12
13
 
13
14
  export class ProviderOutput {
14
15
  readonly stream: AssistantMessageEventStream;
15
- readonly message: AssistantMessage;
16
+ readonly message: AssistantMessage & { chappie?: Source };
16
17
  readonly finished: Promise<void>;
17
18
  #resolveFinished: () => void;
18
19
  #removeAbort?: () => void;
package/src/server.ts CHANGED
@@ -4,6 +4,7 @@ import * as z from "zod";
4
4
  import packageJson from "../package.json" with { type: "json" };
5
5
  import type { Broker } from "./broker.ts";
6
6
  import { deliveryContent } from "./delivery.ts";
7
+ import { historyInput } from "./history.ts";
7
8
  import {
8
9
  answerContent,
9
10
  answerInput,
@@ -31,6 +32,9 @@ const outputSchema = z.object({
31
32
  ),
32
33
  });
33
34
 
35
+ const syncInstructions =
36
+ "Synchronization ends accidental duplicate executions so one execution continues. Retain each initialization code privately in this ChatGPT context for its Pi session. On conflicting activity, call sync with action start, then verify with the code returned by your own most recent initialization. A rejected or unavailable code means this execution must send one final chat message with its task, entry time, and explicit exit statement, then end this ChatGPT response immediately. It must not wait for unlock, poll, reinitialize, or resume after release. Verification returns a new code and leaves ordinary tools and initialization locked. The verified execution uses chat and history to identify conflicting executions, require their final exit messages, and confirm their responses have ended before releasing with that code. Idle Pi status or a quiet history page is not proof of exit. Codes belong only in initialization and sync exchanges, never in chat, history, or notes.";
37
+
34
38
  const questionTemplate = "ui://chappie/question.html";
35
39
  const questionSchema = outputSchema.extend({ question: questionOutput });
36
40
  const toolAnnotations = {
@@ -54,26 +58,30 @@ export function createServer(broker: Broker): McpServer {
54
58
  version: packageJson.version,
55
59
  },
56
60
  {
57
- instructions: broker.askEnabled
58
- ? `${instructions}\n\n${questionInstructions}`
59
- : instructions,
61
+ instructions: [
62
+ instructions,
63
+ ...(broker.askEnabled ? [questionInstructions] : []),
64
+ ...(broker.syncEnabled ? [syncInstructions] : []),
65
+ ].join("\n\n"),
60
66
  },
61
67
  );
62
68
 
63
69
  function handle<Args, Result>(
64
70
  callback: (args: Args, context: RequestContext) => Promise<Result>,
71
+ communication = false,
65
72
  ) {
66
- return async (args: Args, context: RequestContext): Promise<Result> => {
67
- const signal = await broker.workflow(
68
- requestChatId(context),
69
- context.mcpReq._meta?.["otunnel/requestId"],
73
+ return (args: Args, context: RequestContext): Promise<Result> =>
74
+ broker.run(
75
+ requireChatId(context),
76
+ (args as { sessionId?: string }).sessionId,
70
77
  context.mcpReq.signal,
78
+ (signal) =>
79
+ callback(args, {
80
+ ...context,
81
+ mcpReq: { ...context.mcpReq, signal },
82
+ }),
83
+ communication,
71
84
  );
72
- return callback(args, {
73
- ...context,
74
- mcpReq: { ...context.mcpReq, signal },
75
- });
76
- };
77
85
  }
78
86
 
79
87
  server.registerTool(
@@ -81,7 +89,7 @@ export function createServer(broker: Broker): McpServer {
81
89
  {
82
90
  title: "Connect to Pi",
83
91
  description:
84
- "Select this chat's default Pi session and return its environment and tool catalog. Use the task's sessionId to resume, or find it by cwd/name with sessions. For a task without a specified target, omit sessionId to reuse the default or select the first online, unbound session.",
92
+ "Select this chat's default Pi session and return its environment and tool catalog. Use the task's sessionId to resume, or find it by cwd/name with sessions. For a task without a specified target, omit sessionId to reuse the default or select the first online, unbound session. Read recent history when resuming work.",
85
93
  outputSchema,
86
94
  inputSchema: z.object({
87
95
  sessionId: z
@@ -96,6 +104,7 @@ export function createServer(broker: Broker): McpServer {
96
104
  const { inputs, ...initialized } = await broker.initialize(
97
105
  chatId,
98
106
  args.sessionId,
107
+ context.mcpReq._meta?.["otunnel/requestId"],
99
108
  context.mcpReq.signal,
100
109
  );
101
110
  return finishResult(broker, context, textResult(initialized, inputs));
@@ -114,24 +123,30 @@ export function createServer(broker: Broker): McpServer {
114
123
  sessionId: z
115
124
  .string()
116
125
  .optional()
117
- .describe("Pi session for this operation only"),
126
+ .describe(
127
+ "Pi session for this operation; becomes the default if none is set",
128
+ ),
118
129
  }),
119
130
  annotations: toolAnnotations,
120
131
  },
121
132
  handle(async (args, context) => {
122
133
  const chatId = requireChatId(context);
123
- const { sessionId, cwd, inputs } = await broker.chat(
134
+ const { sessionId, cwd, inputs, initialization } = await broker.chat(
124
135
  chatId,
125
136
  args.sessionId,
126
137
  args.text,
138
+ context.mcpReq._meta?.["otunnel/requestId"],
127
139
  context.mcpReq.signal,
128
140
  );
129
141
  return finishResult(
130
142
  broker,
131
143
  context,
132
- textResult({ sessionId, cwd }, inputs),
144
+ textResult(
145
+ { sessionId, cwd, ...(initialization ? { initialization } : {}) },
146
+ inputs,
147
+ ),
133
148
  );
134
- }),
149
+ }, true),
135
150
  );
136
151
 
137
152
  if (broker.askEnabled) {
@@ -154,14 +169,16 @@ export function createServer(broker: Broker): McpServer {
154
169
  _meta: { ui: { resourceUri: questionTemplate } },
155
170
  },
156
171
  handle(async ({ sessionId, ...input }, context) => {
157
- const question = await broker.ask(
172
+ const { initialization, ...question } = await broker.ask(
158
173
  requireChatId(context),
159
174
  sessionId,
160
175
  input,
176
+ context.mcpReq._meta?.["otunnel/requestId"],
161
177
  context.mcpReq.signal,
162
178
  );
163
179
  const result = await finishResult(broker, context, {
164
180
  content: [
181
+ ...(initialization ? textResult({ initialization }).content : []),
165
182
  {
166
183
  type: "text",
167
184
  text: `Question created. Call ask_assert({"questionId":"${question.id}"}) next.`,
@@ -221,7 +238,7 @@ export function createServer(broker: Broker): McpServer {
221
238
  annotations: toolAnnotations,
222
239
  _meta: { ui: { visibility: ["app"] }, "openai/widgetAccessible": true },
223
240
  },
224
- handle(async ({ questionId, answer, loaded = false }, context) => {
241
+ async ({ questionId, answer, loaded = false }, context) => {
225
242
  const question = await broker.answer(
226
243
  requireChatId(context),
227
244
  questionId,
@@ -239,7 +256,7 @@ export function createServer(broker: Broker): McpServer {
239
256
  content: [{ type: "text", text }],
240
257
  structuredContent: { text, question },
241
258
  };
242
- }),
259
+ },
243
260
  );
244
261
 
245
262
  server.registerResource(
@@ -285,7 +302,9 @@ export function createServer(broker: Broker): McpServer {
285
302
  sessionId: z
286
303
  .string()
287
304
  .optional()
288
- .describe("Pi session for this operation only"),
305
+ .describe(
306
+ "Pi session for this operation; becomes the default if none is set",
307
+ ),
289
308
  }),
290
309
  annotations: toolAnnotations,
291
310
  },
@@ -294,13 +313,20 @@ export function createServer(broker: Broker): McpServer {
294
313
  requireChatId(context),
295
314
  args.sessionId,
296
315
  args.names,
316
+ context.mcpReq._meta?.["otunnel/requestId"],
297
317
  context.mcpReq.signal,
298
318
  );
299
319
  return finishResult(
300
320
  broker,
301
321
  context,
302
322
  textResult(
303
- { session: inspected.session, tools: inspected.tools },
323
+ {
324
+ session: inspected.session,
325
+ tools: inspected.tools,
326
+ ...(inspected.initialization
327
+ ? { initialization: inspected.initialization }
328
+ : {}),
329
+ },
304
330
  inputs,
305
331
  ),
306
332
  );
@@ -326,7 +352,9 @@ export function createServer(broker: Broker): McpServer {
326
352
  sessionId: z
327
353
  .string()
328
354
  .optional()
329
- .describe("Pi session for this operation only"),
355
+ .describe(
356
+ "Pi session for this operation; becomes the default if none is set",
357
+ ),
330
358
  }),
331
359
  annotations: toolAnnotations,
332
360
  },
@@ -335,6 +363,7 @@ export function createServer(broker: Broker): McpServer {
335
363
  requireChatId(context),
336
364
  args.sessionId,
337
365
  args.calls,
366
+ context.mcpReq._meta?.["otunnel/requestId"],
338
367
  context.mcpReq.signal,
339
368
  );
340
369
  return finishResult(
@@ -345,6 +374,7 @@ export function createServer(broker: Broker): McpServer {
345
374
  result.sessionId,
346
375
  result.cwd,
347
376
  result.inputs,
377
+ result.initialization,
348
378
  ),
349
379
  );
350
380
  }),
@@ -374,6 +404,7 @@ export function createServer(broker: Broker): McpServer {
374
404
  requireChatId(context),
375
405
  sessionId,
376
406
  calls,
407
+ context.mcpReq._meta?.["otunnel/requestId"],
377
408
  context.mcpReq.signal,
378
409
  );
379
410
  return finishResult(
@@ -384,12 +415,93 @@ export function createServer(broker: Broker): McpServer {
384
415
  result.sessionId,
385
416
  result.cwd,
386
417
  result.inputs,
418
+ result.initialization,
387
419
  ),
388
420
  );
389
421
  }),
390
422
  );
391
423
  }
392
424
 
425
+ server.registerTool(
426
+ "history",
427
+ {
428
+ title: "Session history",
429
+ description:
430
+ "Read recent Pi history with entry IDs and timestamps. Use before/after to page the current branch. History provides context for the current task. An explicit sessionId applies only to this read.",
431
+ inputSchema: historyInput.extend({
432
+ sessionId: z
433
+ .string()
434
+ .optional()
435
+ .describe("Pi session to read; defaults to this chat's session"),
436
+ }),
437
+ outputSchema,
438
+ annotations: toolAnnotations,
439
+ },
440
+ handle(async ({ sessionId, ...range }, context) => {
441
+ const { history, ...session } = await broker.history(
442
+ requireChatId(context),
443
+ sessionId,
444
+ range,
445
+ context.mcpReq._meta?.["otunnel/requestId"],
446
+ context.mcpReq.signal,
447
+ );
448
+ const { content, ...page } = history;
449
+ return formatResult({
450
+ content: [
451
+ ...textResult({
452
+ ...session,
453
+ history: page,
454
+ ...(broker.syncEnabled
455
+ ? { sync: broker.syncState(session.sessionId) }
456
+ : {}),
457
+ }).content,
458
+ ...content,
459
+ ],
460
+ });
461
+ }, true),
462
+ );
463
+
464
+ if (broker.syncEnabled) {
465
+ server.registerTool(
466
+ "sync",
467
+ {
468
+ title: "Synchronize Pi activity",
469
+ description:
470
+ "End duplicate executions: start synchronization, verify your own latest initialization code, and release after conflicting executions exit. Failed verification requires a final chat exit message followed by ending this response. Verification leaves the session locked; chat and history support exit coordination.",
471
+ inputSchema: z.object({
472
+ action: z.enum(["start", "verify", "release"]),
473
+ code: z
474
+ .string()
475
+ .optional()
476
+ .describe(
477
+ "Initialization code for verification, or the verified code for release; omit when unavailable",
478
+ ),
479
+ sessionId: z
480
+ .string()
481
+ .optional()
482
+ .describe(
483
+ "Pi session to synchronize; defaults to this chat's session",
484
+ ),
485
+ }),
486
+ outputSchema,
487
+ annotations: toolAnnotations,
488
+ },
489
+ async ({ action, code, sessionId }, context) =>
490
+ formatResult(
491
+ textResult(
492
+ await broker.sync(
493
+ requireChatId(context),
494
+ sessionId,
495
+ action,
496
+ code,
497
+ context.mcpReq._meta?.["otunnel/requestId"],
498
+ context.mcpReq.signal,
499
+ ),
500
+ ),
501
+ ),
502
+ );
503
+ }
504
+
393
505
  server.registerTool(
394
506
  "sessions",
395
507
  {
@@ -418,7 +530,7 @@ export function createServer(broker: Broker): McpServer {
418
530
  inputs,
419
531
  );
420
532
  return finishResult(broker, context, result);
421
- }),
533
+ }, true),
422
534
  );
423
535
 
424
536
  server.registerResource(
@@ -462,21 +574,32 @@ function textResult(
462
574
  async function finishResult<
463
575
  T extends { content: ReturnType<typeof toolResult>["content"] },
464
576
  >(broker: Broker, context: RequestContext, result: T) {
577
+ context.mcpReq.signal.throwIfAborted();
465
578
  const chatId = requestChatId(context);
466
- const deliveries = chatId ? await broker.deliveries(chatId) : [];
579
+ if (!broker.deliveryAllowed(context.mcpReq.signal))
580
+ return formatResult(result);
581
+ const deliveries = chatId ? broker.deliveries(chatId) : [];
467
582
  const answers = chatId ? broker.answers(chatId) : [];
468
583
  const content = [
469
584
  ...result.content,
470
585
  ...deliveryContent(deliveries),
471
586
  ...answerContent(answers),
472
587
  ];
473
- const structuredContent = {
474
- text: content
475
- .flatMap((block) => (block.type === "text" ? [block.text] : []))
476
- .join("\n"),
477
- };
478
588
  await broker.acknowledge(deliveries, answers, context.mcpReq.signal);
479
- return { ...result, content, structuredContent };
589
+ return formatResult({ ...result, content });
590
+ }
591
+
592
+ function formatResult<
593
+ T extends { content: ReturnType<typeof toolResult>["content"] },
594
+ >(result: T) {
595
+ return {
596
+ ...result,
597
+ structuredContent: {
598
+ text: result.content
599
+ .flatMap((block) => (block.type === "text" ? [block.text] : []))
600
+ .join("\n"),
601
+ },
602
+ };
480
603
  }
481
604
 
482
605
  function requestChatId(context: RequestContext): string | undefined {