@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.
- package/CHANGELOG.md +72 -0
- package/dist/cli.js +3621 -3579
- package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
- package/dist/types/advisor/advise-tool.d.ts +8 -4
- package/dist/types/advisor/config.d.ts +88 -0
- package/dist/types/advisor/index.d.ts +1 -0
- package/dist/types/advisor/runtime.d.ts +15 -1
- package/dist/types/advisor/transcript-recorder.d.ts +13 -2
- package/dist/types/advisor/watchdog.d.ts +20 -0
- package/dist/types/collab/guest.d.ts +29 -0
- package/dist/types/config/model-roles.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +113 -12
- package/dist/types/debug/log-viewer.d.ts +1 -0
- package/dist/types/debug/raw-sse.d.ts +1 -0
- package/dist/types/discovery/omp-extension-roots.d.ts +3 -3
- package/dist/types/edit/hashline/diff.d.ts +0 -11
- package/dist/types/edit/index.d.ts +18 -0
- package/dist/types/edit/streaming.d.ts +30 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
- package/dist/types/extensibility/shared-events.d.ts +1 -0
- package/dist/types/extensibility/tool-event-input.d.ts +7 -0
- package/dist/types/extensibility/utils.d.ts +12 -0
- package/dist/types/mcp/oauth-discovery.d.ts +0 -11
- package/dist/types/mcp/transports/index.d.ts +1 -0
- package/dist/types/mcp/transports/sse.d.ts +20 -0
- package/dist/types/modes/components/advisor-config.d.ts +59 -0
- package/dist/types/modes/components/index.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +9 -1
- package/dist/types/modes/components/settings-selector.d.ts +1 -0
- package/dist/types/modes/components/status-line/component.d.ts +30 -3
- package/dist/types/modes/components/status-line/types.d.ts +13 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +10 -4
- package/dist/types/modes/skill-command.d.ts +32 -0
- package/dist/types/modes/types.d.ts +7 -2
- package/dist/types/sdk.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +84 -12
- package/dist/types/session/indexed-session-storage.d.ts +7 -1
- package/dist/types/session/messages.d.ts +32 -7
- package/dist/types/session/messages.test.d.ts +1 -0
- package/dist/types/session/session-entries.d.ts +31 -3
- package/dist/types/session/session-history-format.d.ts +6 -0
- package/dist/types/session/session-loader.d.ts +9 -1
- package/dist/types/session/session-manager.d.ts +6 -4
- package/dist/types/session/session-storage.d.ts +11 -0
- package/dist/types/session/session-title-slot.d.ts +19 -0
- package/dist/types/session/settings-stream-fn.d.ts +21 -0
- package/dist/types/session/turn-persistence.d.ts +88 -0
- package/dist/types/ssh/connection-manager.d.ts +47 -0
- package/dist/types/ssh/utils.d.ts +16 -0
- package/dist/types/task/executor.d.ts +3 -16
- package/dist/types/task/render.d.ts +0 -5
- package/dist/types/task/renderer.d.ts +13 -0
- package/dist/types/task/types.d.ts +16 -0
- package/dist/types/task/yield-assembly.d.ts +28 -0
- package/dist/types/tiny/text.d.ts +8 -0
- package/dist/types/tools/render-utils.d.ts +2 -0
- package/dist/types/tools/review.d.ts +6 -4
- package/dist/types/tools/ssh.d.ts +1 -1
- package/dist/types/tools/todo.d.ts +6 -0
- package/dist/types/tools/yield.d.ts +8 -3
- package/dist/types/utils/thinking-display.d.ts +4 -0
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +438 -10
- package/src/advisor/__tests__/config.test.ts +173 -0
- package/src/advisor/advise-tool.ts +11 -6
- package/src/advisor/config.ts +256 -0
- package/src/advisor/index.ts +1 -0
- package/src/advisor/runtime.ts +77 -4
- package/src/advisor/transcript-recorder.ts +25 -2
- package/src/advisor/watchdog.ts +57 -31
- package/src/auto-thinking/classifier.ts +2 -2
- package/src/autoresearch/index.ts +7 -2
- package/src/cli/gc-cli.ts +17 -10
- package/src/collab/guest.ts +43 -7
- package/src/config/model-registry.ts +80 -18
- package/src/config/model-resolver.ts +5 -1
- package/src/config/model-roles.ts +3 -3
- package/src/config/settings-schema.ts +107 -8
- package/src/debug/index.ts +32 -7
- package/src/debug/log-viewer.ts +111 -53
- package/src/debug/raw-sse.ts +68 -48
- package/src/discovery/codex.ts +13 -5
- package/src/discovery/omp-extension-roots.ts +38 -13
- package/src/edit/hashline/diff.ts +57 -4
- package/src/edit/index.ts +21 -0
- package/src/edit/streaming.ts +170 -0
- package/src/eval/js/shared/local-module-loader.ts +23 -1
- package/src/export/html/template.js +13 -7
- package/src/extensibility/custom-tools/types.ts +1 -0
- package/src/extensibility/extensions/loader.ts +5 -3
- package/src/extensibility/extensions/wrapper.ts +9 -3
- package/src/extensibility/hooks/loader.ts +3 -3
- package/src/extensibility/hooks/tool-wrapper.ts +13 -4
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +18 -1
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +59 -2
- package/src/extensibility/plugins/manager.ts +76 -5
- package/src/extensibility/shared-events.ts +1 -0
- package/src/extensibility/tool-event-input.ts +23 -0
- package/src/extensibility/utils.ts +74 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/mcp/client.ts +3 -1
- package/src/mcp/manager.ts +12 -5
- package/src/mcp/oauth-discovery.ts +5 -29
- package/src/mcp/transports/http.ts +3 -1
- package/src/mcp/transports/index.ts +1 -0
- package/src/mcp/transports/sse.ts +377 -0
- package/src/memories/index.ts +15 -6
- package/src/mnemopi/backend.ts +2 -2
- package/src/modes/acp/acp-agent.ts +1 -1
- package/src/modes/components/advisor-config.ts +555 -0
- package/src/modes/components/advisor-message.ts +9 -2
- package/src/modes/components/agent-hub.ts +9 -4
- package/src/modes/components/assistant-message.ts +5 -5
- package/src/modes/components/index.ts +2 -0
- package/src/modes/components/model-selector.ts +79 -48
- package/src/modes/components/settings-selector.ts +1 -0
- package/src/modes/components/status-line/component.ts +145 -14
- package/src/modes/components/status-line/segments.ts +47 -22
- package/src/modes/components/status-line/types.ts +13 -1
- package/src/modes/components/tool-execution.ts +47 -6
- package/src/modes/controllers/command-controller.ts +23 -2
- package/src/modes/controllers/event-controller.ts +114 -11
- package/src/modes/controllers/extension-ui-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +61 -61
- package/src/modes/controllers/selector-controller.ts +100 -9
- package/src/modes/interactive-mode.ts +65 -10
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/skill-command.ts +116 -0
- package/src/modes/types.ts +7 -2
- package/src/modes/utils/transcript-render-helpers.ts +2 -2
- package/src/modes/utils/ui-helpers.ts +46 -27
- package/src/prompts/agents/reviewer.md +11 -10
- package/src/prompts/review-custom-request.md +1 -2
- package/src/prompts/review-request.md +1 -2
- package/src/prompts/system/interrupted-thinking.md +7 -0
- package/src/prompts/system/recap-user.md +9 -0
- package/src/prompts/system/subagent-system-prompt.md +8 -5
- package/src/prompts/system/subagent-yield-reminder.md +6 -5
- package/src/prompts/system/system-prompt.md +0 -1
- package/src/prompts/tools/irc.md +2 -2
- package/src/prompts/tools/read.md +2 -2
- package/src/sdk.ts +40 -50
- package/src/session/agent-session.ts +1139 -600
- package/src/session/indexed-session-storage.ts +86 -13
- package/src/session/messages.test.ts +125 -0
- package/src/session/messages.ts +192 -21
- package/src/session/redis-session-storage.ts +49 -2
- package/src/session/session-entries.ts +39 -2
- package/src/session/session-history-format.ts +29 -2
- package/src/session/session-listing.ts +54 -24
- package/src/session/session-loader.ts +66 -3
- package/src/session/session-manager.ts +113 -19
- package/src/session/session-persistence.ts +96 -3
- package/src/session/session-storage.ts +36 -0
- package/src/session/session-title-slot.ts +141 -0
- package/src/session/settings-stream-fn.ts +49 -0
- package/src/session/sql-session-storage.ts +71 -11
- package/src/session/turn-persistence.ts +142 -0
- package/src/session/unexpected-stop-classifier.ts +2 -2
- package/src/slash-commands/builtin-registry.ts +16 -3
- package/src/slash-commands/helpers/mcp.ts +2 -1
- package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
- package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
- package/src/ssh/connection-manager.ts +139 -12
- package/src/ssh/file-transfer.ts +23 -18
- package/src/ssh/ssh-executor.ts +2 -13
- package/src/ssh/utils.ts +19 -0
- package/src/task/executor.ts +21 -23
- package/src/task/render.ts +162 -20
- package/src/task/renderer.ts +14 -0
- package/src/task/types.ts +17 -0
- package/src/task/yield-assembly.ts +207 -0
- package/src/tiny/models.ts +8 -6
- package/src/tiny/text.ts +37 -7
- package/src/tools/ask.ts +55 -4
- package/src/tools/image-gen.ts +2 -1
- package/src/tools/render-utils.ts +2 -0
- package/src/tools/renderers.ts +8 -2
- package/src/tools/review.ts +17 -7
- package/src/tools/ssh.ts +8 -4
- package/src/tools/todo.ts +17 -1
- package/src/tools/tts.ts +2 -1
- package/src/tools/yield.ts +140 -31
- package/src/utils/thinking-display.ts +15 -0
- package/src/utils/title-generator.ts +1 -1
- 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
|
-
|
|
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
|
-
|
|
116
|
-
this.#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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), {
|
|
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(
|
|
297
|
-
|
|
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
|
+
});
|
package/src/session/messages.ts
CHANGED
|
@@ -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
|
|
74
|
-
*
|
|
75
|
-
|
|
76
|
-
|
|
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(
|
|
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(
|
|
92
|
-
return !isSilentAbort(
|
|
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(
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
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
|
-
|
|
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
|