@oh-my-pi/pi-coding-agent 16.5.0 → 16.5.2
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 +91 -0
- package/dist/cli.js +3556 -3504
- package/dist/types/advisor/advise-tool.d.ts +12 -1
- package/dist/types/advisor/runtime.d.ts +41 -1
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/update-cli.d.ts +4 -1
- package/dist/types/cli/usage-cli.d.ts +3 -0
- package/dist/types/cli/usage-error.d.ts +4 -0
- package/dist/types/config/api-key-resolver.d.ts +2 -2
- package/dist/types/config/model-registry.d.ts +3 -3
- package/dist/types/config/model-resolver.d.ts +8 -1
- package/dist/types/config/models-config.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +10 -0
- package/dist/types/discovery/substitute-plugin-root.d.ts +22 -0
- package/dist/types/eval/__tests__/process-entry-import.test.d.ts +1 -0
- package/dist/types/eval/backend.d.ts +3 -3
- package/dist/types/eval/bridge-timeout.d.ts +9 -1
- package/dist/types/eval/js/context-manager.d.ts +5 -3
- package/dist/types/eval/js/process-entry.d.ts +6 -0
- package/dist/types/eval/js/worker-core.d.ts +15 -1
- package/dist/types/eval/py/spawn-options.d.ts +10 -0
- package/dist/types/eval/py/tool-bridge.d.ts +1 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
- package/dist/types/extensibility/extensions/runner.d.ts +3 -1
- package/dist/types/extensibility/extensions/types.d.ts +3 -0
- package/dist/types/extensibility/extensions/wrapper.d.ts +3 -6
- package/dist/types/extensibility/plugins/bun-git-cache.d.ts +3 -0
- package/dist/types/goals/guided-setup.d.ts +12 -0
- package/dist/types/internal-urls/history-protocol.d.ts +3 -2
- package/dist/types/internal-urls/memory-protocol.d.ts +6 -7
- package/dist/types/internal-urls/registry-helpers.d.ts +19 -0
- package/dist/types/main.d.ts +1 -0
- package/dist/types/mcp/oauth-discovery.d.ts +2 -0
- package/dist/types/mcp/oauth-flow.d.ts +2 -0
- package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +1 -0
- package/dist/types/modes/components/agent-hub.d.ts +10 -0
- package/dist/types/modes/components/dynamic-border.d.ts +5 -3
- package/dist/types/modes/components/login-dialog.d.ts +2 -0
- package/dist/types/modes/components/mcp-add-wizard.d.ts +1 -0
- package/dist/types/modes/components/read-tool-group.d.ts +0 -2
- package/dist/types/modes/components/transcript-container.d.ts +3 -2
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
- package/dist/types/modes/orchestrate.d.ts +1 -1
- package/dist/types/modes/rpc/host-tools.d.ts +2 -0
- package/dist/types/modes/rpc/rpc-mode.d.ts +26 -6
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/modes/ultrathink.d.ts +1 -1
- package/dist/types/modes/utils/transcript-render-helpers.d.ts +12 -0
- package/dist/types/modes/workflow.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +6 -0
- package/dist/types/session/exit-diagnostics.d.ts +11 -0
- package/dist/types/session/messages.d.ts +15 -0
- package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +11 -0
- package/dist/types/subprocess/worker-client.d.ts +6 -0
- package/dist/types/tools/bash-skill-urls.d.ts +1 -0
- package/dist/types/tools/grep.d.ts +0 -2
- package/dist/types/tools/read.d.ts +0 -4
- package/dist/types/web/search/provider.d.ts +10 -3
- package/dist/types/web/search/providers/codex.d.ts +5 -4
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +917 -42
- package/src/advisor/advise-tool.ts +17 -1
- package/src/advisor/runtime.ts +270 -67
- package/src/autolearn/controller.ts +15 -3
- package/src/autoresearch/dashboard.ts +2 -2
- package/src/cli/args.ts +12 -0
- package/src/cli/auth-broker-cli.ts +30 -11
- package/src/cli/auth-gateway-cli.ts +5 -1
- package/src/cli/config-cli.ts +15 -3
- package/src/cli/dry-balance-cli.ts +14 -4
- package/src/cli/flag-tables.ts +21 -7
- package/src/cli/update-cli.ts +62 -11
- package/src/cli/usage-cli.ts +58 -5
- package/src/cli/usage-error.ts +7 -0
- package/src/cli.ts +23 -1
- package/src/commands/acp.ts +11 -2
- package/src/commands/launch.ts +12 -3
- package/src/commands/token.ts +3 -1
- package/src/config/api-key-resolver.ts +12 -3
- package/src/config/config-file.ts +30 -12
- package/src/config/model-registry.ts +7 -7
- package/src/config/model-resolver.ts +21 -7
- package/src/config/models-config.ts +1 -1
- package/src/config/settings-schema.ts +10 -0
- package/src/cursor.ts +2 -0
- package/src/discovery/claude-plugins.ts +9 -3
- package/src/discovery/omp-plugins.ts +6 -2
- package/src/discovery/substitute-plugin-root.ts +32 -0
- package/src/eval/__tests__/agent-bridge.test.ts +19 -14
- package/src/eval/__tests__/bridge-timeout.test.ts +106 -0
- package/src/eval/__tests__/js-context-manager.test.ts +158 -1
- package/src/eval/__tests__/kernel-spawn.test.ts +12 -0
- package/src/eval/__tests__/prelude-agent.test.ts +20 -0
- package/src/eval/__tests__/process-entry-import.test.ts +27 -0
- package/src/eval/agent-bridge.ts +121 -116
- package/src/eval/backend.ts +3 -3
- package/src/eval/bridge-timeout.ts +20 -2
- package/src/eval/executor-base.ts +85 -7
- package/src/eval/jl/kernel.ts +2 -1
- package/src/eval/js/context-manager.ts +109 -32
- package/src/eval/js/process-entry.ts +27 -0
- package/src/eval/js/shared/runtime.ts +1 -1
- package/src/eval/js/worker-core.ts +70 -9
- package/src/eval/js/worker-entry.ts +1 -1
- package/src/eval/py/__tests__/prelude.test.ts +72 -0
- package/src/eval/py/kernel.ts +2 -1
- package/src/eval/py/prelude.py +28 -1
- package/src/eval/py/spawn-options.ts +13 -0
- package/src/eval/py/tool-bridge.ts +13 -14
- package/src/eval/rb/kernel.ts +2 -1
- package/src/exec/bash-executor.ts +30 -43
- package/src/extensibility/custom-tools/types.ts +3 -0
- package/src/extensibility/extensions/runner.ts +3 -0
- package/src/extensibility/extensions/types.ts +3 -0
- package/src/extensibility/extensions/wrapper.ts +18 -18
- package/src/extensibility/plugins/bun-git-cache.ts +91 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +32 -16
- package/src/extensibility/plugins/manager.ts +28 -7
- package/src/goals/guided-setup.ts +29 -1
- package/src/internal-urls/history-protocol.ts +95 -15
- package/src/internal-urls/memory-protocol.ts +13 -9
- package/src/internal-urls/registry-helpers.ts +50 -1
- package/src/launch/broker.ts +38 -25
- package/src/lsp/client.ts +7 -1
- package/src/main.ts +29 -0
- package/src/mcp/oauth-discovery.ts +20 -1
- package/src/mcp/oauth-flow.ts +3 -1
- package/src/mcp/tool-bridge.ts +57 -6
- package/src/modes/components/__tests__/dynamic-border.test.ts +55 -0
- package/src/modes/components/agent-dashboard.ts +2 -2
- package/src/modes/components/agent-hub.ts +15 -2
- package/src/modes/components/agent-transcript-viewer.ts +2 -2
- package/src/modes/components/chat-transcript-builder.ts +26 -4
- package/src/modes/components/dynamic-border.ts +9 -6
- package/src/modes/components/extensions/extension-list.ts +2 -2
- package/src/modes/components/hook-selector.ts +10 -4
- package/src/modes/components/login-dialog.ts +5 -0
- package/src/modes/components/mcp-add-wizard.ts +5 -0
- package/src/modes/components/plan-review-overlay.ts +11 -11
- package/src/modes/components/read-tool-group.ts +1 -8
- package/src/modes/components/status-line/component.ts +10 -1
- package/src/modes/components/transcript-container.ts +110 -7
- package/src/modes/controllers/command-controller.ts +12 -4
- package/src/modes/controllers/event-controller.ts +80 -15
- package/src/modes/controllers/input-controller.ts +4 -2
- package/src/modes/controllers/mcp-command-controller.ts +6 -7
- package/src/modes/controllers/selector-controller.ts +24 -5
- package/src/modes/controllers/todo-command-controller.ts +18 -14
- package/src/modes/interactive-mode.ts +7 -3
- package/src/modes/magic-keyword-boundary.ts +23 -0
- package/src/modes/orchestrate.ts +6 -5
- package/src/modes/print-mode.ts +9 -0
- package/src/modes/prompt-action-autocomplete.ts +6 -1
- package/src/modes/rpc/host-tools.ts +15 -0
- package/src/modes/rpc/rpc-mode.ts +123 -48
- package/src/modes/types.ts +1 -1
- package/src/modes/ultrathink.ts +6 -5
- package/src/modes/utils/transcript-render-helpers.ts +54 -0
- package/src/modes/utils/ui-helpers.ts +27 -1
- package/src/modes/workflow.ts +6 -5
- package/src/prompts/advisor/system.md +1 -0
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/prompts/tools/eval.md +2 -2
- package/src/prompts/tools/grep.md +1 -2
- package/src/prompts/tools/read.md +2 -4
- package/src/sdk.ts +61 -34
- package/src/session/agent-session.ts +283 -39
- package/src/session/exit-diagnostics.ts +108 -0
- package/src/session/messages.test.ts +66 -0
- package/src/session/messages.ts +37 -0
- package/src/session/streaming-output.ts +40 -12
- package/src/slash-commands/helpers/active-oauth-account.ts +22 -2
- package/src/slash-commands/helpers/logout.ts +23 -3
- package/src/slash-commands/helpers/usage-report.ts +14 -2
- package/src/subprocess/worker-client.ts +9 -2
- package/src/system-prompt.test.ts +36 -0
- package/src/system-prompt.ts +1 -1
- package/src/task/executor.ts +8 -0
- package/src/task/render.test.ts +36 -0
- package/src/task/render.ts +55 -43
- package/src/tools/bash-skill-urls.ts +4 -1
- package/src/tools/bash.ts +1 -0
- package/src/tools/browser/registry.ts +17 -3
- package/src/tools/eval.ts +14 -9
- package/src/tools/gh.ts +3 -1
- package/src/tools/grep.ts +5 -45
- package/src/tools/path-utils.ts +7 -1
- package/src/tools/read.ts +23 -74
- package/src/tools/write.ts +82 -9
- package/src/tools/yield.ts +29 -1
- package/src/utils/title-generator.ts +10 -6
- package/src/web/search/index.ts +39 -22
- package/src/web/search/provider.ts +33 -16
- package/src/web/search/providers/codex.ts +68 -21
- package/src/web/search/providers/perplexity-auth.ts +20 -11
- package/src/web/search/providers/perplexity.ts +14 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
2
|
+
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
|
|
2
3
|
import type { SessionEntry } from "./session-entries";
|
|
3
4
|
|
|
4
5
|
export const TOOL_EXECUTION_START_CUSTOM_TYPE = "tool_execution_start";
|
|
@@ -53,10 +54,117 @@ interface ToolCallContent {
|
|
|
53
54
|
arguments?: unknown;
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
export interface AssistantModelMetadata {
|
|
58
|
+
api: AssistantMessage["api"];
|
|
59
|
+
provider: string;
|
|
60
|
+
model: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
56
63
|
function isObject(value: unknown): value is Record<string, unknown> {
|
|
57
64
|
if (typeof value !== "object") return false;
|
|
58
65
|
return value !== null;
|
|
59
66
|
}
|
|
67
|
+
function isPendingToolCallDiagnostic(value: unknown): value is PendingToolCallDiagnostic {
|
|
68
|
+
if (!isObject(value) || typeof value.toolName !== "string") return false;
|
|
69
|
+
if ("toolCallId" in value && typeof value.toolCallId !== "string") return false;
|
|
70
|
+
if ("intent" in value && typeof value.intent !== "string") return false;
|
|
71
|
+
if ("assistantTimestamp" in value && typeof value.assistantTimestamp !== "number") return false;
|
|
72
|
+
if ("startedAt" in value && typeof value.startedAt !== "string") return false;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readPendingToolCalls(value: unknown): PendingToolCallDiagnostic[] | undefined {
|
|
77
|
+
if (!Array.isArray(value) || !value.every(isPendingToolCallDiagnostic)) return undefined;
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function readSessionExit(entry: SessionEntry): SessionExitData | undefined {
|
|
82
|
+
if (entry.type !== "custom" || entry.customType !== SESSION_EXIT_CUSTOM_TYPE || !isObject(entry.data)) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
const { reason, kind, recordedAt } = entry.data;
|
|
86
|
+
if (
|
|
87
|
+
typeof reason !== "string" ||
|
|
88
|
+
(kind !== "normal" && kind !== "signal" && kind !== "fatal" && kind !== "process_exit") ||
|
|
89
|
+
typeof recordedAt !== "string"
|
|
90
|
+
) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
reason,
|
|
95
|
+
kind,
|
|
96
|
+
recordedAt,
|
|
97
|
+
pendingToolCalls: readPendingToolCalls(entry.data.pendingToolCalls),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* createInterruptedTurnAbortMessage returns a terminal assistant record when
|
|
103
|
+
* the latest persisted process exit follows a non-terminal conversation tail.
|
|
104
|
+
*/
|
|
105
|
+
export function createInterruptedTurnAbortMessage(
|
|
106
|
+
entries: readonly SessionEntry[],
|
|
107
|
+
fallbackModel?: AssistantModelMetadata,
|
|
108
|
+
): AssistantMessage | undefined {
|
|
109
|
+
let exitIndex = -1;
|
|
110
|
+
let exit: SessionExitData | undefined;
|
|
111
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
112
|
+
const candidate = readSessionExit(entries[index]!);
|
|
113
|
+
if (!candidate) continue;
|
|
114
|
+
exitIndex = index;
|
|
115
|
+
exit = candidate;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
if (!exit || (exit.kind === "normal" && !exit.pendingToolCalls?.length)) return undefined;
|
|
119
|
+
|
|
120
|
+
let tailIndex = -1;
|
|
121
|
+
let tail: AgentMessage | undefined;
|
|
122
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
123
|
+
const entry = entries[index]!;
|
|
124
|
+
if (entry.type !== "message") continue;
|
|
125
|
+
tailIndex = index;
|
|
126
|
+
tail = entry.message;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
if (!tail || tailIndex > exitIndex) return undefined;
|
|
130
|
+
if (tail.role === "assistant" && !tail.content.some(isToolCallContent)) return undefined;
|
|
131
|
+
|
|
132
|
+
let previousAssistant: AssistantMessage | undefined;
|
|
133
|
+
for (let index = tailIndex; index >= 0; index--) {
|
|
134
|
+
const entry = entries[index]!;
|
|
135
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
136
|
+
previousAssistant = entry.message;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
if (
|
|
140
|
+
tail.role === "toolResult" &&
|
|
141
|
+
(previousAssistant?.stopReason === "error" || previousAssistant?.stopReason === "aborted")
|
|
142
|
+
) {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
const model = previousAssistant ?? fallbackModel;
|
|
146
|
+
if (!model) return undefined;
|
|
147
|
+
|
|
148
|
+
const recordedAt = Date.parse(exit.recordedAt);
|
|
149
|
+
return {
|
|
150
|
+
role: "assistant",
|
|
151
|
+
content: [],
|
|
152
|
+
api: model.api,
|
|
153
|
+
provider: model.provider,
|
|
154
|
+
model: model.model,
|
|
155
|
+
usage: {
|
|
156
|
+
input: 0,
|
|
157
|
+
output: 0,
|
|
158
|
+
cacheRead: 0,
|
|
159
|
+
cacheWrite: 0,
|
|
160
|
+
totalTokens: 0,
|
|
161
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
162
|
+
},
|
|
163
|
+
stopReason: "aborted",
|
|
164
|
+
errorMessage: "Previous OMP process exited before completing the turn.",
|
|
165
|
+
timestamp: Number.isFinite(recordedAt) ? recordedAt : Date.now(),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
60
168
|
|
|
61
169
|
function isToolCallContent(value: unknown): value is ToolCallContent {
|
|
62
170
|
if (!isObject(value)) return false;
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type CustomMessage,
|
|
6
6
|
convertToLlm,
|
|
7
7
|
INTERRUPTED_THINKING_MESSAGE_TYPE,
|
|
8
|
+
replaceLlmImagesWithText,
|
|
8
9
|
SKILL_PROMPT_MESSAGE_TYPE,
|
|
9
10
|
type SkillPromptDetails,
|
|
10
11
|
} from "./messages";
|
|
@@ -123,3 +124,68 @@ describe("convertToLlm", () => {
|
|
|
123
124
|
]);
|
|
124
125
|
});
|
|
125
126
|
});
|
|
127
|
+
|
|
128
|
+
describe("replaceLlmImagesWithText", () => {
|
|
129
|
+
it("replaces image blocks in user, developer, and tool-result messages with the placeholder", () => {
|
|
130
|
+
const converted = convertToLlm([
|
|
131
|
+
{
|
|
132
|
+
role: "user",
|
|
133
|
+
content: [
|
|
134
|
+
{ type: "text", text: "look" },
|
|
135
|
+
{ type: "image", data: "aaaa", mimeType: "image/png" },
|
|
136
|
+
],
|
|
137
|
+
attribution: "user",
|
|
138
|
+
timestamp: 1,
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
role: "toolResult",
|
|
142
|
+
toolCallId: "c1",
|
|
143
|
+
toolName: "inspect_image",
|
|
144
|
+
content: [{ type: "image", data: "bbbb", mimeType: "image/png" }],
|
|
145
|
+
isError: false,
|
|
146
|
+
timestamp: 2,
|
|
147
|
+
},
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
const scrubbed = replaceLlmImagesWithText(converted, "[image omitted]");
|
|
151
|
+
|
|
152
|
+
expect(scrubbed).not.toBe(converted);
|
|
153
|
+
const types = scrubbed.flatMap(m => (Array.isArray(m.content) ? m.content.map(b => b.type) : []));
|
|
154
|
+
expect(types).not.toContain("image");
|
|
155
|
+
const user = scrubbed.find(m => m.role === "user");
|
|
156
|
+
expect(Array.isArray(user?.content) && user.content.map(b => (b.type === "text" ? b.text : b.type))).toEqual([
|
|
157
|
+
"look",
|
|
158
|
+
"[image omitted]",
|
|
159
|
+
]);
|
|
160
|
+
const toolResult = scrubbed.find(m => m.role === "toolResult");
|
|
161
|
+
expect(Array.isArray(toolResult?.content) && toolResult.content).toEqual([
|
|
162
|
+
{ type: "text", text: "[image omitted]" },
|
|
163
|
+
]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("collapses consecutive image blocks into a single placeholder", () => {
|
|
167
|
+
const converted = convertToLlm([
|
|
168
|
+
{
|
|
169
|
+
role: "user",
|
|
170
|
+
content: [
|
|
171
|
+
{ type: "image", data: "aaaa", mimeType: "image/png" },
|
|
172
|
+
{ type: "image", data: "bbbb", mimeType: "image/png" },
|
|
173
|
+
],
|
|
174
|
+
attribution: "user",
|
|
175
|
+
timestamp: 1,
|
|
176
|
+
},
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
const scrubbed = replaceLlmImagesWithText(converted, "[image omitted]");
|
|
180
|
+
const user = scrubbed.find(m => m.role === "user");
|
|
181
|
+
expect(Array.isArray(user?.content) && user.content).toEqual([{ type: "text", text: "[image omitted]" }]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("returns the same array reference when there are no image blocks", () => {
|
|
185
|
+
const converted = convertToLlm([
|
|
186
|
+
{ role: "user", content: [{ type: "text", text: "hi" }], attribution: "user", timestamp: 1 },
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
expect(replaceLlmImagesWithText(converted, "[image omitted]")).toBe(converted);
|
|
190
|
+
});
|
|
191
|
+
});
|
package/src/session/messages.ts
CHANGED
|
@@ -512,6 +512,43 @@ export function stripImagesFromMessage(message: AgentMessage): number {
|
|
|
512
512
|
}
|
|
513
513
|
}
|
|
514
514
|
|
|
515
|
+
/**
|
|
516
|
+
* Replace every `ImageContent` block in already-converted LLM {@link Message}s
|
|
517
|
+
* with a text placeholder, returning a new array only when something changed.
|
|
518
|
+
*
|
|
519
|
+
* Unlike {@link stripImagesFromMessage} (which mutates persisted `AgentMessage`s
|
|
520
|
+
* in place), this operates on the ephemeral provider-request view produced by
|
|
521
|
+
* {@link convertToLlm}, so history on disk keeps its images while the outbound
|
|
522
|
+
* request is scrubbed. Used to keep image blocks off the wire when the active
|
|
523
|
+
* model has no vision support (or `images.blockImages` is set) — e.g. after
|
|
524
|
+
* switching from a vision model to a text-only one mid-session (#5400).
|
|
525
|
+
*
|
|
526
|
+
* Consecutive placeholder texts collapse into one so a message that was nothing
|
|
527
|
+
* but images does not balloon into a run of identical notes.
|
|
528
|
+
*/
|
|
529
|
+
export function replaceLlmImagesWithText(messages: Message[], placeholder: string): Message[] {
|
|
530
|
+
let out: Message[] | undefined;
|
|
531
|
+
for (let i = 0; i < messages.length; i++) {
|
|
532
|
+
const msg = messages[i];
|
|
533
|
+
if (msg.role !== "user" && msg.role !== "developer" && msg.role !== "toolResult") continue;
|
|
534
|
+
const content = msg.content;
|
|
535
|
+
if (!Array.isArray(content) || !content.some(part => part.type === "image")) continue;
|
|
536
|
+
const replaced: (TextContent | ImageContent)[] = [];
|
|
537
|
+
for (const part of content) {
|
|
538
|
+
if (part.type !== "image") {
|
|
539
|
+
replaced.push(part);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
const prev = replaced[replaced.length - 1];
|
|
543
|
+
if (prev?.type === "text" && prev.text === placeholder) continue;
|
|
544
|
+
replaced.push({ type: "text", text: placeholder });
|
|
545
|
+
}
|
|
546
|
+
if (out === undefined) out = messages.slice();
|
|
547
|
+
out[i] = { ...msg, content: replaced } as Message;
|
|
548
|
+
}
|
|
549
|
+
return out ?? messages;
|
|
550
|
+
}
|
|
551
|
+
|
|
515
552
|
/**
|
|
516
553
|
* Message type for bash executions via the ! command.
|
|
517
554
|
*/
|
|
@@ -735,6 +735,7 @@ export class OutputSink {
|
|
|
735
735
|
#truncated = false;
|
|
736
736
|
#lastChunkTime = 0;
|
|
737
737
|
#pendingChunk = "";
|
|
738
|
+
#pendingChunkTimer: Timer | undefined;
|
|
738
739
|
|
|
739
740
|
// Per-line column cap streaming state (persists across `push` calls so a
|
|
740
741
|
// long line split across chunks still trips the same trigger).
|
|
@@ -806,20 +807,18 @@ export class OutputSink {
|
|
|
806
807
|
push(chunk: string): void {
|
|
807
808
|
chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
|
|
808
809
|
|
|
809
|
-
// Throttled onChunk: coalesce chunks arriving inside the throttle window
|
|
810
|
-
//
|
|
811
|
-
// final
|
|
810
|
+
// Throttled onChunk: coalesce chunks arriving inside the throttle window.
|
|
811
|
+
// A timer flushes quiet tails at the throttle boundary; dump() catches a
|
|
812
|
+
// final pending chunk when the process exits before that timer fires.
|
|
812
813
|
// Live preview gets the raw (pre-cap) chunk so the TUI never lags behind
|
|
813
814
|
// what reached the sink — the column cap is for the persisted LLM view.
|
|
814
815
|
if (this.#onChunk) {
|
|
815
816
|
const now = Date.now();
|
|
816
817
|
if (now - this.#lastChunkTime >= this.#chunkThrottleMs) {
|
|
817
|
-
this.#
|
|
818
|
-
const merged = this.#pendingChunk + chunk;
|
|
819
|
-
this.#pendingChunk = "";
|
|
820
|
-
this.#onChunk(merged);
|
|
818
|
+
this.#emitPendingChunkWith(chunk, now);
|
|
821
819
|
} else {
|
|
822
820
|
this.#pendingChunk += chunk;
|
|
821
|
+
this.#schedulePendingChunkFlush();
|
|
823
822
|
}
|
|
824
823
|
}
|
|
825
824
|
|
|
@@ -1121,6 +1120,7 @@ export class OutputSink {
|
|
|
1121
1120
|
* branch in `dump()` against stale totals.
|
|
1122
1121
|
*/
|
|
1123
1122
|
replace(text: string): void {
|
|
1123
|
+
this.#clearPendingChunkTimer();
|
|
1124
1124
|
this.#buffer = text;
|
|
1125
1125
|
this.#bufferBytes = Buffer.byteLength(text, "utf-8");
|
|
1126
1126
|
this.#head = "";
|
|
@@ -1138,6 +1138,38 @@ export class OutputSink {
|
|
|
1138
1138
|
this.#pendingChunk = "";
|
|
1139
1139
|
}
|
|
1140
1140
|
|
|
1141
|
+
#clearPendingChunkTimer(): void {
|
|
1142
|
+
if (!this.#pendingChunkTimer) return;
|
|
1143
|
+
clearTimeout(this.#pendingChunkTimer);
|
|
1144
|
+
this.#pendingChunkTimer = undefined;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
#emitPendingChunkWith(chunk: string, now: number): void {
|
|
1148
|
+
this.#clearPendingChunkTimer();
|
|
1149
|
+
this.#lastChunkTime = now;
|
|
1150
|
+
const merged = this.#pendingChunk + chunk;
|
|
1151
|
+
this.#pendingChunk = "";
|
|
1152
|
+
this.#onChunk?.(merged);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
#flushPendingChunk(): void {
|
|
1156
|
+
if (this.#pendingChunk.length === 0) {
|
|
1157
|
+
this.#clearPendingChunkTimer();
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
this.#emitPendingChunkWith("", Date.now());
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
#schedulePendingChunkFlush(): void {
|
|
1164
|
+
if (this.#chunkThrottleMs <= 0 || this.#pendingChunkTimer) return;
|
|
1165
|
+
const elapsed = Date.now() - this.#lastChunkTime;
|
|
1166
|
+
const delay = Math.max(0, this.#chunkThrottleMs - elapsed);
|
|
1167
|
+
this.#pendingChunkTimer = setTimeout(() => {
|
|
1168
|
+
this.#pendingChunkTimer = undefined;
|
|
1169
|
+
this.#flushPendingChunk();
|
|
1170
|
+
}, delay);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1141
1173
|
/**
|
|
1142
1174
|
* Replay the rolling tail ring back into the artifact sink. When bytes
|
|
1143
1175
|
* were actually dropped from the middle (the head budget was exhausted
|
|
@@ -1179,11 +1211,7 @@ export class OutputSink {
|
|
|
1179
1211
|
|
|
1180
1212
|
// Flush any chunk still held back by the throttle so the live preview
|
|
1181
1213
|
// ends with the complete stream.
|
|
1182
|
-
|
|
1183
|
-
const pending = this.#pendingChunk;
|
|
1184
|
-
this.#pendingChunk = "";
|
|
1185
|
-
this.#onChunk(pending);
|
|
1186
|
-
}
|
|
1214
|
+
this.#flushPendingChunk();
|
|
1187
1215
|
const totalLines = this.#sawData ? this.#totalLines + 1 : 0;
|
|
1188
1216
|
|
|
1189
1217
|
if (this.#file) {
|
|
@@ -9,6 +9,17 @@ function normalizeIdentityValue(value: unknown): string | undefined {
|
|
|
9
9
|
* True when a single usage-limit column belongs to the given OAuth identity.
|
|
10
10
|
*
|
|
11
11
|
* Single definition of the matching rules for both `/usage` renderers:
|
|
12
|
+
* - `orgId` ↔ report metadata `orgId` — a GATE that QUALIFIES the base
|
|
13
|
+
* identity, never a replacement for it. Mismatched org presence or
|
|
14
|
+
* different orgs never match: two subscriptions (orgs) can share one
|
|
15
|
+
* email, so an org-scoped identity matches only its own org's reports and
|
|
16
|
+
* an org-less legacy identity never claims an org-attributed report via
|
|
17
|
+
* the shared email. A SHARED org still requires the base-identity match
|
|
18
|
+
* below — Anthropic Team seats have per-user pools yet share the org id
|
|
19
|
+
* in report metadata. Only an org-only identity (no base identifiers
|
|
20
|
+
* recovered at all) matches on the org alone. When neither side carries
|
|
21
|
+
* an org, the base fallback applies unchanged (providers without orgs
|
|
22
|
+
* keep their former behavior).
|
|
12
23
|
* - `accountId` ↔ report metadata `accountId`/`account_id` or `limit.scope.accountId`
|
|
13
24
|
* - `email` ↔ report metadata `email`
|
|
14
25
|
* - `projectId` ↔ report metadata `projectId` or `limit.scope.projectId`
|
|
@@ -22,14 +33,23 @@ export function limitMatchesActiveAccount(
|
|
|
22
33
|
if (!identity) return false;
|
|
23
34
|
const metadata = report.metadata ?? {};
|
|
24
35
|
const activeAccountId = normalizeIdentityValue(identity.accountId);
|
|
36
|
+
const activeEmail = normalizeIdentityValue(identity.email);
|
|
37
|
+
const activeProjectId = normalizeIdentityValue(identity.projectId);
|
|
38
|
+
const activeOrgId = normalizeIdentityValue(identity.orgId);
|
|
39
|
+
const reportOrgId = normalizeIdentityValue(metadata.orgId);
|
|
40
|
+
// Org gate (see doc comment above): different/mismatched-presence orgs
|
|
41
|
+
// never match; a shared org falls through to the base checks unless the
|
|
42
|
+
// identity is org-only.
|
|
43
|
+
if (activeOrgId || reportOrgId) {
|
|
44
|
+
if (activeOrgId !== reportOrgId) return false;
|
|
45
|
+
if (!activeAccountId && !activeEmail && !activeProjectId) return true;
|
|
46
|
+
}
|
|
25
47
|
if (activeAccountId) {
|
|
26
48
|
const reportAccountId = normalizeIdentityValue(metadata.accountId) ?? normalizeIdentityValue(metadata.account_id);
|
|
27
49
|
if (reportAccountId === activeAccountId) return true;
|
|
28
50
|
if (normalizeIdentityValue(limit.scope.accountId) === activeAccountId) return true;
|
|
29
51
|
}
|
|
30
|
-
const activeEmail = normalizeIdentityValue(identity.email);
|
|
31
52
|
if (activeEmail && normalizeIdentityValue(metadata.email) === activeEmail) return true;
|
|
32
|
-
const activeProjectId = normalizeIdentityValue(identity.projectId);
|
|
33
53
|
if (activeProjectId) {
|
|
34
54
|
if (normalizeIdentityValue(metadata.projectId) === activeProjectId) return true;
|
|
35
55
|
if (normalizeIdentityValue(limit.scope.projectId) === activeProjectId) return true;
|
|
@@ -22,13 +22,16 @@ function nonEmpty(value: string | undefined): string | undefined {
|
|
|
22
22
|
function oauthLabel(row: StoredAuthCredential): string {
|
|
23
23
|
const credential = row.credential;
|
|
24
24
|
if (credential.type !== "oauth") return `API key #${row.id}`;
|
|
25
|
-
|
|
25
|
+
const base =
|
|
26
26
|
nonEmpty(credential.email) ??
|
|
27
27
|
nonEmpty(credential.accountId) ??
|
|
28
28
|
nonEmpty(credential.projectId) ??
|
|
29
29
|
nonEmpty(credential.enterpriseUrl) ??
|
|
30
|
-
`OAuth credential #${row.id}
|
|
31
|
-
)
|
|
30
|
+
`OAuth credential #${row.id}`;
|
|
31
|
+
// Two subscriptions (orgs) can share one email — the org is the only
|
|
32
|
+
// user-visible way to tell which row a logout will remove.
|
|
33
|
+
const org = nonEmpty(credential.orgName) ?? nonEmpty(credential.orgId);
|
|
34
|
+
return org && org !== base ? `${base} (${org})` : base;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
function oauthDetail(row: StoredAuthCredential, label: string): string {
|
|
@@ -53,6 +56,23 @@ function oauthMatchesActiveIdentity(
|
|
|
53
56
|
): boolean {
|
|
54
57
|
if (!activeIdentity || row.credential.type !== "oauth") return false;
|
|
55
58
|
const credential = row.credential;
|
|
59
|
+
// The org GATES the base identity rather than replacing it: mismatched org
|
|
60
|
+
// presence or different orgs never match — an org-scoped active session
|
|
61
|
+
// must not preselect the bare-email legacy row, and a bare-email active
|
|
62
|
+
// row must not mark org-scoped siblings active via the shared email. A
|
|
63
|
+
// SHARED org still requires the base-identity match below: two Team seats
|
|
64
|
+
// share one orgId yet own distinct rows. Only an org-only active identity
|
|
65
|
+
// (no base identifiers recovered at all) matches on the org alone.
|
|
66
|
+
if (activeIdentity.orgId !== undefined || credential.orgId !== undefined) {
|
|
67
|
+
if (credential.orgId !== activeIdentity.orgId) return false;
|
|
68
|
+
if (
|
|
69
|
+
activeIdentity.accountId === undefined &&
|
|
70
|
+
activeIdentity.email === undefined &&
|
|
71
|
+
activeIdentity.projectId === undefined
|
|
72
|
+
) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
56
76
|
return (
|
|
57
77
|
(activeIdentity.accountId !== undefined && credential.accountId === activeIdentity.accountId) ||
|
|
58
78
|
(activeIdentity.email !== undefined && credential.email === activeIdentity.email) ||
|
|
@@ -25,14 +25,26 @@ function formatUsageAmount(limit: UsageLimit): string {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
function formatUsageReportAccount(report: UsageReport, limit: UsageLimit, index: number): string {
|
|
28
|
+
const metaOrgName = report.metadata?.orgName;
|
|
29
|
+
const metaOrgId = report.metadata?.orgId;
|
|
30
|
+
const org =
|
|
31
|
+
typeof metaOrgName === "string" && metaOrgName
|
|
32
|
+
? metaOrgName
|
|
33
|
+
: typeof metaOrgId === "string" && metaOrgId
|
|
34
|
+
? metaOrgId
|
|
35
|
+
: undefined;
|
|
36
|
+
// Two subscriptions (orgs) can share one email — suffix the org so the rows
|
|
37
|
+
// are tellable apart.
|
|
28
38
|
const email = report.metadata?.email;
|
|
29
|
-
if (typeof email === "string" && email) return email;
|
|
39
|
+
if (typeof email === "string" && email) return org ? `${email} (${org})` : email;
|
|
30
40
|
// Guard metadata values for truthiness before using, then fall back to scope.
|
|
31
41
|
// ?? won't help here: empty string is not null/undefined, so it would suppress
|
|
32
42
|
// a valid scoped fallback (e.g. metadata.accountId="" hides limit.scope.accountId).
|
|
33
43
|
const metaAccountId = report.metadata?.accountId;
|
|
34
44
|
const accountId = typeof metaAccountId === "string" && metaAccountId ? metaAccountId : limit.scope.accountId;
|
|
35
|
-
if (typeof accountId === "string" && accountId)
|
|
45
|
+
if (typeof accountId === "string" && accountId) {
|
|
46
|
+
return org && org !== accountId ? `${accountId} (${org})` : accountId;
|
|
47
|
+
}
|
|
36
48
|
const metaProjectId = report.metadata?.projectId;
|
|
37
49
|
const projectId = typeof metaProjectId === "string" && metaProjectId ? metaProjectId : limit.scope.projectId;
|
|
38
50
|
if (typeof projectId === "string" && projectId) return projectId;
|
|
@@ -159,6 +159,12 @@ export function createWorkerSubprocess<Outbound>(options: {
|
|
|
159
159
|
spawnCommand: WorkerSpawnCommand;
|
|
160
160
|
env: Record<string, string>;
|
|
161
161
|
exitLabel: string;
|
|
162
|
+
/** Start the child as a new process-group/session leader where Bun supports it. */
|
|
163
|
+
detached?: boolean;
|
|
164
|
+
/** Treat exit code 0 as unexpected; eval cells can call process.exit(0). */
|
|
165
|
+
reportCleanExit?: boolean;
|
|
166
|
+
/** Whether an idle worker should stop keeping the parent event loop alive. */
|
|
167
|
+
unref?: boolean;
|
|
162
168
|
}): SpawnedSubprocess<Outbound> {
|
|
163
169
|
const inbound = new Set<(message: Outbound) => void>();
|
|
164
170
|
const errors = new Set<(error: Error) => void>();
|
|
@@ -175,6 +181,7 @@ export function createWorkerSubprocess<Outbound>(options: {
|
|
|
175
181
|
const proc = Bun.spawn({
|
|
176
182
|
cmd: options.spawnCommand.cmd,
|
|
177
183
|
cwd: options.spawnCommand.cwd,
|
|
184
|
+
detached: options.detached,
|
|
178
185
|
env: options.env,
|
|
179
186
|
stdin: "ignore",
|
|
180
187
|
stdout: "ignore",
|
|
@@ -186,7 +193,7 @@ export function createWorkerSubprocess<Outbound>(options: {
|
|
|
186
193
|
},
|
|
187
194
|
onExit(_proc, exitCode, signalCode) {
|
|
188
195
|
startStderrDrain();
|
|
189
|
-
if (exitCode === 0) return;
|
|
196
|
+
if (exitCode === 0 && !options.reportCleanExit) return;
|
|
190
197
|
// Swallow only the expected SIGKILL from `terminate()`; every other
|
|
191
198
|
// signal exit (SIGSEGV from a native fault, OOM SIGKILL, operator
|
|
192
199
|
// `kill -9`) is a real worker death that must fault in-flight
|
|
@@ -206,7 +213,7 @@ export function createWorkerSubprocess<Outbound>(options: {
|
|
|
206
213
|
// Don't keep the parent event loop alive on an idle worker; the dispose
|
|
207
214
|
// path calls `terminate()` explicitly. Bun's test runner starves IPC for
|
|
208
215
|
// unref'd subprocesses, so keep it referenced only under tests.
|
|
209
|
-
if (!isBunTestRuntime()) proc.unref();
|
|
216
|
+
if (!isBunTestRuntime() && options.unref !== false) proc.unref();
|
|
210
217
|
return { proc, inbound, errors, intentionalExit, stderrDrained: stderrDrained.promise };
|
|
211
218
|
}
|
|
212
219
|
|
|
@@ -198,3 +198,39 @@ describe.skipIf(process.platform !== "linux")("system prompt CPU model", () => {
|
|
|
198
198
|
}
|
|
199
199
|
});
|
|
200
200
|
});
|
|
201
|
+
|
|
202
|
+
describe("non-Linux system prompt CPU model", () => {
|
|
203
|
+
it("includes the model returned by os.cpus", async () => {
|
|
204
|
+
const originalPlatform = process.platform;
|
|
205
|
+
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
206
|
+
const cpus = spyOn(os, "cpus").mockImplementation(() => [
|
|
207
|
+
{
|
|
208
|
+
model: "Synthetic Non-Linux CPU",
|
|
209
|
+
speed: 0,
|
|
210
|
+
times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 },
|
|
211
|
+
},
|
|
212
|
+
]);
|
|
213
|
+
try {
|
|
214
|
+
const systemPrompt = await buildSystemPrompt({
|
|
215
|
+
resolvedCustomPrompt: "Base prompt",
|
|
216
|
+
contextFiles: [],
|
|
217
|
+
skills: [],
|
|
218
|
+
rules: [],
|
|
219
|
+
workspaceTree: {
|
|
220
|
+
rootPath: import.meta.dir,
|
|
221
|
+
rendered: "",
|
|
222
|
+
truncated: false,
|
|
223
|
+
totalLines: 0,
|
|
224
|
+
agentsMdFiles: [],
|
|
225
|
+
},
|
|
226
|
+
activeRepoContext: null,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(cpus).toHaveBeenCalledTimes(1);
|
|
230
|
+
expect(systemPrompt.systemPrompt.join("\n")).toContain("- CPU: Synthetic Non-Linux CPU");
|
|
231
|
+
} finally {
|
|
232
|
+
cpus.mockRestore();
|
|
233
|
+
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
});
|
package/src/system-prompt.ts
CHANGED
|
@@ -254,7 +254,7 @@ async function getCachedGpu(): Promise<string | undefined> {
|
|
|
254
254
|
}
|
|
255
255
|
|
|
256
256
|
async function getCpuModel(): Promise<string | undefined> {
|
|
257
|
-
if (process.platform !== "linux") return
|
|
257
|
+
if (process.platform !== "linux") return os.cpus()[0]?.model;
|
|
258
258
|
try {
|
|
259
259
|
const cpuInfo = await Bun.file("/proc/cpuinfo").text();
|
|
260
260
|
const match = /^model name\s*:\s*(.+)$/m.exec(cpuInfo);
|
package/src/task/executor.ts
CHANGED
|
@@ -2325,14 +2325,22 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
2325
2325
|
thinkingLevel: resolvedThinkingLevel,
|
|
2326
2326
|
explicitThinkingLevel,
|
|
2327
2327
|
authFallbackUsed,
|
|
2328
|
+
warning: modelResolutionWarning,
|
|
2328
2329
|
} = await awaitAbortable(
|
|
2329
2330
|
resolveModelOverrideWithAuthFallback(
|
|
2330
2331
|
modelPatterns,
|
|
2331
2332
|
options.parentActiveModelPattern,
|
|
2332
2333
|
modelRegistry,
|
|
2333
2334
|
settings,
|
|
2335
|
+
id,
|
|
2334
2336
|
),
|
|
2335
2337
|
);
|
|
2338
|
+
if (modelResolutionWarning) {
|
|
2339
|
+
logger.warn("Subagent model resolution warning", {
|
|
2340
|
+
warning: modelResolutionWarning,
|
|
2341
|
+
requested: modelPatterns,
|
|
2342
|
+
});
|
|
2343
|
+
}
|
|
2336
2344
|
if (authFallbackUsed && model) {
|
|
2337
2345
|
logger.warn("Subagent model has no working credentials; falling back to parent session model", {
|
|
2338
2346
|
requested: modelPatterns,
|
package/src/task/render.test.ts
CHANGED
|
@@ -184,6 +184,42 @@ describe("task live progress rendering", () => {
|
|
|
184
184
|
expect(collapsedText).not.toContain("line 1");
|
|
185
185
|
expect(collapsedText).not.toContain("raw output:");
|
|
186
186
|
});
|
|
187
|
+
it("sanitizes control sequences from expanded subagent recent output", () => {
|
|
188
|
+
setViewportRows(40);
|
|
189
|
+
const progress = makeProgress(["safe after \x1b[2Kclear", "raw\rprompt"]);
|
|
190
|
+
|
|
191
|
+
const text = renderProgressText(progress, true, uiTheme);
|
|
192
|
+
|
|
193
|
+
expect(text).toContain("safe after clear");
|
|
194
|
+
expect(text).toContain("rawprompt");
|
|
195
|
+
expect(text).not.toContain("\x1b[2K");
|
|
196
|
+
expect(text).not.toContain("\r");
|
|
197
|
+
});
|
|
198
|
+
it("sanitizes control sequences from finalized subagent results", () => {
|
|
199
|
+
const output = JSON.stringify({ "\x1b[2Kkey": "safe value" });
|
|
200
|
+
const details: TaskToolDetails = {
|
|
201
|
+
projectAgentsDir: null,
|
|
202
|
+
results: [
|
|
203
|
+
makeSingleResult(0, {
|
|
204
|
+
id: "Final\x1b[2KAgent",
|
|
205
|
+
description: "description\rtext",
|
|
206
|
+
aborted: true,
|
|
207
|
+
abortReason: "aborted \x1b[2Kreason",
|
|
208
|
+
output,
|
|
209
|
+
}),
|
|
210
|
+
],
|
|
211
|
+
totalDurationMs: 1,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const text = renderResultText(details, true, uiTheme);
|
|
215
|
+
|
|
216
|
+
expect(text).toContain("FinalAgent");
|
|
217
|
+
expect(text).toContain("descriptiontext");
|
|
218
|
+
expect(text).toContain("aborted reason");
|
|
219
|
+
expect(text).toContain("key");
|
|
220
|
+
expect(text).not.toContain("\x1b[2K");
|
|
221
|
+
expect(text).not.toContain("\r");
|
|
222
|
+
});
|
|
187
223
|
|
|
188
224
|
it("caps collapsed nested task progress at four rows plus an elision line", () => {
|
|
189
225
|
setViewportRows(40);
|