@d3ara1n/pi-subagent 2.2.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -14
- package/package.json +1 -1
- package/src/config.test.ts +19 -1
- package/src/config.ts +13 -0
- package/src/history.ts +3 -0
- package/src/index.ts +93 -110
- package/src/inheritance.test.ts +217 -0
- package/src/inheritance.ts +188 -0
- package/src/reminder.test.ts +101 -52
- package/src/reminder.ts +15 -9
- package/src/render-async.ts +70 -2
- package/src/render.test.ts +63 -0
- package/src/render.ts +25 -3
- package/src/run.test.ts +51 -1
- package/src/run.ts +28 -6
- package/src/spawn.test.ts +34 -10
- package/src/spawn.ts +44 -10
- package/src/types.ts +28 -9
- package/src/utils.test.ts +51 -0
- package/src/utils.ts +48 -5
- package/src/view.ts +15 -3
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic, text-only serialization of an active pi session branch for
|
|
3
|
+
* optional subagent conversation inheritance.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
const OMISSION_MARKER = "[Earlier inherited conversation omitted for length.]";
|
|
9
|
+
const MESSAGE_OMISSION_MARKER = "[Earlier text in this message omitted.]";
|
|
10
|
+
|
|
11
|
+
type EntryLike = {
|
|
12
|
+
type?: unknown;
|
|
13
|
+
summary?: unknown;
|
|
14
|
+
message?: unknown;
|
|
15
|
+
retainedTail?: unknown;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type MessageLike = {
|
|
19
|
+
role?: unknown;
|
|
20
|
+
content?: unknown;
|
|
21
|
+
summary?: unknown;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type Chunk = {
|
|
25
|
+
kind: "summary" | "dialogue";
|
|
26
|
+
text: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface InheritedConversationSnapshot {
|
|
30
|
+
/** Delimiter-safe text delivered to the child. */
|
|
31
|
+
text: string;
|
|
32
|
+
/** True when eligible inherited content was omitted to satisfy maxChars. */
|
|
33
|
+
truncated: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Keep inherited text from being interpreted as one of the surrounding prompt tags. */
|
|
37
|
+
function escapePromptText(text: string): string {
|
|
38
|
+
return text.replaceAll("&", "&").replaceAll("<", "<");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function textContent(content: unknown): string {
|
|
42
|
+
if (typeof content === "string") return escapePromptText(content);
|
|
43
|
+
if (!Array.isArray(content)) return "";
|
|
44
|
+
return content
|
|
45
|
+
.flatMap((part) => {
|
|
46
|
+
if (!part || typeof part !== "object") return [];
|
|
47
|
+
const block = part as { type?: unknown; text?: unknown };
|
|
48
|
+
return block.type === "text" && typeof block.text === "string"
|
|
49
|
+
? [escapePromptText(block.text)]
|
|
50
|
+
: [];
|
|
51
|
+
})
|
|
52
|
+
.join("");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function serializeMessage(message: unknown): Chunk | undefined {
|
|
56
|
+
if (!message || typeof message !== "object") return undefined;
|
|
57
|
+
const { role, content, summary } = message as MessageLike;
|
|
58
|
+
if (role === "user" || role === "assistant") {
|
|
59
|
+
const text = textContent(content);
|
|
60
|
+
return text ? { kind: "dialogue", text: `[${role}]\n${text}` } : undefined;
|
|
61
|
+
}
|
|
62
|
+
if (role === "compactionSummary" && typeof summary === "string" && summary) {
|
|
63
|
+
return {
|
|
64
|
+
kind: "summary",
|
|
65
|
+
text: `[Compaction summary]\n${escapePromptText(summary)}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (role === "branchSummary" && typeof summary === "string" && summary) {
|
|
69
|
+
return { kind: "summary", text: `[Branch summary]\n${escapePromptText(summary)}` };
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function truncateDialogueChunk(text: string, maxChars: number): string {
|
|
75
|
+
if (text.length <= maxChars) return text;
|
|
76
|
+
const newline = text.indexOf("\n");
|
|
77
|
+
const label = newline >= 0 ? text.slice(0, newline) : "[message]";
|
|
78
|
+
const prefix = `${label}\n${MESSAGE_OMISSION_MARKER}\n`;
|
|
79
|
+
if (prefix.length >= maxChars) return prefix.slice(0, maxChars);
|
|
80
|
+
return prefix + text.slice(text.length - (maxChars - prefix.length));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Select newest complete dialogue chunks, truncating only the newest chunk as a last resort. */
|
|
84
|
+
function newestDialogue(chunks: Chunk[], maxChars: number): string {
|
|
85
|
+
if (maxChars <= 0 || chunks.length === 0) return "";
|
|
86
|
+
const selected: string[] = [];
|
|
87
|
+
let used = 0;
|
|
88
|
+
for (let i = chunks.length - 1; i >= 0; i -= 1) {
|
|
89
|
+
const separator = selected.length > 0 ? 2 : 0;
|
|
90
|
+
const remaining = maxChars - used - separator;
|
|
91
|
+
if (remaining <= 0) break;
|
|
92
|
+
const text = chunks[i].text;
|
|
93
|
+
if (text.length <= remaining) {
|
|
94
|
+
selected.unshift(text);
|
|
95
|
+
used += separator + text.length;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
selected.unshift(truncateDialogueChunk(text, remaining));
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
return selected.join("\n\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Serialize the supplied active, compaction-aware session entries in order.
|
|
106
|
+
* Only compaction/branch summaries and user/assistant text are retained.
|
|
107
|
+
*/
|
|
108
|
+
export function serializeInheritedConversation(
|
|
109
|
+
entries: SessionEntry[],
|
|
110
|
+
maxChars: number,
|
|
111
|
+
): InheritedConversationSnapshot {
|
|
112
|
+
const limit = Number.isFinite(maxChars) ? Math.max(0, Math.floor(maxChars)) : 0;
|
|
113
|
+
if (limit === 0) return { text: "", truncated: false };
|
|
114
|
+
const chunks: Chunk[] = [];
|
|
115
|
+
|
|
116
|
+
const addMessage = (message: unknown) => {
|
|
117
|
+
const serialized = serializeMessage(message);
|
|
118
|
+
if (serialized) chunks.push(serialized);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
for (const rawEntry of entries) {
|
|
122
|
+
const entry = rawEntry as EntryLike;
|
|
123
|
+
if (entry.type === "compaction") {
|
|
124
|
+
if (typeof entry.summary === "string" && entry.summary) {
|
|
125
|
+
chunks.push({
|
|
126
|
+
kind: "summary",
|
|
127
|
+
text: `[Compaction summary]\n${escapePromptText(entry.summary)}`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
// Newer compaction entries materialize their kept context here. The
|
|
131
|
+
// installed firstKeptEntryId shape returns those entries separately.
|
|
132
|
+
if (Array.isArray(entry.retainedTail)) {
|
|
133
|
+
for (const message of entry.retainedTail) addMessage(message);
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (entry.type === "branch_summary") {
|
|
138
|
+
if (typeof entry.summary === "string" && entry.summary) {
|
|
139
|
+
chunks.push({
|
|
140
|
+
kind: "summary",
|
|
141
|
+
text: `[Branch summary]\n${escapePromptText(entry.summary)}`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (entry.type === "message") addMessage(entry.message);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const full = chunks.map((chunk) => chunk.text).join("\n\n");
|
|
150
|
+
if (full.length <= limit) return { text: full, truncated: false };
|
|
151
|
+
|
|
152
|
+
const summaryText = chunks
|
|
153
|
+
.filter((chunk) => chunk.kind === "summary")
|
|
154
|
+
.map((chunk) => chunk.text)
|
|
155
|
+
.join("\n\n");
|
|
156
|
+
const dialogueChunks = chunks.filter((chunk) => chunk.kind === "dialogue");
|
|
157
|
+
const dialogueText = dialogueChunks.map((chunk) => chunk.text).join("\n\n");
|
|
158
|
+
|
|
159
|
+
// Reserve an explicit marker, then retain summary context plus the newest
|
|
160
|
+
// dialogue. Start with a 40/60 split, but redistribute every unused char so
|
|
161
|
+
// a short side never wastes capacity. This is deterministic and model-free.
|
|
162
|
+
const hasSummary = summaryText.length > 0;
|
|
163
|
+
const hasDialogue = dialogueText.length > 0;
|
|
164
|
+
const separatorChars = (hasSummary ? 2 : 0) + (hasDialogue ? 2 : 0);
|
|
165
|
+
if (limit <= OMISSION_MARKER.length + separatorChars) {
|
|
166
|
+
return { text: OMISSION_MARKER.slice(0, limit), truncated: true };
|
|
167
|
+
}
|
|
168
|
+
const available = limit - OMISSION_MARKER.length - separatorChars;
|
|
169
|
+
let summaryBudget =
|
|
170
|
+
hasSummary && hasDialogue ? Math.floor(available * 0.4) : hasSummary ? available : 0;
|
|
171
|
+
let dialogueBudget = hasDialogue ? available - summaryBudget : 0;
|
|
172
|
+
|
|
173
|
+
if (summaryText.length < summaryBudget) {
|
|
174
|
+
dialogueBudget += summaryBudget - summaryText.length;
|
|
175
|
+
summaryBudget = summaryText.length;
|
|
176
|
+
}
|
|
177
|
+
if (dialogueText.length < dialogueBudget) {
|
|
178
|
+
summaryBudget += dialogueBudget - dialogueText.length;
|
|
179
|
+
dialogueBudget = dialogueText.length;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const selectedSummary = summaryBudget > 0 ? summaryText.slice(0, summaryBudget) : "";
|
|
183
|
+
const selectedDialogue = newestDialogue(dialogueChunks, dialogueBudget);
|
|
184
|
+
return {
|
|
185
|
+
text: [selectedSummary, OMISSION_MARKER, selectedDialogue].filter(Boolean).join("\n\n"),
|
|
186
|
+
truncated: true,
|
|
187
|
+
};
|
|
188
|
+
}
|
package/src/reminder.test.ts
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* node --test packages/pi-subagent/src/reminder.test.ts
|
|
6
6
|
*
|
|
7
7
|
* Coverage: row formatting per state (queued/running/finished/failed/budget),
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* delivery-derived row removal (terminal rows checked on the active branch
|
|
9
|
+
* drop out; live rows never do), byte-stability between calls (the
|
|
10
|
+
* cache-prefix contract), empty-inbox no-op, and cache-stable head injection
|
|
11
|
+
* (string content, block content, non-user first message, empty transcript).
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { test, describe } from "node:test";
|
|
@@ -40,19 +41,22 @@ function entry(partial: Partial<InboxEntry> & Pick<InboxEntry, "id" | "state">):
|
|
|
40
41
|
|
|
41
42
|
describe("buildInboxReminder", () => {
|
|
42
43
|
test("empty inbox returns undefined (zero injection)", () => {
|
|
43
|
-
assert.equal(buildInboxReminder([]), undefined);
|
|
44
|
+
assert.equal(buildInboxReminder([], new Set()), undefined);
|
|
44
45
|
});
|
|
45
46
|
|
|
46
47
|
test("queued and running rows carry no time-derived detail", () => {
|
|
47
|
-
const text = buildInboxReminder(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
48
|
+
const text = buildInboxReminder(
|
|
49
|
+
[
|
|
50
|
+
entry({ id: "sub-3", state: "queued", snapshot: frame({ exitCode: -1, queued: true }) }),
|
|
51
|
+
entry({
|
|
52
|
+
id: "sub-2",
|
|
53
|
+
state: "running",
|
|
54
|
+
// Live frame: startTime present — a naive formatter would derive elapsed from it.
|
|
55
|
+
snapshot: frame({ exitCode: -1, startTime: Date.now() - 60_000 }),
|
|
56
|
+
}),
|
|
57
|
+
],
|
|
58
|
+
new Set(),
|
|
59
|
+
)!;
|
|
56
60
|
assert.match(text, /\n- sub-3 \(worker\) — queued — "Investigate flaky tests/);
|
|
57
61
|
assert.match(text, /\n- sub-2 \(worker\) — running — "Investigate flaky tests/);
|
|
58
62
|
// No seconds anywhere on live rows — byte-stability contract.
|
|
@@ -60,56 +64,68 @@ describe("buildInboxReminder", () => {
|
|
|
60
64
|
});
|
|
61
65
|
|
|
62
66
|
test("finished row freezes duration from elapsedMs", () => {
|
|
63
|
-
const text = buildInboxReminder(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
const text = buildInboxReminder(
|
|
68
|
+
[
|
|
69
|
+
entry({
|
|
70
|
+
id: "sub-1",
|
|
71
|
+
state: "finished",
|
|
72
|
+
snapshot: frame({ exitCode: 0, elapsedMs: 192_000 }),
|
|
73
|
+
}),
|
|
74
|
+
],
|
|
75
|
+
new Set(),
|
|
76
|
+
)!;
|
|
70
77
|
assert.match(text, /\n- sub-1 \(worker\) — finished \(ran 3m12s\) — "Investigate flaky tests/);
|
|
71
78
|
});
|
|
72
79
|
|
|
73
80
|
test("budget-stopped finished row is flagged partial", () => {
|
|
74
|
-
const text = buildInboxReminder(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
const text = buildInboxReminder(
|
|
82
|
+
[
|
|
83
|
+
entry({
|
|
84
|
+
id: "sub-1",
|
|
85
|
+
state: "finished",
|
|
86
|
+
snapshot: frame({ exitCode: 0, elapsedMs: 300_000, stopReason: "budget_exceeded" }),
|
|
87
|
+
}),
|
|
88
|
+
],
|
|
89
|
+
new Set(),
|
|
90
|
+
)!;
|
|
81
91
|
assert.match(text, /— finished, partial — budget exceeded \(ran 5m\) —/);
|
|
82
92
|
});
|
|
83
93
|
|
|
84
94
|
test("failed row carries the error preview, first line only", () => {
|
|
85
|
-
const text = buildInboxReminder(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
95
|
+
const text = buildInboxReminder(
|
|
96
|
+
[
|
|
97
|
+
entry({
|
|
98
|
+
id: "sub-4",
|
|
99
|
+
state: "failed",
|
|
100
|
+
snapshot: frame({
|
|
101
|
+
exitCode: 1,
|
|
102
|
+
elapsedMs: 5_000,
|
|
103
|
+
errorMessage: "provider timeout\nretry hint: check quota",
|
|
104
|
+
}),
|
|
93
105
|
}),
|
|
94
|
-
|
|
95
|
-
|
|
106
|
+
],
|
|
107
|
+
new Set(),
|
|
108
|
+
)!;
|
|
96
109
|
assert.match(text, /— failed — provider timeout \(ran 5s\) —/);
|
|
97
110
|
assert.doesNotMatch(text, /retry hint/);
|
|
98
111
|
});
|
|
99
112
|
|
|
100
113
|
test("cancelled row carries the cancel label, not failed", () => {
|
|
101
|
-
const text = buildInboxReminder(
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
const text = buildInboxReminder(
|
|
115
|
+
[
|
|
116
|
+
entry({
|
|
117
|
+
id: "sub-5",
|
|
118
|
+
state: "failed",
|
|
119
|
+
snapshot: frame({
|
|
120
|
+
exitCode: 1,
|
|
121
|
+
stopReason: "cancelled",
|
|
122
|
+
elapsedMs: 7_000,
|
|
123
|
+
errorMessage: "user: wrong direction after review",
|
|
124
|
+
}),
|
|
110
125
|
}),
|
|
111
|
-
|
|
112
|
-
|
|
126
|
+
],
|
|
127
|
+
new Set(),
|
|
128
|
+
)!;
|
|
113
129
|
assert.match(text, /— cancelled — user: wrong direction after review \(ran 7s\) —/);
|
|
114
130
|
});
|
|
115
131
|
|
|
@@ -118,19 +134,52 @@ describe("buildInboxReminder", () => {
|
|
|
118
134
|
entry({ id: "sub-1", state: "finished", snapshot: frame({ exitCode: 0, elapsedMs: 42_000 }) }),
|
|
119
135
|
entry({ id: "sub-2", state: "running", snapshot: frame({ exitCode: -1, startTime: 123 }) }),
|
|
120
136
|
];
|
|
121
|
-
assert.equal(buildInboxReminder(entries), buildInboxReminder(entries));
|
|
137
|
+
assert.equal(buildInboxReminder(entries, new Set()), buildInboxReminder(entries, new Set()));
|
|
122
138
|
});
|
|
123
139
|
|
|
124
140
|
test("long task text is truncated to the shared 70-char preview cap", () => {
|
|
125
141
|
const long = "x".repeat(120);
|
|
126
|
-
const text = buildInboxReminder([entry({ id: "sub-9", state: "running", task: long })])!;
|
|
142
|
+
const text = buildInboxReminder([entry({ id: "sub-9", state: "running", task: long })], new Set())!;
|
|
127
143
|
assert.ok(text.includes(`"${"x".repeat(70)}..."`));
|
|
128
144
|
});
|
|
129
145
|
|
|
130
146
|
test("header explains pull-only collection semantics", () => {
|
|
131
|
-
const text = buildInboxReminder([entry({ id: "sub-1", state: "running" })])!;
|
|
147
|
+
const text = buildInboxReminder([entry({ id: "sub-1", state: "running" })], new Set())!;
|
|
132
148
|
assert.match(text, /^\[background subagent runs — results are pull-only for the model/);
|
|
133
|
-
assert.match(text, /already
|
|
149
|
+
assert.match(text, /already checked on this branch\]/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("terminal rows in the delivered set drop out of the inbox", () => {
|
|
153
|
+
const text = buildInboxReminder(
|
|
154
|
+
[
|
|
155
|
+
entry({ id: "sub-1", state: "finished", snapshot: frame({ exitCode: 0, elapsedMs: 42_000 }) }),
|
|
156
|
+
entry({ id: "sub-2", state: "failed", snapshot: frame({ exitCode: 1, elapsedMs: 5_000 }) }),
|
|
157
|
+
],
|
|
158
|
+
new Set(["sub-1"]),
|
|
159
|
+
)!;
|
|
160
|
+
assert.doesNotMatch(text, /sub-1/);
|
|
161
|
+
assert.match(text, /sub-2 \(worker\) — failed/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("live rows stay listed even when their id is in the delivered set", () => {
|
|
165
|
+
// A live frame checked mid-run does not count as delivery — the result
|
|
166
|
+
// was not final yet, so the run keeps nagging until a terminal check.
|
|
167
|
+
const text = buildInboxReminder(
|
|
168
|
+
[
|
|
169
|
+
entry({ id: "sub-1", state: "queued", snapshot: frame({ exitCode: -1, queued: true }) }),
|
|
170
|
+
entry({ id: "sub-2", state: "running", snapshot: frame({ exitCode: -1, startTime: 99 }) }),
|
|
171
|
+
],
|
|
172
|
+
new Set(["sub-1", "sub-2"]),
|
|
173
|
+
)!;
|
|
174
|
+
assert.match(text, /sub-1 \(worker\) — queued/);
|
|
175
|
+
assert.match(text, /sub-2 \(worker\) — running/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("all terminal rows delivered returns undefined (zero injection)", () => {
|
|
179
|
+
const entries = [
|
|
180
|
+
entry({ id: "sub-1", state: "finished", snapshot: frame({ exitCode: 0, elapsedMs: 42_000 }) }),
|
|
181
|
+
];
|
|
182
|
+
assert.equal(buildInboxReminder(entries, new Set(["sub-1"])), undefined);
|
|
134
183
|
});
|
|
135
184
|
});
|
|
136
185
|
|
package/src/reminder.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The model's inbox of
|
|
2
|
+
* The model's inbox of background subagent runs.
|
|
3
3
|
*
|
|
4
4
|
* Injected into the LLM context before every provider call via the `context`
|
|
5
|
-
* event. The reminder lists every delegated run
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* event. The reminder lists every delegated run not yet delivered by a
|
|
6
|
+
* subagent_check on the active branch — queued, running, and
|
|
7
|
+
* finished/failed alike — so the model cannot forget about them. Delivery
|
|
8
|
+
* state is derived from the session tree (see collectDeliveredIds), not
|
|
9
|
+
* tracked in the registry: branching past a check re-arms the inbox,
|
|
10
|
+
* branching back silences it, and compaction un-delivers naturally.
|
|
9
11
|
*
|
|
10
12
|
* Cache discipline: the reminder is prepended to the FIRST user message, so
|
|
11
13
|
* it sits at a stable position in the message prefix. Its content must stay
|
|
@@ -28,7 +30,7 @@ export interface InboxEntry {
|
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
const INBOX_HEADER =
|
|
31
|
-
"[background subagent runs — results are pull-only for the model: no completion notice wakes you. subagent_wait, then subagent_check to collect each run; a terminal check removes it from this list; runs missing here were already
|
|
33
|
+
"[background subagent runs — results are pull-only for the model: no completion notice wakes you. subagent_wait, then subagent_check to collect each run; a terminal check removes it from this list; runs missing here were already checked on this branch]";
|
|
32
34
|
|
|
33
35
|
/** `42s`, `3m12s`, `4m` — whole seconds, no live clocks. */
|
|
34
36
|
function formatDuration(totalSec: number): string {
|
|
@@ -61,12 +63,16 @@ function inboxStatus(entry: InboxEntry): string {
|
|
|
61
63
|
|
|
62
64
|
/**
|
|
63
65
|
* Build the inbox reminder text, or undefined when every delegated run has
|
|
64
|
-
* been
|
|
65
|
-
* untouched and the provider cache fully
|
|
66
|
+
* already been checked on the active branch (nothing to remind about —
|
|
67
|
+
* inject nothing, keep the context untouched and the provider cache fully
|
|
68
|
+
* stable). Queued/running runs are always listed regardless of delivery
|
|
69
|
+
* state — their result is not final yet, so a past check (of a live frame)
|
|
70
|
+
* never counts as delivered.
|
|
66
71
|
*/
|
|
67
|
-
export function buildInboxReminder(entries: Iterable<InboxEntry>): string | undefined {
|
|
72
|
+
export function buildInboxReminder(entries: Iterable<InboxEntry>, delivered: Set<string>): string | undefined {
|
|
68
73
|
const rows: string[] = [];
|
|
69
74
|
for (const entry of entries) {
|
|
75
|
+
if (entry.state !== "queued" && entry.state !== "running" && delivered.has(entry.id)) continue;
|
|
70
76
|
rows.push(`- ${entry.id} (${entry.role}) — ${inboxStatus(entry)} — "${taskPreview(entry.task)}"`);
|
|
71
77
|
}
|
|
72
78
|
if (rows.length === 0) return undefined;
|
package/src/render-async.ts
CHANGED
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
CheckDetails,
|
|
34
34
|
CompletionNoticeDetails,
|
|
35
35
|
RunViewEntry,
|
|
36
|
+
SteerDetails,
|
|
36
37
|
SubagentResult,
|
|
37
38
|
WaitDetails,
|
|
38
39
|
} from "./types.ts";
|
|
@@ -46,6 +47,7 @@ import {
|
|
|
46
47
|
deriveRunState,
|
|
47
48
|
ensureElapsedTimer,
|
|
48
49
|
formatFallback,
|
|
50
|
+
formatInheritedConversationInput,
|
|
49
51
|
formatThinking,
|
|
50
52
|
formatTimePart,
|
|
51
53
|
formatToolCall,
|
|
@@ -282,10 +284,13 @@ export const renderCompletionNotice: MessageRenderer<CompletionNoticeDetails> =
|
|
|
282
284
|
|
|
283
285
|
export const renderBackgroundDelegateCall: RenderCallFn = (args, theme) => {
|
|
284
286
|
const roleName = (args as any).role || "...";
|
|
287
|
+
const mode = (args as any).inheritConversation
|
|
288
|
+
? " (background · inherits conversation)"
|
|
289
|
+
: " (background)";
|
|
285
290
|
const text =
|
|
286
291
|
theme.fg("toolTitle", theme.bold("subagent_delegate ")) +
|
|
287
292
|
theme.fg("accent", roleName) +
|
|
288
|
-
theme.fg("dim",
|
|
293
|
+
theme.fg("dim", mode);
|
|
289
294
|
return new Text(text, 0, 0);
|
|
290
295
|
};
|
|
291
296
|
|
|
@@ -301,7 +306,7 @@ export const renderBackgroundDelegateResult: RenderResultFn = (result, { expande
|
|
|
301
306
|
|
|
302
307
|
if (!expanded) return collapsedText(summaryLine);
|
|
303
308
|
|
|
304
|
-
// Expanded: full input — reference files, context
|
|
309
|
+
// Expanded: full input — reference files, context/inheritance metadata, task text.
|
|
305
310
|
const container = new Container();
|
|
306
311
|
container.addChild(new Text(summaryLine, 0, 0));
|
|
307
312
|
container.addChild(new Spacer(1));
|
|
@@ -313,6 +318,21 @@ export const renderBackgroundDelegateResult: RenderResultFn = (result, { expande
|
|
|
313
318
|
if (details.context) {
|
|
314
319
|
container.addChild(new Text(fg("dim", `ctx ${details.context.length} chars`), 0, 0));
|
|
315
320
|
}
|
|
321
|
+
if (details.inheritConversation) {
|
|
322
|
+
container.addChild(
|
|
323
|
+
new Text(
|
|
324
|
+
fg(
|
|
325
|
+
"dim",
|
|
326
|
+
formatInheritedConversationInput(
|
|
327
|
+
details.inheritedConversationChars ?? 0,
|
|
328
|
+
details.inheritedConversationTruncated === true,
|
|
329
|
+
),
|
|
330
|
+
),
|
|
331
|
+
0,
|
|
332
|
+
0,
|
|
333
|
+
),
|
|
334
|
+
);
|
|
335
|
+
}
|
|
316
336
|
container.addChild(new Text(fg("dim", details.task), 0, 0));
|
|
317
337
|
return container;
|
|
318
338
|
};
|
|
@@ -387,6 +407,54 @@ export const renderCheckResult: RenderResultFn = (result, { expanded }, theme, _
|
|
|
387
407
|
return collapsedText(checkEntryCollapsedText(details.result, fg));
|
|
388
408
|
};
|
|
389
409
|
|
|
410
|
+
// ── steer: correction echo (check verifies the effect) ──
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Factory: the call line shows the run's role, but args carry only id +
|
|
414
|
+
* message — the registry lookup is injected. Unknown id (e.g. re-rendering a
|
|
415
|
+
* persisted session where the registry is empty) degrades to the bare id.
|
|
416
|
+
*/
|
|
417
|
+
export function createSteerCallRender(roleOf: (id: string) => string | undefined): RenderCallFn {
|
|
418
|
+
return (args, theme) => {
|
|
419
|
+
const id = (args as any).id || "...";
|
|
420
|
+
const role = roleOf(id);
|
|
421
|
+
const label = role ? `${id} (${role})` : id;
|
|
422
|
+
const text = theme.fg("toolTitle", theme.bold("subagent_steer ")) + theme.fg("accent", label);
|
|
423
|
+
return new Text(text, 0, 0);
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export const renderSteerResult: RenderResultFn = (result, { expanded }, theme) => {
|
|
428
|
+
const details = result.details as SteerDetails | undefined;
|
|
429
|
+
if (!details) return collapsedText(contentText(result));
|
|
430
|
+
|
|
431
|
+
const fg = theme.fg.bind(theme) as Fg;
|
|
432
|
+
const icon = fg("accent", "\u21a9"); // ↩ — same marker as steer entries in the activity stream
|
|
433
|
+
const message = details.message.trim() || "(empty message)";
|
|
434
|
+
|
|
435
|
+
// Body carries the correction only — no id/role prefix; the target lives in
|
|
436
|
+
// the call line and the delivery hint below.
|
|
437
|
+
if (!expanded) {
|
|
438
|
+
const firstLine = message.split("\n")[0];
|
|
439
|
+
return collapsedText(`${icon} ${fg("text", firstLine)}`);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const container = new Container();
|
|
443
|
+
container.addChild(new Text(`${icon} ${fg("text", message)}`, 0, 0));
|
|
444
|
+
container.addChild(new Spacer(1));
|
|
445
|
+
container.addChild(
|
|
446
|
+
new Text(
|
|
447
|
+
fg(
|
|
448
|
+
"dim",
|
|
449
|
+
`${details.id} (${details.role}) — delivered after the current tool batch, verify with subagent_check later`,
|
|
450
|
+
),
|
|
451
|
+
0,
|
|
452
|
+
0,
|
|
453
|
+
),
|
|
454
|
+
);
|
|
455
|
+
return container;
|
|
456
|
+
};
|
|
457
|
+
|
|
390
458
|
// ── cancel: confirmation-only view (check is the result-fetcher) ──
|
|
391
459
|
|
|
392
460
|
export const renderCancelCall: RenderCallFn = (args, theme) => {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Tests for inherited-conversation TUI observability. */
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { renderBackgroundDelegateCall, renderBackgroundDelegateResult } from "./render-async.ts";
|
|
6
|
+
import { renderDelegateCall } from "./render.ts";
|
|
7
|
+
|
|
8
|
+
const theme = {
|
|
9
|
+
fg: (_color: string, text: string) => text,
|
|
10
|
+
bold: (text: string) => text,
|
|
11
|
+
} as any;
|
|
12
|
+
|
|
13
|
+
function rendered(component: { render(width: number): string[] }): string {
|
|
14
|
+
return component
|
|
15
|
+
.render(200)
|
|
16
|
+
.map((line) => line.trimEnd())
|
|
17
|
+
.join("\n");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("delegate call titles mark inherited conversation without changing isolated mode", () => {
|
|
21
|
+
const isolated = rendered(renderDelegateCall({ role: "worker" } as any, theme, {} as any));
|
|
22
|
+
const inherited = rendered(
|
|
23
|
+
renderDelegateCall({ role: "worker", inheritConversation: true } as any, theme, {} as any),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
assert.equal(isolated, "subagent_delegate worker");
|
|
27
|
+
assert.equal(inherited, "subagent_delegate worker (inherits conversation)");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("background call and expanded input expose only safe inheritance metadata", () => {
|
|
31
|
+
const call = rendered(
|
|
32
|
+
renderBackgroundDelegateCall(
|
|
33
|
+
{ role: "worker", background: true, inheritConversation: true } as any,
|
|
34
|
+
theme,
|
|
35
|
+
{} as any,
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
assert.equal(call, "subagent_delegate worker (background · inherits conversation)");
|
|
39
|
+
|
|
40
|
+
const result = rendered(
|
|
41
|
+
renderBackgroundDelegateResult(
|
|
42
|
+
{
|
|
43
|
+
content: [{ type: "text", text: "started" }],
|
|
44
|
+
details: {
|
|
45
|
+
id: "sub-1",
|
|
46
|
+
role: "worker",
|
|
47
|
+
task: "Implement the delta",
|
|
48
|
+
context: "explicit context",
|
|
49
|
+
inheritConversation: true,
|
|
50
|
+
inheritedConversationChars: 50_000,
|
|
51
|
+
inheritedConversationTruncated: true,
|
|
52
|
+
},
|
|
53
|
+
} as any,
|
|
54
|
+
{ expanded: true, isPartial: false },
|
|
55
|
+
theme,
|
|
56
|
+
{} as any,
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
assert.match(result, /ctx 16 chars/);
|
|
61
|
+
assert.match(result, /conversation 50000 chars · truncated/);
|
|
62
|
+
assert.ok(!result.includes("inherited_conversation"));
|
|
63
|
+
});
|
package/src/render.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
contentText,
|
|
16
16
|
ensureElapsedTimer,
|
|
17
17
|
formatFallback,
|
|
18
|
+
formatInheritedConversationInput,
|
|
18
19
|
formatThinking,
|
|
19
20
|
formatTimePart,
|
|
20
21
|
formatToolCall,
|
|
@@ -35,7 +36,13 @@ type RenderResultFn = NonNullable<ToolDefinition["renderResult"]>;
|
|
|
35
36
|
|
|
36
37
|
export const renderDelegateCall: RenderCallFn = (args, theme, _context) => {
|
|
37
38
|
const roleName = (args as any).role || "...";
|
|
38
|
-
const
|
|
39
|
+
const inheritance = (args as any).inheritConversation
|
|
40
|
+
? theme.fg("dim", " (inherits conversation)")
|
|
41
|
+
: "";
|
|
42
|
+
const text =
|
|
43
|
+
theme.fg("toolTitle", theme.bold("subagent_delegate ")) +
|
|
44
|
+
theme.fg("accent", roleName) +
|
|
45
|
+
inheritance;
|
|
39
46
|
return new Text(text, 0, 0);
|
|
40
47
|
};
|
|
41
48
|
|
|
@@ -104,8 +111,8 @@ export const renderDelegateResult: RenderResultFn = (result, { expanded }, theme
|
|
|
104
111
|
container.addChild(new Text(fallbackLine, 0, 0));
|
|
105
112
|
}
|
|
106
113
|
|
|
107
|
-
// Input block: reference files + context
|
|
108
|
-
// grouped without inner spacing (
|
|
114
|
+
// Input block: reference files + context/inherited-conversation metadata
|
|
115
|
+
// + task full text, grouped without inner spacing (all subagent input).
|
|
109
116
|
container.addChild(new Spacer(1));
|
|
110
117
|
if (r.files) {
|
|
111
118
|
for (const f of r.files) {
|
|
@@ -115,6 +122,21 @@ export const renderDelegateResult: RenderResultFn = (result, { expanded }, theme
|
|
|
115
122
|
if (r.context) {
|
|
116
123
|
container.addChild(new Text(theme.fg("dim", `ctx ${r.context.length} chars`), 0, 0));
|
|
117
124
|
}
|
|
125
|
+
if (r.inheritConversation) {
|
|
126
|
+
container.addChild(
|
|
127
|
+
new Text(
|
|
128
|
+
theme.fg(
|
|
129
|
+
"dim",
|
|
130
|
+
formatInheritedConversationInput(
|
|
131
|
+
r.inheritedConversationChars ?? 0,
|
|
132
|
+
r.inheritedConversationTruncated === true,
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
0,
|
|
136
|
+
0,
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
118
140
|
container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
|
|
119
141
|
|
|
120
142
|
// Activity stream (shown while running and after completion).
|