@oh-my-pi/pi-coding-agent 16.2.2 → 16.2.4
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 +60 -0
- package/dist/cli.js +3682 -3615
- 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/transcript-recorder.d.ts +13 -2
- package/dist/types/advisor/watchdog.d.ts +20 -0
- package/dist/types/cli/update-cli.d.ts +15 -0
- package/dist/types/collab/guest.d.ts +29 -0
- package/dist/types/collab/replication-shrink.d.ts +39 -0
- package/dist/types/config/provider-globals.d.ts +7 -0
- package/dist/types/config/settings-schema.d.ts +81 -0
- package/dist/types/debug/log-viewer.d.ts +1 -0
- package/dist/types/debug/raw-sse.d.ts +1 -0
- package/dist/types/edit/hashline/diff.d.ts +0 -11
- package/dist/types/extensibility/tool-event-input.d.ts +7 -0
- package/dist/types/extensibility/utils.d.ts +12 -0
- package/dist/types/mcp/transports/index.d.ts +1 -0
- package/dist/types/mcp/transports/sse.d.ts +20 -0
- package/dist/types/memories/index.d.ts +20 -1
- 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 +35 -1
- package/dist/types/modes/components/status-line/types.d.ts +13 -1
- package/dist/types/modes/controllers/command-controller.d.ts +3 -3
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +11 -4
- package/dist/types/modes/skill-command.d.ts +32 -0
- package/dist/types/modes/types.d.ts +9 -3
- package/dist/types/session/agent-session.d.ts +58 -10
- package/dist/types/session/indexed-session-storage.d.ts +7 -1
- package/dist/types/session/messages.d.ts +26 -0
- 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 +8 -7
- package/dist/types/session/session-storage.d.ts +11 -0
- package/dist/types/session/session-title-slot.d.ts +19 -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 +242 -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 +12 -2
- package/src/advisor/transcript-recorder.ts +25 -2
- package/src/advisor/watchdog.ts +57 -31
- package/src/autolearn/controller.ts +13 -22
- package/src/autoresearch/index.ts +7 -2
- package/src/cli/gc-cli.ts +17 -10
- package/src/cli/update-cli.ts +254 -0
- package/src/collab/guest.ts +43 -7
- package/src/collab/host.ts +13 -8
- package/src/collab/replication-shrink.ts +111 -0
- package/src/config/model-registry.ts +80 -18
- package/src/config/provider-globals.ts +25 -0
- package/src/config/settings-schema.ts +77 -0
- 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/edit/hashline/diff.ts +57 -4
- package/src/eval/__tests__/julia-prelude.test.ts +2 -2
- package/src/eval/js/shared/local-module-loader.ts +23 -1
- package/src/export/html/template.js +13 -7
- 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/manager.ts +2 -1
- 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/transports/index.ts +1 -0
- package/src/mcp/transports/sse.ts +377 -0
- package/src/memories/index.ts +130 -15
- package/src/memory-backend/local-backend.ts +5 -3
- 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/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 +150 -5
- package/src/modes/components/status-line/segments.ts +46 -21
- 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 +27 -36
- package/src/modes/controllers/event-controller.ts +113 -1
- 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 +74 -25
- package/src/modes/skill-command.ts +116 -0
- package/src/modes/types.ts +9 -3
- package/src/modes/utils/ui-helpers.ts +41 -23
- package/src/prompts/agents/reviewer.md +11 -10
- package/src/prompts/goals/goal-todo-context.md +12 -0
- 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 +28 -24
- package/src/session/agent-session.ts +899 -429
- package/src/session/indexed-session-storage.ts +86 -13
- package/src/session/messages.test.ts +125 -0
- package/src/session/messages.ts +172 -9
- 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 +115 -22
- package/src/session/session-persistence.ts +95 -1
- package/src/session/session-storage.ts +36 -0
- package/src/session/session-title-slot.ts +141 -0
- package/src/session/sql-session-storage.ts +71 -11
- package/src/slash-commands/builtin-registry.ts +23 -24
- 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/text.ts +23 -0
- package/src/tools/ask.ts +55 -4
- 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/yield.ts +140 -31
- package/src/utils/thinking-display.ts +15 -0
- package/src/prompts/system/autolearn-nudge.md +0 -5
|
@@ -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
|
@@ -37,6 +37,97 @@ export const SKILL_PROMPT_MESSAGE_TYPE = "skill-prompt";
|
|
|
37
37
|
export const LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE = "lsp-late-diagnostic";
|
|
38
38
|
export const BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE = "background-tan-dispatch";
|
|
39
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
|
+
|
|
40
131
|
/** Details persisted on a `/tan` background-dispatch breadcrumb. */
|
|
41
132
|
export interface BackgroundTanDispatchDetails {
|
|
42
133
|
jobId: string;
|
|
@@ -459,21 +550,32 @@ export function sanitizeRehydratedOpenAIResponsesAssistantMessage(message: Assis
|
|
|
459
550
|
if (message.providerPayload?.type !== "openaiResponsesHistory") {
|
|
460
551
|
return message;
|
|
461
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
|
+
}
|
|
462
565
|
|
|
463
566
|
let didSanitizeContent = false;
|
|
464
567
|
const sanitizedContent = message.content.map(block => {
|
|
465
568
|
if (block.type !== "thinking" || block.thinkingSignature === undefined) {
|
|
466
569
|
return block;
|
|
467
570
|
}
|
|
468
|
-
|
|
469
571
|
didSanitizeContent = true;
|
|
470
572
|
return { ...block, thinkingSignature: undefined };
|
|
471
573
|
});
|
|
472
574
|
|
|
473
|
-
// Strip the assistant-side native replay payload entirely.
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
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.
|
|
477
579
|
return {
|
|
478
580
|
...message,
|
|
479
581
|
...(didSanitizeContent ? { content: sanitizedContent } : {}),
|
|
@@ -481,6 +583,38 @@ export function sanitizeRehydratedOpenAIResponsesAssistantMessage(message: Assis
|
|
|
481
583
|
};
|
|
482
584
|
}
|
|
483
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
|
+
|
|
484
618
|
/**
|
|
485
619
|
* Transform AgentMessages (including custom types) to LLM-compatible Messages.
|
|
486
620
|
*
|
|
@@ -490,7 +624,7 @@ export function sanitizeRehydratedOpenAIResponsesAssistantMessage(message: Assis
|
|
|
490
624
|
* - Custom extensions and tools
|
|
491
625
|
*/
|
|
492
626
|
export function convertToLlm(messages: AgentMessage[]): Message[] {
|
|
493
|
-
return messages.flatMap((m): Message[] => {
|
|
627
|
+
return messages.flatMap((m, index): Message[] => {
|
|
494
628
|
switch (m.role) {
|
|
495
629
|
case "bashExecution":
|
|
496
630
|
if (m.excludeFromContext) {
|
|
@@ -555,13 +689,42 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
|
|
|
555
689
|
}
|
|
556
690
|
return out;
|
|
557
691
|
}
|
|
558
|
-
case "custom":
|
|
559
|
-
|
|
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
|
+
}
|
|
560
724
|
case "branchSummary":
|
|
561
725
|
case "compactionSummary":
|
|
562
726
|
case "user":
|
|
563
727
|
case "developer":
|
|
564
|
-
case "assistant":
|
|
565
728
|
case "toolResult": {
|
|
566
729
|
// Core roles share one transformer with agent-core —
|
|
567
730
|
// duplicating them here is how snapcompact frames once
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type SessionStorageBackend,
|
|
5
5
|
type SessionStorageIndexEntry,
|
|
6
6
|
} from "./indexed-session-storage";
|
|
7
|
+
import type { SessionTitleUpdate } from "./session-title-slot";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Minimal subset of the `bun:redis` `RedisClient` surface used by
|
|
@@ -44,6 +45,28 @@ export interface RedisSessionStorageOptions {
|
|
|
44
45
|
const DEFAULT_PREFIX = "omp:sessions:";
|
|
45
46
|
const DEFAULT_SCAN_COUNT = 500;
|
|
46
47
|
|
|
48
|
+
function encodeTitleMeta(title: SessionTitleUpdate): string {
|
|
49
|
+
return JSON.stringify(title);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function decodeTitleMeta(raw: string | undefined): SessionTitleUpdate | undefined {
|
|
53
|
+
if (!raw) return undefined;
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
56
|
+
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
57
|
+
const record = parsed as Record<string, unknown>;
|
|
58
|
+
if (typeof record.updatedAt !== "string") return undefined;
|
|
59
|
+
const source = record.source === "auto" || record.source === "user" ? record.source : undefined;
|
|
60
|
+
return {
|
|
61
|
+
title: typeof record.title === "string" ? record.title : undefined,
|
|
62
|
+
source,
|
|
63
|
+
updatedAt: record.updatedAt,
|
|
64
|
+
};
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
47
70
|
/**
|
|
48
71
|
* Redis-backed implementation of {@link SessionStorage}. Each session JSONL
|
|
49
72
|
* file maps to a Redis STRING key, with per-key metadata (mtime) tracked in a
|
|
@@ -83,8 +106,9 @@ class RedisSessionStorageBackend implements SessionStorageBackend {
|
|
|
83
106
|
async loadIndex(): Promise<SessionStorageIndexEntry[]> {
|
|
84
107
|
const filePrefix = this.#fileKey("");
|
|
85
108
|
const metaRaw = await this.#client.hgetall(this.#metaKey());
|
|
109
|
+
const titleRaw = await this.#client.hgetall(this.#titleMetaKey());
|
|
86
110
|
const meta: Record<string, string> = metaRaw ?? {};
|
|
87
|
-
|
|
111
|
+
const titles: Record<string, string> = titleRaw ?? {};
|
|
88
112
|
const seen = new Set<string>();
|
|
89
113
|
let cursor = "0";
|
|
90
114
|
do {
|
|
@@ -106,10 +130,14 @@ class RedisSessionStorageBackend implements SessionStorageBackend {
|
|
|
106
130
|
const size = await this.#client.strlen(key);
|
|
107
131
|
const rawMtime = meta[path];
|
|
108
132
|
const parsedMtime = rawMtime === undefined ? Number.NaN : Number(rawMtime);
|
|
133
|
+
const title = decodeTitleMeta(titles[path]);
|
|
109
134
|
return {
|
|
110
135
|
path,
|
|
111
136
|
size,
|
|
112
137
|
mtimeMs: Number.isFinite(parsedMtime) ? parsedMtime : fallbackMtimeMs,
|
|
138
|
+
title: title?.title,
|
|
139
|
+
titleSource: title?.source,
|
|
140
|
+
titleUpdatedAt: title?.updatedAt,
|
|
113
141
|
};
|
|
114
142
|
}),
|
|
115
143
|
);
|
|
@@ -126,9 +154,14 @@ class RedisSessionStorageBackend implements SessionStorageBackend {
|
|
|
126
154
|
return Promise.all([head, tail]);
|
|
127
155
|
}
|
|
128
156
|
|
|
129
|
-
async writeFull(path: string, content: string, mtimeMs: number): Promise<void> {
|
|
157
|
+
async writeFull(path: string, content: string, mtimeMs: number, title?: SessionTitleUpdate): Promise<void> {
|
|
130
158
|
await this.#client.set(this.#fileKey(path), content);
|
|
131
159
|
await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
|
|
160
|
+
if (title) {
|
|
161
|
+
await this.#client.hset(this.#titleMetaKey(), path, encodeTitleMeta(title));
|
|
162
|
+
} else {
|
|
163
|
+
await this.#client.hdel(this.#titleMetaKey(), path);
|
|
164
|
+
}
|
|
132
165
|
}
|
|
133
166
|
|
|
134
167
|
async append(path: string, line: string, mtimeMs: number): Promise<void> {
|
|
@@ -136,6 +169,11 @@ class RedisSessionStorageBackend implements SessionStorageBackend {
|
|
|
136
169
|
await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
|
|
137
170
|
}
|
|
138
171
|
|
|
172
|
+
async updateSessionTitle(path: string, title: SessionTitleUpdate, mtimeMs: number): Promise<void> {
|
|
173
|
+
await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
|
|
174
|
+
await this.#client.hset(this.#titleMetaKey(), path, encodeTitleMeta(title));
|
|
175
|
+
}
|
|
176
|
+
|
|
139
177
|
async truncate(path: string, mtimeMs: number): Promise<void> {
|
|
140
178
|
await this.writeFull(path, "", mtimeMs);
|
|
141
179
|
}
|
|
@@ -144,13 +182,18 @@ class RedisSessionStorageBackend implements SessionStorageBackend {
|
|
|
144
182
|
if (paths.length === 0) return;
|
|
145
183
|
await this.#client.del(...paths.map(path => this.#fileKey(path)));
|
|
146
184
|
await this.#client.hdel(this.#metaKey(), ...paths);
|
|
185
|
+
await this.#client.hdel(this.#titleMetaKey(), ...paths);
|
|
147
186
|
}
|
|
148
187
|
|
|
149
188
|
async move(src: string, dst: string, mtimeMs: number): Promise<void> {
|
|
150
189
|
await this.#client.rename(this.#fileKey(src), this.#fileKey(dst));
|
|
151
190
|
try {
|
|
191
|
+
const titleMeta = await this.#client.hgetall(this.#titleMetaKey());
|
|
152
192
|
await this.#client.hdel(this.#metaKey(), src);
|
|
153
193
|
await this.#client.hset(this.#metaKey(), dst, String(mtimeMs));
|
|
194
|
+
await this.#client.hdel(this.#titleMetaKey(), src);
|
|
195
|
+
const title = titleMeta[src];
|
|
196
|
+
if (title !== undefined) await this.#client.hset(this.#titleMetaKey(), dst, title);
|
|
154
197
|
} catch (err) {
|
|
155
198
|
logger.warn("Redis session storage meta rename failed", {
|
|
156
199
|
src,
|
|
@@ -167,4 +210,8 @@ class RedisSessionStorageBackend implements SessionStorageBackend {
|
|
|
167
210
|
#metaKey(): string {
|
|
168
211
|
return `${this.#prefix}meta`;
|
|
169
212
|
}
|
|
213
|
+
|
|
214
|
+
#titleMetaKey(): string {
|
|
215
|
+
return `${this.#prefix}title`;
|
|
216
|
+
}
|
|
170
217
|
}
|