@oh-my-pi/pi-coding-agent 16.2.1 → 16.2.3

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.
Files changed (187) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/cli.js +3621 -3579
  3. package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
  4. package/dist/types/advisor/advise-tool.d.ts +8 -4
  5. package/dist/types/advisor/config.d.ts +88 -0
  6. package/dist/types/advisor/index.d.ts +1 -0
  7. package/dist/types/advisor/runtime.d.ts +15 -1
  8. package/dist/types/advisor/transcript-recorder.d.ts +13 -2
  9. package/dist/types/advisor/watchdog.d.ts +20 -0
  10. package/dist/types/collab/guest.d.ts +29 -0
  11. package/dist/types/config/model-roles.d.ts +1 -1
  12. package/dist/types/config/settings-schema.d.ts +113 -12
  13. package/dist/types/debug/log-viewer.d.ts +1 -0
  14. package/dist/types/debug/raw-sse.d.ts +1 -0
  15. package/dist/types/discovery/omp-extension-roots.d.ts +3 -3
  16. package/dist/types/edit/hashline/diff.d.ts +0 -11
  17. package/dist/types/edit/index.d.ts +18 -0
  18. package/dist/types/edit/streaming.d.ts +30 -0
  19. package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
  20. package/dist/types/extensibility/shared-events.d.ts +1 -0
  21. package/dist/types/extensibility/tool-event-input.d.ts +7 -0
  22. package/dist/types/extensibility/utils.d.ts +12 -0
  23. package/dist/types/mcp/oauth-discovery.d.ts +0 -11
  24. package/dist/types/mcp/transports/index.d.ts +1 -0
  25. package/dist/types/mcp/transports/sse.d.ts +20 -0
  26. package/dist/types/modes/components/advisor-config.d.ts +59 -0
  27. package/dist/types/modes/components/index.d.ts +1 -0
  28. package/dist/types/modes/components/model-selector.d.ts +9 -1
  29. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  30. package/dist/types/modes/components/status-line/component.d.ts +30 -3
  31. package/dist/types/modes/components/status-line/types.d.ts +13 -1
  32. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  33. package/dist/types/modes/interactive-mode.d.ts +10 -4
  34. package/dist/types/modes/skill-command.d.ts +32 -0
  35. package/dist/types/modes/types.d.ts +7 -2
  36. package/dist/types/sdk.d.ts +1 -1
  37. package/dist/types/session/agent-session.d.ts +84 -12
  38. package/dist/types/session/indexed-session-storage.d.ts +7 -1
  39. package/dist/types/session/messages.d.ts +32 -7
  40. package/dist/types/session/messages.test.d.ts +1 -0
  41. package/dist/types/session/session-entries.d.ts +31 -3
  42. package/dist/types/session/session-history-format.d.ts +6 -0
  43. package/dist/types/session/session-loader.d.ts +9 -1
  44. package/dist/types/session/session-manager.d.ts +6 -4
  45. package/dist/types/session/session-storage.d.ts +11 -0
  46. package/dist/types/session/session-title-slot.d.ts +19 -0
  47. package/dist/types/session/settings-stream-fn.d.ts +21 -0
  48. package/dist/types/session/turn-persistence.d.ts +88 -0
  49. package/dist/types/ssh/connection-manager.d.ts +47 -0
  50. package/dist/types/ssh/utils.d.ts +16 -0
  51. package/dist/types/task/executor.d.ts +3 -16
  52. package/dist/types/task/render.d.ts +0 -5
  53. package/dist/types/task/renderer.d.ts +13 -0
  54. package/dist/types/task/types.d.ts +16 -0
  55. package/dist/types/task/yield-assembly.d.ts +28 -0
  56. package/dist/types/tiny/text.d.ts +8 -0
  57. package/dist/types/tools/render-utils.d.ts +2 -0
  58. package/dist/types/tools/review.d.ts +6 -4
  59. package/dist/types/tools/ssh.d.ts +1 -1
  60. package/dist/types/tools/todo.d.ts +6 -0
  61. package/dist/types/tools/yield.d.ts +8 -3
  62. package/dist/types/utils/thinking-display.d.ts +4 -0
  63. package/package.json +12 -12
  64. package/src/advisor/__tests__/advisor.test.ts +438 -10
  65. package/src/advisor/__tests__/config.test.ts +173 -0
  66. package/src/advisor/advise-tool.ts +11 -6
  67. package/src/advisor/config.ts +256 -0
  68. package/src/advisor/index.ts +1 -0
  69. package/src/advisor/runtime.ts +77 -4
  70. package/src/advisor/transcript-recorder.ts +25 -2
  71. package/src/advisor/watchdog.ts +57 -31
  72. package/src/auto-thinking/classifier.ts +2 -2
  73. package/src/autoresearch/index.ts +7 -2
  74. package/src/cli/gc-cli.ts +17 -10
  75. package/src/collab/guest.ts +43 -7
  76. package/src/config/model-registry.ts +80 -18
  77. package/src/config/model-resolver.ts +5 -1
  78. package/src/config/model-roles.ts +3 -3
  79. package/src/config/settings-schema.ts +107 -8
  80. package/src/debug/index.ts +32 -7
  81. package/src/debug/log-viewer.ts +111 -53
  82. package/src/debug/raw-sse.ts +68 -48
  83. package/src/discovery/codex.ts +13 -5
  84. package/src/discovery/omp-extension-roots.ts +38 -13
  85. package/src/edit/hashline/diff.ts +57 -4
  86. package/src/edit/index.ts +21 -0
  87. package/src/edit/streaming.ts +170 -0
  88. package/src/eval/js/shared/local-module-loader.ts +23 -1
  89. package/src/export/html/template.js +13 -7
  90. package/src/extensibility/custom-tools/types.ts +1 -0
  91. package/src/extensibility/extensions/loader.ts +5 -3
  92. package/src/extensibility/extensions/wrapper.ts +9 -3
  93. package/src/extensibility/hooks/loader.ts +3 -3
  94. package/src/extensibility/hooks/tool-wrapper.ts +13 -4
  95. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +18 -1
  96. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +59 -2
  97. package/src/extensibility/plugins/manager.ts +76 -5
  98. package/src/extensibility/shared-events.ts +1 -0
  99. package/src/extensibility/tool-event-input.ts +23 -0
  100. package/src/extensibility/utils.ts +74 -0
  101. package/src/internal-urls/docs-index.generated.txt +1 -1
  102. package/src/mcp/client.ts +3 -1
  103. package/src/mcp/manager.ts +12 -5
  104. package/src/mcp/oauth-discovery.ts +5 -29
  105. package/src/mcp/transports/http.ts +3 -1
  106. package/src/mcp/transports/index.ts +1 -0
  107. package/src/mcp/transports/sse.ts +377 -0
  108. package/src/memories/index.ts +15 -6
  109. package/src/mnemopi/backend.ts +2 -2
  110. package/src/modes/acp/acp-agent.ts +1 -1
  111. package/src/modes/components/advisor-config.ts +555 -0
  112. package/src/modes/components/advisor-message.ts +9 -2
  113. package/src/modes/components/agent-hub.ts +9 -4
  114. package/src/modes/components/assistant-message.ts +5 -5
  115. package/src/modes/components/index.ts +2 -0
  116. package/src/modes/components/model-selector.ts +79 -48
  117. package/src/modes/components/settings-selector.ts +1 -0
  118. package/src/modes/components/status-line/component.ts +145 -14
  119. package/src/modes/components/status-line/segments.ts +47 -22
  120. package/src/modes/components/status-line/types.ts +13 -1
  121. package/src/modes/components/tool-execution.ts +47 -6
  122. package/src/modes/controllers/command-controller.ts +23 -2
  123. package/src/modes/controllers/event-controller.ts +114 -11
  124. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  125. package/src/modes/controllers/input-controller.ts +61 -61
  126. package/src/modes/controllers/selector-controller.ts +100 -9
  127. package/src/modes/interactive-mode.ts +65 -10
  128. package/src/modes/print-mode.ts +1 -1
  129. package/src/modes/skill-command.ts +116 -0
  130. package/src/modes/types.ts +7 -2
  131. package/src/modes/utils/transcript-render-helpers.ts +2 -2
  132. package/src/modes/utils/ui-helpers.ts +46 -27
  133. package/src/prompts/agents/reviewer.md +11 -10
  134. package/src/prompts/review-custom-request.md +1 -2
  135. package/src/prompts/review-request.md +1 -2
  136. package/src/prompts/system/interrupted-thinking.md +7 -0
  137. package/src/prompts/system/recap-user.md +9 -0
  138. package/src/prompts/system/subagent-system-prompt.md +8 -5
  139. package/src/prompts/system/subagent-yield-reminder.md +6 -5
  140. package/src/prompts/system/system-prompt.md +0 -1
  141. package/src/prompts/tools/irc.md +2 -2
  142. package/src/prompts/tools/read.md +2 -2
  143. package/src/sdk.ts +40 -50
  144. package/src/session/agent-session.ts +1139 -600
  145. package/src/session/indexed-session-storage.ts +86 -13
  146. package/src/session/messages.test.ts +125 -0
  147. package/src/session/messages.ts +192 -21
  148. package/src/session/redis-session-storage.ts +49 -2
  149. package/src/session/session-entries.ts +39 -2
  150. package/src/session/session-history-format.ts +29 -2
  151. package/src/session/session-listing.ts +54 -24
  152. package/src/session/session-loader.ts +66 -3
  153. package/src/session/session-manager.ts +113 -19
  154. package/src/session/session-persistence.ts +96 -3
  155. package/src/session/session-storage.ts +36 -0
  156. package/src/session/session-title-slot.ts +141 -0
  157. package/src/session/settings-stream-fn.ts +49 -0
  158. package/src/session/sql-session-storage.ts +71 -11
  159. package/src/session/turn-persistence.ts +142 -0
  160. package/src/session/unexpected-stop-classifier.ts +2 -2
  161. package/src/slash-commands/builtin-registry.ts +16 -3
  162. package/src/slash-commands/helpers/mcp.ts +2 -1
  163. package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
  164. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
  165. package/src/ssh/connection-manager.ts +139 -12
  166. package/src/ssh/file-transfer.ts +23 -18
  167. package/src/ssh/ssh-executor.ts +2 -13
  168. package/src/ssh/utils.ts +19 -0
  169. package/src/task/executor.ts +21 -23
  170. package/src/task/render.ts +162 -20
  171. package/src/task/renderer.ts +14 -0
  172. package/src/task/types.ts +17 -0
  173. package/src/task/yield-assembly.ts +207 -0
  174. package/src/tiny/models.ts +8 -6
  175. package/src/tiny/text.ts +37 -7
  176. package/src/tools/ask.ts +55 -4
  177. package/src/tools/image-gen.ts +2 -1
  178. package/src/tools/render-utils.ts +2 -0
  179. package/src/tools/renderers.ts +8 -2
  180. package/src/tools/review.ts +17 -7
  181. package/src/tools/ssh.ts +8 -4
  182. package/src/tools/todo.ts +17 -1
  183. package/src/tools/tts.ts +2 -1
  184. package/src/tools/yield.ts +140 -31
  185. package/src/utils/thinking-display.ts +15 -0
  186. package/src/utils/title-generator.ts +1 -1
  187. package/src/web/search/providers/tavily.ts +36 -19
@@ -1,10 +1,20 @@
1
1
  import { toError } from "@oh-my-pi/pi-utils";
2
2
  import type { SessionStorage, SessionStorageStat, SessionStorageWriter } from "./session-storage";
3
+ import {
4
+ overlayTitleSlotContent,
5
+ overlayTitleSlotPrefix,
6
+ parseTitleSlotFromContent,
7
+ type SessionTitleUpdate,
8
+ titleUpdateFromSlot,
9
+ } from "./session-title-slot";
3
10
 
4
11
  export interface SessionStorageIndexEntry {
5
12
  path: string;
6
13
  size: number;
7
14
  mtimeMs: number;
15
+ title?: string;
16
+ titleSource?: SessionTitleUpdate["source"];
17
+ titleUpdatedAt?: string;
8
18
  }
9
19
 
10
20
  export interface SessionStorageBackend {
@@ -12,8 +22,9 @@ export interface SessionStorageBackend {
12
22
  loadIndex(): Promise<Iterable<SessionStorageIndexEntry>>;
13
23
  readFull(path: string): Promise<string | null>;
14
24
  readSlices(path: string, prefixBytes: number, suffixBytes: number): Promise<[string, string]>;
15
- writeFull(path: string, content: string, mtimeMs: number): Promise<void>;
25
+ writeFull(path: string, content: string, mtimeMs: number, title?: SessionTitleUpdate): Promise<void>;
16
26
  append(path: string, line: string, mtimeMs: number): Promise<void>;
27
+ updateSessionTitle(path: string, title: SessionTitleUpdate, mtimeMs: number): Promise<void>;
17
28
  truncate(path: string, mtimeMs: number): Promise<void>;
18
29
  remove(paths: string[]): Promise<void>;
19
30
  move(src: string, dst: string, mtimeMs: number): Promise<void>;
@@ -22,6 +33,9 @@ export interface SessionStorageBackend {
22
33
  interface IndexEntry {
23
34
  size: number;
24
35
  mtimeMs: number;
36
+ title?: string;
37
+ titleSource?: SessionTitleUpdate["source"];
38
+ titleUpdatedAt?: string;
25
39
  }
26
40
 
27
41
  interface EnqueueOptions {
@@ -64,6 +78,10 @@ function uniquePaths(paths: readonly string[]): string[] {
64
78
  }
65
79
  return out;
66
80
  }
81
+ function titleUpdateForIndex(entry: IndexEntry): SessionTitleUpdate | undefined {
82
+ if (!entry.titleUpdatedAt) return undefined;
83
+ return { title: entry.title, source: entry.titleSource, updatedAt: entry.titleUpdatedAt };
84
+ }
67
85
 
68
86
  export class IndexedSessionStorage implements SessionStorage {
69
87
  readonly #backend: SessionStorageBackend;
@@ -89,7 +107,10 @@ export class IndexedSessionStorage implements SessionStorage {
89
107
  const rows = await this.#backend.loadIndex();
90
108
  this.#index.clear();
91
109
  for (const row of rows) {
92
- this.#setIndex(row.path, row.size, row.mtimeMs);
110
+ const title = row.titleUpdatedAt
111
+ ? { title: row.title, source: row.titleSource, updatedAt: row.titleUpdatedAt }
112
+ : null;
113
+ this.#setIndex(row.path, row.size, row.mtimeMs, title);
93
114
  }
94
115
  }
95
116
 
@@ -112,8 +133,40 @@ export class IndexedSessionStorage implements SessionStorage {
112
133
 
113
134
  writeTextSync(path: string, content: string): void {
114
135
  const mtimeMs = this.#allocMtimeMs();
115
- this.#setIndex(path, byteLength(content), mtimeMs);
116
- this.#enqueuePath(path, () => this.#backend.writeFull(path, content, mtimeMs), { trackDrain: true });
136
+ const title = titleUpdateFromSlot(parseTitleSlotFromContent(content));
137
+ this.#setIndex(path, byteLength(content), mtimeMs, title ?? null);
138
+ this.#enqueuePath(path, () => this.#backend.writeFull(path, content, mtimeMs, title), { trackDrain: true });
139
+ }
140
+
141
+ async updateSessionTitle(path: string, title: SessionTitleUpdate): Promise<void> {
142
+ await this.#awaitPath(path);
143
+ const previous = this.#index.get(path);
144
+ if (!previous) throw enoent(path);
145
+ const mtimeMs = this.#allocMtimeMs();
146
+ const next = {
147
+ ...previous,
148
+ title: title.title,
149
+ titleSource: title.source,
150
+ titleUpdatedAt: title.updatedAt,
151
+ mtimeMs,
152
+ };
153
+ this.#index.set(path, next);
154
+ try {
155
+ await this.#enqueuePath(path, () => this.#backend.updateSessionTitle(path, title, mtimeMs), {
156
+ trackDrain: false,
157
+ });
158
+ } catch (err) {
159
+ const current = this.#index.get(path);
160
+ if (
161
+ current?.mtimeMs === next.mtimeMs &&
162
+ current.title === next.title &&
163
+ current.titleSource === next.titleSource &&
164
+ current.titleUpdatedAt === next.titleUpdatedAt
165
+ ) {
166
+ this.#index.set(path, previous);
167
+ }
168
+ throw toError(err);
169
+ }
117
170
  }
118
171
 
119
172
  statSync(path: string): SessionStorageStat {
@@ -144,29 +197,37 @@ export class IndexedSessionStorage implements SessionStorage {
144
197
  }
145
198
 
146
199
  async readText(path: string): Promise<string> {
147
- if (!this.#index.has(path)) throw enoent(path);
200
+ const entry = this.#index.get(path);
201
+ if (!entry) throw enoent(path);
148
202
  await this.#awaitPath(path);
149
203
  const content = await this.#backend.readFull(path);
150
204
  if (content === null) throw enoent(path);
151
- return content;
205
+ const title = titleUpdateForIndex(entry);
206
+ return title ? overlayTitleSlotContent(content, title) : content;
152
207
  }
153
208
 
154
209
  async readTextSlices(path: string, prefixBytes: number, suffixBytes: number): Promise<[string, string]> {
155
- if (!this.#index.has(path)) throw enoent(path);
210
+ const entry = this.#index.get(path);
211
+ if (!entry) throw enoent(path);
156
212
  const prefixLimit = normalizeByteLimit(prefixBytes);
157
213
  const suffixLimit = normalizeByteLimit(suffixBytes);
158
214
  if (prefixLimit === 0 && suffixLimit === 0) return ["", ""];
159
215
  await this.#awaitPath(path);
160
- return this.#backend.readSlices(path, prefixLimit, suffixLimit);
216
+ const [prefix, suffix] = await this.#backend.readSlices(path, prefixLimit, suffixLimit);
217
+ const title = titleUpdateForIndex(entry);
218
+ return [title ? overlayTitleSlotPrefix(prefix, prefixLimit, title) : prefix, suffix];
161
219
  }
162
220
 
163
221
  async writeText(path: string, content: string): Promise<void> {
164
222
  await this.#awaitPath(path);
165
223
  const previous = this.#index.get(path);
166
224
  const mtimeMs = this.#allocMtimeMs();
167
- this.#setIndex(path, byteLength(content), mtimeMs);
225
+ const title = titleUpdateFromSlot(parseTitleSlotFromContent(content));
226
+ this.#setIndex(path, byteLength(content), mtimeMs, title ?? null);
168
227
  try {
169
- await this.#enqueuePath(path, () => this.#backend.writeFull(path, content, mtimeMs), { trackDrain: false });
228
+ await this.#enqueuePath(path, () => this.#backend.writeFull(path, content, mtimeMs, title), {
229
+ trackDrain: false,
230
+ });
170
231
  } catch (err) {
171
232
  this.#restoreIndex(path, previous);
172
233
  throw toError(err);
@@ -249,7 +310,7 @@ export class IndexedSessionStorage implements SessionStorage {
249
310
 
250
311
  _truncateForWriter(path: string): number {
251
312
  const mtimeMs = this.#allocMtimeMs();
252
- this.#setIndex(path, 0, mtimeMs);
313
+ this.#setIndex(path, 0, mtimeMs, null);
253
314
  return mtimeMs;
254
315
  }
255
316
 
@@ -293,8 +354,20 @@ export class IndexedSessionStorage implements SessionStorage {
293
354
  }
294
355
  }
295
356
 
296
- #setIndex(path: string, size: number, mtimeMs: number): void {
297
- this.#index.set(path, { size, mtimeMs });
357
+ #setIndex(
358
+ path: string,
359
+ size: number,
360
+ mtimeMs: number,
361
+ title: SessionTitleUpdate | null | undefined = undefined,
362
+ ): void {
363
+ const current = title === undefined ? this.#index.get(path) : undefined;
364
+ this.#index.set(path, {
365
+ size,
366
+ mtimeMs,
367
+ title: title === undefined ? current?.title : (title?.title ?? undefined),
368
+ titleSource: title === undefined ? current?.titleSource : (title?.source ?? undefined),
369
+ titleUpdatedAt: title === undefined ? current?.titleUpdatedAt : (title?.updatedAt ?? undefined),
370
+ });
298
371
  if (mtimeMs > this.#nextMtimeMs) this.#nextMtimeMs = mtimeMs;
299
372
  }
300
373
 
@@ -0,0 +1,125 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
4
+ import {
5
+ type CustomMessage,
6
+ convertToLlm,
7
+ INTERRUPTED_THINKING_MESSAGE_TYPE,
8
+ SKILL_PROMPT_MESSAGE_TYPE,
9
+ type SkillPromptDetails,
10
+ } from "./messages";
11
+
12
+ function customMessage(customType: string, attribution: "agent" | "user"): CustomMessage<SkillPromptDetails> {
13
+ return {
14
+ role: "custom",
15
+ customType,
16
+ content: "Use this skill.",
17
+ display: true,
18
+ details: { name: "atomic-commit", path: "/tmp/SKILL.md", lineCount: 1 },
19
+ attribution,
20
+ timestamp: 1,
21
+ };
22
+ }
23
+
24
+ const interruptedUsage: AssistantMessage["usage"] = {
25
+ input: 0,
26
+ output: 0,
27
+ cacheRead: 0,
28
+ cacheWrite: 0,
29
+ totalTokens: 0,
30
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
31
+ };
32
+
33
+ function abortedAssistant(content: AssistantMessage["content"]): AssistantMessage {
34
+ return {
35
+ role: "assistant",
36
+ content,
37
+ api: "anthropic-messages",
38
+ provider: "anthropic",
39
+ model: "claude-sonnet-4-5",
40
+ usage: interruptedUsage,
41
+ stopReason: "aborted",
42
+ timestamp: 1,
43
+ };
44
+ }
45
+
46
+ function interruptedThinkingContinuity(): CustomMessage {
47
+ return {
48
+ role: "custom",
49
+ customType: INTERRUPTED_THINKING_MESSAGE_TYPE,
50
+ content: "preserved reasoning",
51
+ display: false,
52
+ attribution: "agent",
53
+ timestamp: 2,
54
+ };
55
+ }
56
+
57
+ describe("convertToLlm", () => {
58
+ it("presents user-invoked skill prompts as user turns", () => {
59
+ const [message] = convertToLlm([customMessage(SKILL_PROMPT_MESSAGE_TYPE, "user")]);
60
+
61
+ expect(message?.role).toBe("user");
62
+ if (message?.role !== "user") {
63
+ throw new Error(`Expected user role, received ${message?.role ?? "none"}`);
64
+ }
65
+ expect(message.attribution).toBe("user");
66
+ });
67
+
68
+ it("keeps auto-applied skill prompts and other custom messages as developer turns", () => {
69
+ const [autoSkill, otherCustom] = convertToLlm([
70
+ customMessage(SKILL_PROMPT_MESSAGE_TYPE, "agent"),
71
+ customMessage("extension-note", "user"),
72
+ ]);
73
+
74
+ expect(autoSkill?.role).toBe("developer");
75
+ expect(otherCustom?.role).toBe("developer");
76
+ });
77
+
78
+ it("strips the demoted trailing thinking run from the assistant LLM view when its continuity message follows", () => {
79
+ const messages: AgentMessage[] = [
80
+ abortedAssistant([
81
+ { type: "text", text: "partial answer" },
82
+ { type: "thinking", thinking: "interrupted reasoning" },
83
+ ]),
84
+ interruptedThinkingContinuity(),
85
+ ];
86
+
87
+ const llm = convertToLlm(messages);
88
+ const assistant = llm.find(entry => entry.role === "assistant");
89
+ expect(Array.isArray(assistant?.content) && assistant.content.map(block => block.type)).toEqual(["text"]);
90
+ expect(llm.some(entry => entry.role === "developer")).toBe(true);
91
+ });
92
+
93
+ it("keeps trailing thinking on the assistant LLM view when no continuity message follows", () => {
94
+ const messages: AgentMessage[] = [
95
+ abortedAssistant([
96
+ { type: "text", text: "partial answer" },
97
+ { type: "thinking", thinking: "interrupted reasoning" },
98
+ ]),
99
+ ];
100
+
101
+ const llm = convertToLlm(messages);
102
+ const assistant = llm.find(entry => entry.role === "assistant");
103
+ expect(Array.isArray(assistant?.content) && assistant.content.map(block => block.type)).toEqual([
104
+ "text",
105
+ "thinking",
106
+ ]);
107
+ });
108
+
109
+ it("keeps a signed (complete) trailing thinking block in the assistant LLM view even with a continuity message", () => {
110
+ const messages: AgentMessage[] = [
111
+ abortedAssistant([
112
+ { type: "text", text: "partial answer" },
113
+ { type: "thinking", thinking: "complete reasoning", thinkingSignature: "sig" },
114
+ ]),
115
+ interruptedThinkingContinuity(),
116
+ ];
117
+
118
+ const llm = convertToLlm(messages);
119
+ const assistant = llm.find(entry => entry.role === "assistant");
120
+ expect(Array.isArray(assistant?.content) && assistant.content.map(block => block.type)).toEqual([
121
+ "text",
122
+ "thinking",
123
+ ]);
124
+ });
125
+ });
@@ -18,6 +18,7 @@ import type {
18
18
  TextContent,
19
19
  UserMessage,
20
20
  } from "@oh-my-pi/pi-ai";
21
+ import * as AIError from "@oh-my-pi/pi-ai/error";
21
22
  import { prompt } from "@oh-my-pi/pi-utils";
22
23
  import userInterjectionTemplate from "../prompts/steering/user-interjection.md" with { type: "text" };
23
24
 
@@ -36,6 +37,97 @@ export const SKILL_PROMPT_MESSAGE_TYPE = "skill-prompt";
36
37
  export const LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE = "lsp-late-diagnostic";
37
38
  export const BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE = "background-tan-dispatch";
38
39
 
40
+ /** Custom message type for hidden interrupted-thinking continuity context. */
41
+ export const INTERRUPTED_THINKING_MESSAGE_TYPE = "interrupted-thinking";
42
+
43
+ /** Metadata persisted with a hidden interrupted-thinking continuity message. */
44
+ export interface InterruptedThinkingDetails {
45
+ interruptedAt: number;
46
+ provider: AssistantMessage["provider"];
47
+ model: string;
48
+ blockCount: number;
49
+ }
50
+
51
+ /** Pure helper result for persisting interrupted thinking outside the assistant turn. */
52
+ export interface DemotedInterruptedThinking {
53
+ reasoning: string;
54
+ strippedContent: AssistantMessage["content"];
55
+ blockCount: number;
56
+ }
57
+
58
+ /**
59
+ * Demote a trailing run of *incomplete* interrupted-thinking from an assistant
60
+ * message — reasoning that was still streaming when the user aborted.
61
+ *
62
+ * A block joins the run only when it is a non-empty `thinking` block with no
63
+ * `thinkingSignature`. A signed/complete thinking block (Anthropic signature,
64
+ * OpenAI reasoning item id) is safely replayable, so it ends the run and stays
65
+ * in place — as do `redactedThinking` encrypted blobs, text, tool calls,
66
+ * empty-thinking blocks, and trailing empty text placeholders.
67
+ */
68
+ export function demoteInterruptedThinking(
69
+ message: Pick<AssistantMessage, "content">,
70
+ ): DemotedInterruptedThinking | undefined {
71
+ const content = message.content;
72
+ let scanEnd = content.length;
73
+ while (scanEnd > 0) {
74
+ const block = content[scanEnd - 1]!;
75
+ if (block.type !== "text" || block.text.trim().length > 0) {
76
+ break;
77
+ }
78
+ scanEnd--;
79
+ }
80
+
81
+ let runStart = scanEnd;
82
+ while (runStart > 0) {
83
+ const block = content[runStart - 1]!;
84
+ if (block.type !== "thinking" || block.thinking.trim().length === 0 || block.thinkingSignature) {
85
+ break;
86
+ }
87
+ runStart--;
88
+ }
89
+
90
+ const blockCount = scanEnd - runStart;
91
+ if (blockCount === 0) {
92
+ return undefined;
93
+ }
94
+
95
+ const reasoningBlocks: string[] = [];
96
+ for (let index = runStart; index < scanEnd; index++) {
97
+ const block = content[index]!;
98
+ if (block.type === "thinking") {
99
+ reasoningBlocks.push(block.thinking.trim());
100
+ }
101
+ }
102
+
103
+ return {
104
+ reasoning: reasoningBlocks.join("\n\n"),
105
+ strippedContent: content.slice(0, runStart),
106
+ blockCount,
107
+ };
108
+ }
109
+
110
+ /**
111
+ * True when the assistant turn at `messages[index]` is immediately followed by
112
+ * its hidden `interrupted-thinking` continuity message — the marker that a
113
+ * trailing thinking run was demoted on user interrupt. The run stays on the
114
+ * persisted/displayed assistant message; this flag tells the LLM path to drop it.
115
+ */
116
+ function followedByInterruptedThinking(messages: AgentMessage[], index: number): boolean {
117
+ const next = messages[index + 1];
118
+ return next !== undefined && next.role === "custom" && next.customType === INTERRUPTED_THINKING_MESSAGE_TYPE;
119
+ }
120
+
121
+ /**
122
+ * Drop the demoted trailing thinking run from an assistant message for the LLM
123
+ * view only. The run is incomplete and unsigned, so providers reject it; the
124
+ * continuity message that follows carries the reasoning instead.
125
+ */
126
+ function stripDemotedThinkingForLlm(message: AssistantMessage): AssistantMessage {
127
+ const demoted = demoteInterruptedThinking(message);
128
+ return demoted ? { ...message, content: demoted.strippedContent } : message;
129
+ }
130
+
39
131
  /** Details persisted on a `/tan` background-dispatch breadcrumb. */
40
132
  export interface BackgroundTanDispatchDetails {
41
133
  jobId: string;
@@ -70,11 +162,10 @@ export interface SkillPromptDetails {
70
162
  * (fallback error emission) read it via `isSilentAbort`. */
71
163
  export const SILENT_ABORT_MARKER = "__omp.silent_abort__";
72
164
 
73
- /** Type-guard for `SILENT_ABORT_MARKER`. Renderers MUST branch on this rather
74
- * than string-comparing inline so refactors to the marker constant (e.g.,
75
- * namespacing changes) propagate through every consumer in lockstep. */
76
- export function isSilentAbort(errorMessage: string | undefined): boolean {
77
- return errorMessage === SILENT_ABORT_MARKER;
165
+ /** Type-guard for silent aborts. Renderers MUST call this helper so structured
166
+ * `errorId` and legacy persisted marker messages stay in lockstep. */
167
+ export function isSilentAbort(message: Pick<AssistantMessage, "errorId" | "errorMessage">): boolean {
168
+ return AIError.is(message.errorId, AIError.Flag.SilentAbort) || message.errorMessage === SILENT_ABORT_MARKER;
78
169
  }
79
170
 
80
171
  /** Reason threaded through `AbortController.abort(reason)` when the user aborts
@@ -84,12 +175,12 @@ export function isSilentAbort(errorMessage: string | undefined): boolean {
84
175
  * abort, but interactive renderers suppress this redundant transcript line. */
85
176
  export const USER_INTERRUPT_LABEL = "Interrupted by user";
86
177
 
87
- export function isUserInterruptAbort(errorMessage: string | undefined): boolean {
88
- return errorMessage === USER_INTERRUPT_LABEL;
178
+ export function isUserInterruptAbort(message: Pick<AssistantMessage, "errorId" | "errorMessage">): boolean {
179
+ return AIError.is(message.errorId, AIError.Flag.UserInterrupt) || message.errorMessage === USER_INTERRUPT_LABEL;
89
180
  }
90
181
 
91
- export function shouldRenderAbortReason(errorMessage: string | undefined): boolean {
92
- return !isSilentAbort(errorMessage) && !isUserInterruptAbort(errorMessage);
182
+ export function shouldRenderAbortReason(message: Pick<AssistantMessage, "errorId" | "errorMessage">): boolean {
183
+ return !isSilentAbort(message) && !isUserInterruptAbort(message);
93
184
  }
94
185
 
95
186
  /** Sentinel `errorMessage` the agent stamps on any abort that carried no custom
@@ -101,9 +192,17 @@ export const GENERIC_ABORT_SENTINEL = "Request was aborted";
101
192
  * no threaded reason fall back to the retry-aware generic label. Call
102
193
  * `shouldRenderAbortReason` before rendering when user interrupts should stay
103
194
  * visually quiet. */
104
- export function resolveAbortLabel(errorMessage: string | undefined, retryAttempt = 0): string {
105
- if (errorMessage && errorMessage !== GENERIC_ABORT_SENTINEL && !isSilentAbort(errorMessage)) {
106
- return errorMessage;
195
+ export function resolveAbortLabel(
196
+ message: Pick<AssistantMessage, "errorId" | "errorMessage">,
197
+ retryAttempt = 0,
198
+ ): string {
199
+ const genericAbort =
200
+ AIError.is(message.errorId, AIError.Flag.Abort) ||
201
+ !message.errorMessage ||
202
+ message.errorMessage === GENERIC_ABORT_SENTINEL ||
203
+ isSilentAbort(message);
204
+ if (!genericAbort) {
205
+ return message.errorMessage!;
107
206
  }
108
207
  if (retryAttempt > 0) {
109
208
  return `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`;
@@ -451,21 +550,32 @@ export function sanitizeRehydratedOpenAIResponsesAssistantMessage(message: Assis
451
550
  if (message.providerPayload?.type !== "openaiResponsesHistory") {
452
551
  return message;
453
552
  }
553
+ // Only GitHub Copilot rejects replayed assistant-side native history on a
554
+ // warmed (resumed) session with HTTP 401 — that is the sole reason this strip
555
+ // exists. For every other Responses-family provider (OpenAI, OpenAI-Codex,
556
+ // Azure) the encrypted reasoning and native response items are self-contained
557
+ // and MUST survive rehydration: remote compaction replays them to rebuild
558
+ // faithful native history (user + assistant turns + encrypted reasoning), and
559
+ // same-model live turns reuse them for prompt-cache continuity. Stripping them
560
+ // for all providers is what left resumed sessions compacting tool-call-only
561
+ // history with no reasoning and no assistant prose.
562
+ if (message.provider !== "github-copilot") {
563
+ return message;
564
+ }
454
565
 
455
566
  let didSanitizeContent = false;
456
567
  const sanitizedContent = message.content.map(block => {
457
568
  if (block.type !== "thinking" || block.thinkingSignature === undefined) {
458
569
  return block;
459
570
  }
460
-
461
571
  didSanitizeContent = true;
462
572
  return { ...block, thinkingSignature: undefined };
463
573
  });
464
574
 
465
- // Strip the assistant-side native replay payload entirely.
466
- // After rehydration it belongs to a previous live provider connection and
467
- // replaying it on a warmed session causes 401 rejections from GitHub Copilot.
468
- // User/developer payloads are preserved separately by the caller.
575
+ // Strip the assistant-side native replay payload entirely. After rehydration
576
+ // it belongs to a previous live Copilot connection and replaying it on a
577
+ // warmed session causes 401 rejections. User/developer payloads are preserved
578
+ // separately by the caller.
469
579
  return {
470
580
  ...message,
471
581
  ...(didSanitizeContent ? { content: sanitizedContent } : {}),
@@ -473,6 +583,38 @@ export function sanitizeRehydratedOpenAIResponsesAssistantMessage(message: Assis
473
583
  };
474
584
  }
475
585
 
586
+ function customMessageContentToLlmContent(content: CustomMessage["content"]): (TextContent | ImageContent)[] {
587
+ return typeof content === "string" ? [{ type: "text", text: content }] : content;
588
+ }
589
+
590
+ function isUserInvokedSkillPrompt(message: CustomMessage): boolean {
591
+ return message.customType === SKILL_PROMPT_MESSAGE_TYPE && message.attribution === "user";
592
+ }
593
+
594
+ function convertImageBearingCustomMessage(message: CustomMessage | HookMessage): Message[] | undefined {
595
+ if (typeof message.content === "string") return undefined;
596
+ const textBlocks = message.content.filter((content): content is TextContent => content.type === "text");
597
+ const imageBlocks = message.content.filter((content): content is ImageContent => content.type === "image");
598
+ if (imageBlocks.length === 0) return undefined;
599
+
600
+ const converted: Message[] = [];
601
+ if (textBlocks.length > 0) {
602
+ converted.push({
603
+ role: "developer",
604
+ content: textBlocks,
605
+ attribution: message.attribution,
606
+ timestamp: message.timestamp,
607
+ });
608
+ }
609
+ converted.push({
610
+ role: "user",
611
+ content: [{ type: "text", text: `Images attached to ${message.customType}.` }, ...imageBlocks],
612
+ attribution: message.attribution,
613
+ timestamp: message.timestamp,
614
+ });
615
+ return converted;
616
+ }
617
+
476
618
  /**
477
619
  * Transform AgentMessages (including custom types) to LLM-compatible Messages.
478
620
  *
@@ -482,7 +624,7 @@ export function sanitizeRehydratedOpenAIResponsesAssistantMessage(message: Assis
482
624
  * - Custom extensions and tools
483
625
  */
484
626
  export function convertToLlm(messages: AgentMessage[]): Message[] {
485
- return messages.flatMap((m): Message[] => {
627
+ return messages.flatMap((m, index): Message[] => {
486
628
  switch (m.role) {
487
629
  case "bashExecution":
488
630
  if (m.excludeFromContext) {
@@ -547,13 +689,42 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
547
689
  }
548
690
  return out;
549
691
  }
550
- case "custom":
551
- case "hookMessage":
692
+ case "custom": {
693
+ if (isUserInvokedSkillPrompt(m)) {
694
+ return [
695
+ {
696
+ role: "user",
697
+ content: customMessageContentToLlmContent(m.content),
698
+ attribution: "user",
699
+ timestamp: m.timestamp,
700
+ },
701
+ ];
702
+ }
703
+ const split = convertImageBearingCustomMessage(m);
704
+ if (split) return split;
705
+ const converted = convertMessageToLlm(m);
706
+ return converted ? [converted] : [];
707
+ }
708
+ case "hookMessage": {
709
+ const split = convertImageBearingCustomMessage(m);
710
+ if (split) return split;
711
+ const converted = convertMessageToLlm(m);
712
+ return converted ? [converted] : [];
713
+ }
714
+ case "assistant": {
715
+ // A user-interrupted turn keeps its trailing thinking run on the
716
+ // persisted/displayed message so reload and Ctrl+L rebuilds still
717
+ // show it. That run is incomplete/unsigned and gets rejected on
718
+ // resend, so strip it here — LLM path only — when the hidden
719
+ // interrupted-thinking continuity message follows.
720
+ const source = followedByInterruptedThinking(messages, index) ? stripDemotedThinkingForLlm(m) : m;
721
+ const converted = convertMessageToLlm(source);
722
+ return converted ? [converted] : [];
723
+ }
552
724
  case "branchSummary":
553
725
  case "compactionSummary":
554
726
  case "user":
555
727
  case "developer":
556
- case "assistant":
557
728
  case "toolResult": {
558
729
  // Core roles share one transformer with agent-core —
559
730
  // duplicating them here is how snapcompact frames once