@selesai/code 0.13.12 → 0.13.14
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 +9 -0
- package/dist/extensions/pi-subagents/CHANGELOG.md +77 -1
- package/dist/extensions/pi-subagents/VISION.md +12 -1
- package/dist/extensions/pi-subagents/docs/agents.md +13 -6
- package/dist/extensions/pi-subagents/docs/configuration.md +35 -9
- package/dist/extensions/pi-subagents/docs/extension-api.md +1 -1
- package/dist/extensions/pi-subagents/docs/missions.md +1 -1
- package/dist/extensions/pi-subagents/docs/models.md +6 -6
- package/dist/extensions/pi-subagents/docs/observability.md +6 -3
- package/dist/extensions/pi-subagents/docs/tool-reference.md +3 -3
- package/dist/extensions/pi-subagents/docs/watchdog.md +92 -114
- package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
- package/dist/extensions/pi-subagents/install.mjs +1 -2
- package/dist/extensions/pi-subagents/package.json +1 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +6 -6
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/multi-lane-orchestration.md +1 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/prompting-and-roles.md +4 -4
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/review-and-validation.md +1 -1
- package/dist/extensions/pi-subagents/src/agents/agent-management.ts +41 -4
- package/dist/extensions/pi-subagents/src/agents/agent-serializer.ts +3 -0
- package/dist/extensions/pi-subagents/src/agents/agents.ts +120 -124
- package/dist/extensions/pi-subagents/src/agents/runtime-agent-registry.ts +5 -1
- package/dist/extensions/pi-subagents/src/api/preflight.ts +4 -0
- package/dist/extensions/pi-subagents/src/api/shared-types.ts +3 -0
- package/dist/extensions/pi-subagents/src/extension/config.ts +20 -0
- package/dist/extensions/pi-subagents/src/extension/public-execution.ts +1 -0
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +6 -2
- package/dist/extensions/pi-subagents/src/extension/tool-description.ts +1 -1
- package/dist/extensions/pi-subagents/src/inspectors/herdr/inspector-runner.ts +19 -13
- package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +26 -8
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +103 -23
- package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +6 -2
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +18 -2
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +54 -3
- package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +16 -0
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +22 -2
- package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +63 -6
- package/dist/extensions/pi-subagents/src/runs/background/steering.ts +4 -1
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +69 -12
- package/dist/extensions/pi-subagents/src/runs/background/wait-completions.ts +13 -0
- package/dist/extensions/pi-subagents/src/runs/background/wait-tool.ts +3 -1
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +21 -8
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +59 -6
- package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +95 -18
- package/dist/extensions/pi-subagents/src/runs/shared/async-status-projection.ts +11 -43
- package/dist/extensions/pi-subagents/src/runs/shared/capability-ceiling.ts +1 -0
- package/dist/extensions/pi-subagents/src/runs/shared/dynamic-fanout.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/shared/lane-metadata.ts +24 -3
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-handoff.ts +4 -0
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +2 -6
- package/dist/extensions/pi-subagents/src/runs/shared/permissions.ts +1 -0
- package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +33 -18
- package/dist/extensions/pi-subagents/src/runs/shared/pi-spawn.ts +73 -37
- package/dist/extensions/pi-subagents/src/runs/shared/structured-output.ts +33 -6
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-control.ts +4 -2
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +20 -3
- package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +21 -7
- package/dist/extensions/pi-subagents/src/runs/shared/tool-timeout.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +467 -63
- package/dist/extensions/pi-subagents/src/shared/atomic-json.ts +3 -1
- package/dist/extensions/pi-subagents/src/shared/fork-context.ts +0 -12
- package/dist/extensions/pi-subagents/src/shared/fork-session-cwd.ts +27 -0
- package/dist/extensions/pi-subagents/src/shared/launch-contract.ts +3 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +42 -4
- package/dist/extensions/pi-subagents/src/shared/utils.ts +18 -7
- package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +1 -1
- package/dist/extensions/pi-subagents/src/slash/subagents-admin.ts +26 -12
- package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +61 -2
- package/dist/extensions/pi-subagents/src/tui/fleet.ts +12 -7
- package/dist/extensions/pi-subagents/src/tui/render.ts +227 -14
- package/dist/extensions/pi-subagents/src/watchdog/child-status.ts +56 -34
- package/dist/extensions/pi-subagents/src/watchdog/diff-tool.ts +77 -0
- package/dist/extensions/pi-subagents/src/watchdog/emission-guard.ts +5 -3
- package/dist/extensions/pi-subagents/src/watchdog/guidance.ts +20 -0
- package/dist/extensions/pi-subagents/src/watchdog/register-child.ts +16 -13
- package/dist/extensions/pi-subagents/src/watchdog/register-main.ts +10 -9
- package/dist/extensions/pi-subagents/src/watchdog/render.ts +4 -5
- package/dist/extensions/pi-subagents/src/watchdog/review.ts +15 -4
- package/dist/extensions/pi-subagents/src/watchdog/rules.ts +70 -0
- package/dist/extensions/pi-subagents/src/watchdog/runtime.ts +75 -92
- package/dist/extensions/pi-subagents/src/watchdog/scope.ts +2 -1
- package/dist/extensions/pi-subagents/src/watchdog/settings.ts +48 -104
- package/dist/extensions/pi-subagents/src/watchdog/types.ts +18 -32
- package/dist/extensions/pi-subagents/src/watchdog/warning-format.ts +0 -1
- package/dist/extensions/pi-subagents/src/workflows/chat-progress.ts +3 -2
- package/dist/extensions/pi-subagents/src/workflows/workflow-checklist.ts +439 -0
- package/dist/extensions/pi-subagents/src/workflows/workflow-preflight.ts +28 -1
- package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +335 -21
- package/dist/extensions/pi-subagents/test/integration/async-job-tracker.test.ts +4 -1
- package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +22 -18
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +0 -1
- package/dist/extensions/pi-subagents/test/integration/render-widget.test.ts +21 -18
- package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +307 -22
- package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +1 -0
- package/dist/extensions/pi-subagents/test/support/helpers.ts +25 -0
- package/dist/extensions/pi-subagents/test/support/isolated-temp-root.mjs +15 -4
- package/dist/extensions/pi-subagents/test/support/mock-pi-script.mjs +17 -2
- package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +96 -15
- package/dist/extensions/pi-subagents/test/unit/active-async-capacity.test.ts +53 -5
- package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +20 -0
- package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +221 -8
- package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +45 -24
- package/dist/extensions/pi-subagents/test/unit/agent-scan-dirs.test.ts +133 -0
- package/dist/extensions/pi-subagents/test/unit/async-execution.test.ts +28 -2
- package/dist/extensions/pi-subagents/test/unit/async-interrupt-action.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +2 -0
- package/dist/extensions/pi-subagents/test/unit/async-retention.test.ts +4 -1
- package/dist/extensions/pi-subagents/test/unit/async-status-projection.test.ts +18 -4
- package/dist/extensions/pi-subagents/test/unit/atomic-json.test.ts +16 -0
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +38 -0
- package/dist/extensions/pi-subagents/test/unit/default-extensions.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +21 -0
- package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +19 -6
- package/dist/extensions/pi-subagents/test/unit/get-final-output.test.ts +26 -0
- package/dist/extensions/pi-subagents/test/unit/herdr-inspector.test.ts +22 -1
- package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +2 -6
- package/dist/extensions/pi-subagents/test/unit/notify.test.ts +66 -1
- package/dist/extensions/pi-subagents/test/unit/pi-args-permission-system.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +116 -10
- package/dist/extensions/pi-subagents/test/unit/pi-spawn.test.ts +136 -18
- package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +20 -0
- package/dist/extensions/pi-subagents/test/unit/process-terminal.test.ts +22 -0
- package/dist/extensions/pi-subagents/test/unit/profiles.test.ts +1 -0
- package/dist/extensions/pi-subagents/test/unit/project-panes-public-api.test.ts +8 -0
- package/dist/extensions/pi-subagents/test/unit/render-helpers.test.ts +127 -48
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +68 -0
- package/dist/extensions/pi-subagents/test/unit/runtime-agent-registration.test.ts +17 -0
- package/dist/extensions/pi-subagents/test/unit/scheduled-runs.test.ts +88 -0
- package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +4 -2
- package/dist/extensions/pi-subagents/test/unit/scripted-workflow.test.ts +9 -9
- package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/unit/steering.test.ts +9 -3
- package/dist/extensions/pi-subagents/test/unit/subagent-control.test.ts +22 -9
- package/dist/extensions/pi-subagents/test/unit/subagent-prompt-runtime.test.ts +91 -8
- package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +27 -0
- package/dist/extensions/pi-subagents/test/unit/temp-paths.test.ts +50 -0
- package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +0 -1
- package/dist/extensions/pi-subagents/test/unit/tool-timeout.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/unit/wait-completions.test.ts +34 -0
- package/dist/extensions/pi-subagents/test/unit/wait-subscriptions.test.ts +1 -2
- package/dist/extensions/pi-subagents/test/unit/watchdog-child-status.test.ts +73 -13
- package/dist/extensions/pi-subagents/test/unit/watchdog-diff-tool.test.ts +79 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-permission-arbiter.test.ts +2 -4
- package/dist/extensions/pi-subagents/test/unit/watchdog-render.test.ts +7 -8
- package/dist/extensions/pi-subagents/test/unit/watchdog-review.test.ts +48 -3
- package/dist/extensions/pi-subagents/test/unit/watchdog-rules.test.ts +37 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-runtime.test.ts +36 -118
- package/dist/extensions/pi-subagents/test/unit/watchdog-scope.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/watchdog-settings.test.ts +37 -14
- package/dist/extensions/pi-subagents/test/unit/workflow-chat-progress.test.ts +45 -26
- package/dist/extensions/pi-subagents/test/unit/workflow-checklist.test.ts +223 -0
- package/dist/extensions/pi-subagents/test/unit/workflow-launch-params.test.ts +50 -1
- package/dist/extensions/pi-subagents/test/unit/workflow-preflight.test.ts +22 -0
- package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +136 -4
- package/dist/extensions/pi-zentui/README.md +19 -5
- package/dist/extensions/pi-zentui/docs/configuration.md +37 -9
- package/dist/extensions/pi-zentui/extensions/zentui/config.ts +29 -0
- package/dist/extensions/pi-zentui/extensions/zentui/index.ts +31 -2
- package/dist/extensions/pi-zentui/extensions/zentui/prototype-patch-registry.ts +55 -11
- package/dist/extensions/pi-zentui/extensions/zentui/settings-command.ts +115 -3
- package/dist/extensions/pi-zentui/extensions/zentui/settings-previews.ts +119 -2
- package/dist/extensions/pi-zentui/extensions/zentui/thinking-experimental.ts +1768 -0
- package/dist/extensions/pi-zentui/extensions/zentui/thinking-status.ts +69 -0
- package/dist/extensions/pi-zentui/extensions/zentui/thinking-steps.ts +155 -0
- package/dist/extensions/pi-zentui/extensions/zentui/ui.ts +2 -1
- package/dist/extensions/pi-zentui/extensions/zentui/user-message-osc.ts +16 -2
- package/dist/extensions/pi-zentui/extensions/zentui/user-message-styles.ts +1 -1
- package/dist/extensions/pi-zentui/test/config-load-lifecycle.test.ts +124 -0
- package/dist/extensions/pi-zentui/test/config.test.ts +82 -2
- package/dist/extensions/pi-zentui/test/editor-viewport-indicators.test.ts +19 -4
- package/dist/extensions/pi-zentui/test/extension-compliance.test.ts +58 -9
- package/dist/extensions/pi-zentui/test/package-contents.mjs +33 -0
- package/dist/extensions/pi-zentui/test/prototype-patch-registry.test.ts +131 -0
- package/dist/extensions/pi-zentui/test/responsive-dependencies.test.ts +4 -4
- package/dist/extensions/pi-zentui/test/settings-command.test.ts +289 -7
- package/dist/extensions/pi-zentui/test/settings-previews.test.ts +106 -0
- package/dist/extensions/pi-zentui/test/thinking-experimental-docs.test.ts +51 -0
- package/dist/extensions/pi-zentui/test/thinking-experimental-loader-diagnostics.mjs +54 -0
- package/dist/extensions/pi-zentui/test/thinking-experimental-missing-export.test.ts +44 -0
- package/dist/extensions/pi-zentui/test/thinking-experimental-rows.test.ts +297 -0
- package/dist/extensions/pi-zentui/test/thinking-experimental-tui.mjs +2329 -0
- package/dist/extensions/pi-zentui/test/thinking-experimental.test.ts +1668 -0
- package/dist/extensions/pi-zentui/test/thinking-status.test.ts +85 -0
- package/dist/extensions/pi-zentui/test/thinking-steps.test.ts +122 -0
- package/dist/extensions/pi-zentui/test/user-message-osc.test.ts +58 -0
- package/dist/skills/brandkit/SKILL.md +0 -2
- package/dist/skills/{industrial-brutalist-ui → brutalist}/SKILL.md +1 -3
- package/dist/skills/gpt-taste/SKILL.md +0 -2
- package/dist/skills/image-to-code/SKILL.md +0 -2
- package/dist/skills/imagegen-frontend-mobile/SKILL.md +0 -2
- package/dist/skills/imagegen-frontend-web/SKILL.md +0 -2
- package/dist/skills/{minimalist-ui → minimalist}/SKILL.md +1 -3
- package/dist/skills/{full-output-enforcement → output}/SKILL.md +1 -3
- package/dist/skills/{redesign-existing-projects → redesign}/SKILL.md +1 -3
- package/dist/skills/{high-end-visual-design → soft}/SKILL.md +1 -3
- package/dist/skills/stitch/DESIGN.md +121 -0
- package/dist/skills/{stitch-design-taste → stitch}/SKILL.md +1 -3
- package/dist/skills/{design-taste-frontend → taste}/SKILL.md +1 -3
- package/dist/skills/{design-taste-frontend-v1 → taste-v1}/SKILL.md +2 -4
- package/package.json +1 -1
|
@@ -0,0 +1,1668 @@
|
|
|
1
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
import {
|
|
3
|
+
AssistantMessageComponent,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
initTheme,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { type Component, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui";
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import type { ThinkingStepsComponentConfig } from "../extensions/zentui/config";
|
|
10
|
+
import { ZENTUI_PROTOTYPE_PATCH_REGISTRY } from "../extensions/zentui/prototype-patch-registry";
|
|
11
|
+
import {
|
|
12
|
+
THINKING_EXPERIMENTAL_MAX_TRACKED_COMPONENTS,
|
|
13
|
+
ThinkingExperimentalController,
|
|
14
|
+
} from "../extensions/zentui/thinking-experimental";
|
|
15
|
+
|
|
16
|
+
initTheme("dark", false);
|
|
17
|
+
|
|
18
|
+
const prototype = AssistantMessageComponent.prototype;
|
|
19
|
+
const originalDescriptor = Object.getOwnPropertyDescriptor(prototype, "updateContent");
|
|
20
|
+
const identity = (text: string) => text;
|
|
21
|
+
const markdownTheme = Object.fromEntries(
|
|
22
|
+
[
|
|
23
|
+
"heading",
|
|
24
|
+
"link",
|
|
25
|
+
"linkUrl",
|
|
26
|
+
"code",
|
|
27
|
+
"codeBlock",
|
|
28
|
+
"codeBlockBorder",
|
|
29
|
+
"quote",
|
|
30
|
+
"quoteBorder",
|
|
31
|
+
"hr",
|
|
32
|
+
"listBullet",
|
|
33
|
+
"bold",
|
|
34
|
+
"italic",
|
|
35
|
+
"strikethrough",
|
|
36
|
+
"underline",
|
|
37
|
+
].map((key) => [key, identity]),
|
|
38
|
+
) as unknown as MarkdownTheme;
|
|
39
|
+
|
|
40
|
+
function message(thinking: string, timestamp = 1_000, answer?: string): AssistantMessage {
|
|
41
|
+
return messageWithContent(
|
|
42
|
+
[{ type: "thinking", thinking }, ...(answer ? [{ type: "text" as const, text: answer }] : [])],
|
|
43
|
+
timestamp,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function messageWithContent(
|
|
48
|
+
content: AssistantMessage["content"],
|
|
49
|
+
timestamp = 1_000,
|
|
50
|
+
): AssistantMessage {
|
|
51
|
+
return {
|
|
52
|
+
role: "assistant",
|
|
53
|
+
content,
|
|
54
|
+
api: "test",
|
|
55
|
+
provider: "test",
|
|
56
|
+
model: "test",
|
|
57
|
+
usage: {
|
|
58
|
+
input: 0,
|
|
59
|
+
output: 0,
|
|
60
|
+
cacheRead: 0,
|
|
61
|
+
cacheWrite: 0,
|
|
62
|
+
totalTokens: 0,
|
|
63
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
64
|
+
},
|
|
65
|
+
stopReason: "stop",
|
|
66
|
+
timestamp,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function plain(lines: string[]): string[] {
|
|
71
|
+
return lines.map((line) =>
|
|
72
|
+
line
|
|
73
|
+
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
|
|
74
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
75
|
+
.trimEnd(),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function context() {
|
|
80
|
+
let inputHandler: ((data: string) => { consume?: boolean } | undefined) | undefined;
|
|
81
|
+
let inputRegistrations = 0;
|
|
82
|
+
const stopInput = vi.fn();
|
|
83
|
+
const forbidden = {
|
|
84
|
+
setWorkingMessage: vi.fn(),
|
|
85
|
+
setWorkingVisible: vi.fn(),
|
|
86
|
+
setWorkingIndicator: vi.fn(),
|
|
87
|
+
setFooter: vi.fn(),
|
|
88
|
+
setStatus: vi.fn(),
|
|
89
|
+
setEditorComponent: vi.fn(),
|
|
90
|
+
setHiddenThinkingLabel: vi.fn(),
|
|
91
|
+
};
|
|
92
|
+
const ctx = {
|
|
93
|
+
mode: "tui",
|
|
94
|
+
hasUI: true,
|
|
95
|
+
ui: {
|
|
96
|
+
...forbidden,
|
|
97
|
+
theme: { fg: (_color: string, text: string) => text },
|
|
98
|
+
onTerminalInput(handler: typeof inputHandler) {
|
|
99
|
+
inputRegistrations += 1;
|
|
100
|
+
inputHandler = handler;
|
|
101
|
+
return stopInput;
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
sessionManager: { getEntries: () => [] },
|
|
105
|
+
} as unknown as ExtensionContext;
|
|
106
|
+
return {
|
|
107
|
+
ctx,
|
|
108
|
+
forbidden,
|
|
109
|
+
input: (data: string) => inputHandler?.(data),
|
|
110
|
+
inputRegistrations: () => inputRegistrations,
|
|
111
|
+
stopInput,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function component(hideThinkingBlock = false): AssistantMessageComponent {
|
|
116
|
+
return new AssistantMessageComponent(
|
|
117
|
+
undefined,
|
|
118
|
+
hideThinkingBlock,
|
|
119
|
+
markdownTheme,
|
|
120
|
+
"Thinking...",
|
|
121
|
+
1,
|
|
122
|
+
[],
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// npm keeps Pi's own Pi-TUI package nested, while Pi's source loader deliberately
|
|
127
|
+
// supplies one virtual-module identity. Bridge only the unit host to model that loader.
|
|
128
|
+
function bridgeSourceLoadedMarkdownIdentity(): void {
|
|
129
|
+
const native = originalDescriptor?.value as (this: unknown, ...args: unknown[]) => unknown;
|
|
130
|
+
const constructors = new Map<string, object>([
|
|
131
|
+
["Markdown", Markdown.prototype],
|
|
132
|
+
["Spacer", Spacer.prototype],
|
|
133
|
+
["Text", Text.prototype],
|
|
134
|
+
]);
|
|
135
|
+
Object.defineProperty(prototype, "updateContent", {
|
|
136
|
+
...originalDescriptor,
|
|
137
|
+
value: function sourceLoadedIdentityBridge(this: unknown, ...args: unknown[]) {
|
|
138
|
+
const result = Reflect.apply(native, this, args);
|
|
139
|
+
const children = (this as { contentContainer?: { children?: object[] } }).contentContainer
|
|
140
|
+
?.children;
|
|
141
|
+
for (const child of children ?? []) {
|
|
142
|
+
const expected = constructors.get(child.constructor.name);
|
|
143
|
+
if (expected && Object.getPrototypeOf(child) !== expected)
|
|
144
|
+
Object.setPrototypeOf(child, expected);
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function installLegacyThinkingRenderer(
|
|
152
|
+
afterNative?: (container: { children: Component[] }, children: Component[]) => void,
|
|
153
|
+
): void {
|
|
154
|
+
Object.defineProperty(prototype, "updateContent", {
|
|
155
|
+
...originalDescriptor,
|
|
156
|
+
value: function legacyThinkingChildren(
|
|
157
|
+
this: { contentContainer?: { children: Component[] } },
|
|
158
|
+
value: AssistantMessage,
|
|
159
|
+
) {
|
|
160
|
+
const children: Component[] = [new Spacer(1)];
|
|
161
|
+
for (const [index, part] of value.content.entries()) {
|
|
162
|
+
if (part.type === "thinking" && part.thinking.trim()) {
|
|
163
|
+
children.push(
|
|
164
|
+
new Markdown(part.thinking.trim(), 1, 0, markdownTheme, {
|
|
165
|
+
color: identity,
|
|
166
|
+
italic: true,
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
if (value.content.slice(index + 1).some((next) => next.type !== "toolCall")) {
|
|
170
|
+
children.push(new Spacer(1));
|
|
171
|
+
}
|
|
172
|
+
} else if (part.type === "text" && part.text.trim()) {
|
|
173
|
+
children.push(new Markdown(part.text.trim(), 1, 0, markdownTheme));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const container = this.contentContainer ?? { children: [] };
|
|
177
|
+
container.children = children;
|
|
178
|
+
this.contentContainer = container;
|
|
179
|
+
afterNative?.(container, children);
|
|
180
|
+
return value;
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const controllers = new Set<ThinkingExperimentalController>();
|
|
186
|
+
|
|
187
|
+
afterEach(() => {
|
|
188
|
+
for (const controller of controllers) controller.shutdown();
|
|
189
|
+
controllers.clear();
|
|
190
|
+
if (originalDescriptor) Object.defineProperty(prototype, "updateContent", originalDescriptor);
|
|
191
|
+
delete (prototype as unknown as Record<PropertyKey, unknown>)[ZENTUI_PROTOTYPE_PATCH_REGISTRY];
|
|
192
|
+
vi.useRealTimers();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
function controller(
|
|
196
|
+
config: ThinkingStepsComponentConfig,
|
|
197
|
+
now: () => number = Date.now,
|
|
198
|
+
requestRender = vi.fn(),
|
|
199
|
+
getHostKeybindings?: () => {
|
|
200
|
+
getDefinition(action: string): unknown;
|
|
201
|
+
getKeys?(action: string): string[];
|
|
202
|
+
matches(data: string, action: string): boolean;
|
|
203
|
+
},
|
|
204
|
+
getHostKeyText?: (action: string) => string,
|
|
205
|
+
): ThinkingExperimentalController {
|
|
206
|
+
const value = new ThinkingExperimentalController(
|
|
207
|
+
() => config,
|
|
208
|
+
requestRender,
|
|
209
|
+
now,
|
|
210
|
+
getHostKeybindings,
|
|
211
|
+
getHostKeyText,
|
|
212
|
+
);
|
|
213
|
+
controllers.add(value);
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
describe("Thinking (Experimental) private assistant decorator", () => {
|
|
218
|
+
it.each(["rail", "tree"] as const)(
|
|
219
|
+
"installs %s only at enabled startup, preserves hidden native thinking, and owns no input",
|
|
220
|
+
(mode) => {
|
|
221
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
222
|
+
const host = context();
|
|
223
|
+
const value = controller({ enabled: true, mode });
|
|
224
|
+
expect(value.startSession(host.ctx)).toEqual({ applied: true });
|
|
225
|
+
expect(value.state).toMatchObject({ active: true, activeMode: mode });
|
|
226
|
+
expect(host.inputRegistrations()).toBe(0);
|
|
227
|
+
const visible = component(false);
|
|
228
|
+
visible.updateContent(message("# One\n# Two", 1_000), true);
|
|
229
|
+
const output = plain(visible.render(80)).join("\n");
|
|
230
|
+
expect(output).toContain(mode === "rail" ? "│ • Two" : "└─ • Two");
|
|
231
|
+
const hidden = component(true);
|
|
232
|
+
hidden.updateContent(message("\x1b[31m# Hidden\x1b[0m", 2_000), true);
|
|
233
|
+
const hiddenOutput = plain(hidden.render(80)).join("\n");
|
|
234
|
+
expect(hiddenOutput).toContain("Thinking...");
|
|
235
|
+
expect(hiddenOutput).not.toContain("Hidden");
|
|
236
|
+
},
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
it.each(["rail", "tree"] as const)(
|
|
240
|
+
"renders SGR-decorated thinking in %s and preserves mixed unsafe source natively",
|
|
241
|
+
(mode) => {
|
|
242
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
243
|
+
const unsafeSource = "\x1b[38;2;137;180;250m# Native fallback\x1b[39m\x1b[2J";
|
|
244
|
+
const expectedNative = component();
|
|
245
|
+
expectedNative.updateContent(message(unsafeSource, 3_000), true);
|
|
246
|
+
|
|
247
|
+
const value = controller({ enabled: true, mode });
|
|
248
|
+
expect(value.startSession(context().ctx)).toEqual({ applied: true });
|
|
249
|
+
const decorated = component();
|
|
250
|
+
decorated.updateContent(
|
|
251
|
+
message(
|
|
252
|
+
"\x1b[38;2;137;180;250m# First\x1b[39m\n\x1b[38;2;186;194;222m# Second\x1b[39m",
|
|
253
|
+
2_000,
|
|
254
|
+
),
|
|
255
|
+
true,
|
|
256
|
+
);
|
|
257
|
+
const decoratedRows = decorated.render(80);
|
|
258
|
+
const decoratedPlain = plain(decoratedRows).join("\n");
|
|
259
|
+
expect(decoratedPlain).toContain(mode === "rail" ? "│ • Second" : "└─ • Second");
|
|
260
|
+
expect(decoratedRows.join("\n")).not.toContain("\x1b[38;2;137;180;250m");
|
|
261
|
+
expect(decoratedRows.join("\n")).not.toContain("\x1b[38;2;186;194;222m");
|
|
262
|
+
|
|
263
|
+
const unsafe = component();
|
|
264
|
+
unsafe.updateContent(message(unsafeSource, 3_000), true);
|
|
265
|
+
expect(unsafe.render(80)).toEqual(expectedNative.render(80));
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
it("preconfigures while disabled and requires restart for first enable and post-disable re-enable", () => {
|
|
270
|
+
const config: ThinkingStepsComponentConfig = { enabled: false, mode: "tree" };
|
|
271
|
+
const host = context();
|
|
272
|
+
const value = controller(config);
|
|
273
|
+
expect(value.startSession(host.ctx)).toEqual({ applied: true });
|
|
274
|
+
expect(Object.getOwnPropertyDescriptor(prototype, "updateContent")).toEqual(originalDescriptor);
|
|
275
|
+
|
|
276
|
+
config.mode = "streaming";
|
|
277
|
+
expect(value.reconcile()).toEqual({ applied: true });
|
|
278
|
+
config.enabled = true;
|
|
279
|
+
expect(value.reconcile()).toEqual({
|
|
280
|
+
applied: false,
|
|
281
|
+
reason: "Saved: Streaming · Active: Native · restart required",
|
|
282
|
+
});
|
|
283
|
+
expect(config).toEqual({ enabled: true, mode: "streaming" });
|
|
284
|
+
expect(Object.getOwnPropertyDescriptor(prototype, "updateContent")).toEqual(originalDescriptor);
|
|
285
|
+
expect(value.state).toMatchObject({
|
|
286
|
+
available: true,
|
|
287
|
+
active: false,
|
|
288
|
+
restartRequired: true,
|
|
289
|
+
});
|
|
290
|
+
expect(value.state.reason).toBeUndefined();
|
|
291
|
+
expect(host.input("\x14")).toBeUndefined();
|
|
292
|
+
expect(host.inputRegistrations()).toBe(0);
|
|
293
|
+
expect(host.stopInput).not.toHaveBeenCalled();
|
|
294
|
+
|
|
295
|
+
value.shutdown();
|
|
296
|
+
expect(value.startSession(host.ctx)).toEqual({ applied: true });
|
|
297
|
+
expect(prototype.updateContent).not.toBe(originalDescriptor?.value);
|
|
298
|
+
expect(value.state).toMatchObject({
|
|
299
|
+
available: true,
|
|
300
|
+
active: true,
|
|
301
|
+
restartRequired: false,
|
|
302
|
+
});
|
|
303
|
+
expect(host.inputRegistrations()).toBe(1);
|
|
304
|
+
config.enabled = false;
|
|
305
|
+
expect(value.reconcile()).toEqual({ applied: true });
|
|
306
|
+
expect(value.state).toMatchObject({ active: false, restartRequired: false });
|
|
307
|
+
config.enabled = true;
|
|
308
|
+
expect(value.reconcile()).toMatchObject({ applied: false });
|
|
309
|
+
expect(value.state).toMatchObject({ active: false, restartRequired: true });
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("switches startup Streaming to Tree to Rail live with one redraw and releases resources", () => {
|
|
313
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
314
|
+
vi.useFakeTimers();
|
|
315
|
+
const config: ThinkingStepsComponentConfig = { enabled: true, mode: "streaming" };
|
|
316
|
+
const host = context();
|
|
317
|
+
const requestRender = vi.fn();
|
|
318
|
+
const value = controller(config, () => 2_000, requestRender);
|
|
319
|
+
expect(value.startSession(host.ctx)).toEqual({ applied: true });
|
|
320
|
+
const assistant = component();
|
|
321
|
+
const current = message("# One\n# Two\n# Three\n# Four\n# Five\n# Six", 1_000);
|
|
322
|
+
(current as { stopReason: string }).stopReason = "pending";
|
|
323
|
+
assistant.updateContent(current, true);
|
|
324
|
+
expect(plain(assistant.render(80)).join("\n")).toContain("Thinking 0.0s");
|
|
325
|
+
expect(host.inputRegistrations()).toBe(1);
|
|
326
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
327
|
+
|
|
328
|
+
config.mode = "tree";
|
|
329
|
+
expect(value.reconcile()).toEqual({ applied: true });
|
|
330
|
+
expect(plain(assistant.render(80)).join("\n")).toContain("└─ • Six");
|
|
331
|
+
expect(requestRender).toHaveBeenCalledTimes(1);
|
|
332
|
+
expect(host.stopInput).toHaveBeenCalledTimes(1);
|
|
333
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
334
|
+
|
|
335
|
+
config.mode = "rail";
|
|
336
|
+
expect(value.reconcile()).toEqual({ applied: true });
|
|
337
|
+
expect(plain(assistant.render(80)).join("\n")).toContain("│ • Six");
|
|
338
|
+
expect(requestRender).toHaveBeenCalledTimes(2);
|
|
339
|
+
expect(host.inputRegistrations()).toBe(1);
|
|
340
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
341
|
+
expect(value.reconcile()).toEqual({ applied: true });
|
|
342
|
+
expect(requestRender).toHaveBeenCalledTimes(2);
|
|
343
|
+
for (const method of Object.values(host.forbidden)) expect(method).not.toHaveBeenCalled();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("keeps a structural mode active and acquires no resources when Streaming is selected", () => {
|
|
347
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
348
|
+
vi.useFakeTimers();
|
|
349
|
+
const config: ThinkingStepsComponentConfig = { enabled: true, mode: "tree" };
|
|
350
|
+
const host = context();
|
|
351
|
+
const requestRender = vi.fn();
|
|
352
|
+
const value = controller(config, () => 2_000, requestRender);
|
|
353
|
+
expect(value.startSession(host.ctx)).toEqual({ applied: true });
|
|
354
|
+
const assistant = component();
|
|
355
|
+
assistant.updateContent(message("# One\n# Two", 1_000), true);
|
|
356
|
+
|
|
357
|
+
config.mode = "streaming";
|
|
358
|
+
expect(value.reconcile()).toEqual({
|
|
359
|
+
applied: false,
|
|
360
|
+
reason: "Saved: Streaming · Active: Tree · restart required",
|
|
361
|
+
});
|
|
362
|
+
expect(value.state).toMatchObject({
|
|
363
|
+
rendererAvailable: true,
|
|
364
|
+
streamingAvailable: true,
|
|
365
|
+
active: true,
|
|
366
|
+
activeMode: "tree",
|
|
367
|
+
restartRequired: true,
|
|
368
|
+
});
|
|
369
|
+
expect(plain(assistant.render(80)).join("\n")).toContain("└─ • Two");
|
|
370
|
+
expect(requestRender).not.toHaveBeenCalled();
|
|
371
|
+
expect(host.inputRegistrations()).toBe(0);
|
|
372
|
+
expect(host.stopInput).not.toHaveBeenCalled();
|
|
373
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("fails open native when startup Streaming cannot own listener cleanup", () => {
|
|
377
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
378
|
+
const config: ThinkingStepsComponentConfig = { enabled: true, mode: "streaming" };
|
|
379
|
+
const host = context();
|
|
380
|
+
let leakedHandler: ((data: string) => { consume?: boolean } | undefined) | undefined;
|
|
381
|
+
(
|
|
382
|
+
host.ctx.ui as unknown as { onTerminalInput: (handler: typeof leakedHandler) => unknown }
|
|
383
|
+
).onTerminalInput = (handler) => {
|
|
384
|
+
leakedHandler = handler;
|
|
385
|
+
return { notCleanup: true };
|
|
386
|
+
};
|
|
387
|
+
const value = controller(config);
|
|
388
|
+
expect(value.startSession(host.ctx)).toMatchObject({
|
|
389
|
+
applied: false,
|
|
390
|
+
reason: expect.stringContaining("cleanup is unavailable"),
|
|
391
|
+
});
|
|
392
|
+
expect(value.state).toMatchObject({
|
|
393
|
+
rendererAvailable: true,
|
|
394
|
+
streamingAvailable: false,
|
|
395
|
+
active: false,
|
|
396
|
+
restartRequired: true,
|
|
397
|
+
});
|
|
398
|
+
const assistant = component();
|
|
399
|
+
assistant.updateContent(message("startup native reasoning"), true);
|
|
400
|
+
expect(plain(assistant.render(80)).join("\n")).toContain("startup native reasoning");
|
|
401
|
+
expect(leakedHandler?.("\x14")).toBeUndefined();
|
|
402
|
+
value.shutdown();
|
|
403
|
+
expect(leakedHandler?.("\x14")).toBeUndefined();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("does not fold the first pending Streaming message when timer startup fails", () => {
|
|
407
|
+
let nativeChildren: Component[] | undefined;
|
|
408
|
+
installLegacyThinkingRenderer((_container, children) => {
|
|
409
|
+
nativeChildren = children;
|
|
410
|
+
});
|
|
411
|
+
const config: ThinkingStepsComponentConfig = { enabled: true, mode: "streaming" };
|
|
412
|
+
const host = context();
|
|
413
|
+
const value = controller(config, () => 2_000);
|
|
414
|
+
expect(value.startSession(host.ctx)).toEqual({ applied: true });
|
|
415
|
+
const interval = vi.spyOn(globalThis, "setInterval").mockImplementationOnce(() => {
|
|
416
|
+
throw new Error("timer denied");
|
|
417
|
+
});
|
|
418
|
+
const assistant = component();
|
|
419
|
+
const pending = message("first pending native reasoning", 1_000);
|
|
420
|
+
(pending as { stopReason: string }).stopReason = "pending";
|
|
421
|
+
expect(() => assistant.updateContent(pending, true)).not.toThrow();
|
|
422
|
+
interval.mockRestore();
|
|
423
|
+
|
|
424
|
+
const children = (assistant as unknown as { contentContainer: { children: Component[] } })
|
|
425
|
+
.contentContainer.children;
|
|
426
|
+
expect(children).toBe(nativeChildren);
|
|
427
|
+
expect(children.every((child, index) => child === nativeChildren?.[index])).toBe(true);
|
|
428
|
+
expect(plain(assistant.render(80)).join("\n")).toContain("first pending native reasoning");
|
|
429
|
+
expect(value.state).toMatchObject({
|
|
430
|
+
rendererAvailable: true,
|
|
431
|
+
streamingAvailable: false,
|
|
432
|
+
active: false,
|
|
433
|
+
restartRequired: true,
|
|
434
|
+
reason: "Pi's thinking timer is unavailable",
|
|
435
|
+
});
|
|
436
|
+
expect(value.diagnostics).toMatchObject({ trackedComponents: 0, activeComponents: 0 });
|
|
437
|
+
expect(host.stopInput).toHaveBeenCalledTimes(1);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("fails a live transition transactionally when a tracked layout becomes incompatible", () => {
|
|
441
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
442
|
+
vi.useFakeTimers();
|
|
443
|
+
const config: ThinkingStepsComponentConfig = { enabled: true, mode: "rail" };
|
|
444
|
+
const host = context();
|
|
445
|
+
const value = controller(config, () => 5_000);
|
|
446
|
+
expect(value.startSession(host.ctx)).toEqual({ applied: true });
|
|
447
|
+
const assistant = component();
|
|
448
|
+
const original = message("# Original Rail", 1_000);
|
|
449
|
+
(original as { stopReason: string }).stopReason = "pending";
|
|
450
|
+
assistant.updateContent(original, true);
|
|
451
|
+
const tracked = (
|
|
452
|
+
value as unknown as { states: WeakMap<object, { args: unknown[] }> }
|
|
453
|
+
).states.get(assistant);
|
|
454
|
+
expect(tracked).toBeDefined();
|
|
455
|
+
if (tracked) tracked.args = [message("incompatible native replacement", 2_000), true];
|
|
456
|
+
|
|
457
|
+
config.mode = "tree";
|
|
458
|
+
const result = value.reconcile();
|
|
459
|
+
expect(result).toEqual({
|
|
460
|
+
applied: false,
|
|
461
|
+
reason:
|
|
462
|
+
"Saved: Tree · Active: Native · Renderer unavailable · restart required · Pi's private assistant renderer shape is incompatible",
|
|
463
|
+
});
|
|
464
|
+
expect(value.state).toMatchObject({
|
|
465
|
+
available: false,
|
|
466
|
+
active: false,
|
|
467
|
+
restartRequired: true,
|
|
468
|
+
reason: "Pi's private assistant renderer shape is incompatible",
|
|
469
|
+
});
|
|
470
|
+
expect(value.state.activeMode).toBeUndefined();
|
|
471
|
+
expect(value.diagnostics).toMatchObject({ trackedComponents: 0, activeComponents: 0 });
|
|
472
|
+
expect(plain(assistant.render(80)).join("\n")).toContain("incompatible native replacement");
|
|
473
|
+
expect(host.stopInput).not.toHaveBeenCalled();
|
|
474
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("contains throwing listener cleanup while leaving the selected structural mode active", () => {
|
|
478
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
479
|
+
const config: ThinkingStepsComponentConfig = { enabled: true, mode: "streaming" };
|
|
480
|
+
const host = context();
|
|
481
|
+
host.stopInput.mockImplementation(() => {
|
|
482
|
+
throw new Error("cleanup denied");
|
|
483
|
+
});
|
|
484
|
+
const value = controller(config);
|
|
485
|
+
expect(value.startSession(host.ctx)).toEqual({ applied: true });
|
|
486
|
+
const assistant = component();
|
|
487
|
+
assistant.updateContent(message("# Transition cleanup"), false);
|
|
488
|
+
config.mode = "tree";
|
|
489
|
+
let result: ReturnType<ThinkingExperimentalController["reconcile"]> | undefined;
|
|
490
|
+
expect(() => {
|
|
491
|
+
result = value.reconcile();
|
|
492
|
+
}).not.toThrow();
|
|
493
|
+
expect(result).toEqual({
|
|
494
|
+
applied: true,
|
|
495
|
+
reason:
|
|
496
|
+
"Saved: Tree · Active: Tree · Streaming unavailable · Pi's terminal input listener cleanup is unavailable",
|
|
497
|
+
});
|
|
498
|
+
expect(value.state).toMatchObject({
|
|
499
|
+
active: true,
|
|
500
|
+
activeMode: "tree",
|
|
501
|
+
streamingAvailable: false,
|
|
502
|
+
restartRequired: false,
|
|
503
|
+
});
|
|
504
|
+
expect(plain(assistant.render(80)).join("\n")).toContain("└─ · Transition cleanup");
|
|
505
|
+
expect(() => value.shutdown()).not.toThrow();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("disables Streaming to native with a canonical warning when cleanup throws", () => {
|
|
509
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
510
|
+
vi.useFakeTimers();
|
|
511
|
+
const config: ThinkingStepsComponentConfig = { enabled: true, mode: "streaming" };
|
|
512
|
+
const host = context();
|
|
513
|
+
host.stopInput.mockImplementation(() => {
|
|
514
|
+
throw new Error("cleanup denied");
|
|
515
|
+
});
|
|
516
|
+
const value = controller(config);
|
|
517
|
+
expect(value.startSession(host.ctx)).toEqual({ applied: true });
|
|
518
|
+
const assistant = component();
|
|
519
|
+
const pending = message("# Disable cleanup");
|
|
520
|
+
(pending as { stopReason: string }).stopReason = "pending";
|
|
521
|
+
assistant.updateContent(pending, true);
|
|
522
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
523
|
+
|
|
524
|
+
config.enabled = false;
|
|
525
|
+
expect(value.reconcile()).toEqual({
|
|
526
|
+
applied: true,
|
|
527
|
+
reason:
|
|
528
|
+
"Saved: Disabled · Active: Native · Streaming unavailable · Pi's terminal input listener cleanup is unavailable",
|
|
529
|
+
});
|
|
530
|
+
expect(value.state).toMatchObject({
|
|
531
|
+
active: false,
|
|
532
|
+
streamingAvailable: false,
|
|
533
|
+
streamingPoisoned: true,
|
|
534
|
+
restartRequired: false,
|
|
535
|
+
reason: "Pi's terminal input listener cleanup is unavailable",
|
|
536
|
+
});
|
|
537
|
+
expect(host.stopInput).toHaveBeenCalledTimes(1);
|
|
538
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
539
|
+
expect(host.input("\x14")).toBeUndefined();
|
|
540
|
+
const nativeOutput = plain(assistant.render(80)).join("\n");
|
|
541
|
+
expect(nativeOutput).toContain("Disable cleanup");
|
|
542
|
+
expect(nativeOutput).not.toContain("Thinking 0.0s");
|
|
543
|
+
|
|
544
|
+
expect(value.reconcile()).toEqual({ applied: true });
|
|
545
|
+
expect(host.stopInput).toHaveBeenCalledTimes(1);
|
|
546
|
+
expect(value.state).toMatchObject({
|
|
547
|
+
streamingAvailable: false,
|
|
548
|
+
streamingPoisoned: true,
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("keeps a failed timer handle inert and retries its cleanup at shutdown", () => {
|
|
553
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
554
|
+
vi.useFakeTimers();
|
|
555
|
+
const config: ThinkingStepsComponentConfig = { enabled: true, mode: "streaming" };
|
|
556
|
+
const host = context();
|
|
557
|
+
const requestRender = vi.fn();
|
|
558
|
+
const value = controller(config, Date.now, requestRender);
|
|
559
|
+
expect(value.startSession(host.ctx)).toEqual({ applied: true });
|
|
560
|
+
const assistant = component();
|
|
561
|
+
const pending = message("# Timer cleanup");
|
|
562
|
+
(pending as { stopReason: string }).stopReason = "pending";
|
|
563
|
+
assistant.updateContent(pending, true);
|
|
564
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
565
|
+
const clear = vi.spyOn(globalThis, "clearInterval").mockImplementationOnce(() => {
|
|
566
|
+
throw new Error("timer cleanup denied");
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
config.mode = "tree";
|
|
570
|
+
expect(value.reconcile()).toEqual({
|
|
571
|
+
applied: true,
|
|
572
|
+
reason:
|
|
573
|
+
"Saved: Tree · Active: Tree · Streaming unavailable · Pi's thinking timer cleanup is unavailable",
|
|
574
|
+
});
|
|
575
|
+
expect(value.state).toMatchObject({
|
|
576
|
+
active: true,
|
|
577
|
+
activeMode: "tree",
|
|
578
|
+
streamingAvailable: false,
|
|
579
|
+
streamingPoisoned: true,
|
|
580
|
+
restartRequired: false,
|
|
581
|
+
reason: "Pi's thinking timer cleanup is unavailable",
|
|
582
|
+
});
|
|
583
|
+
expect(host.stopInput).toHaveBeenCalledTimes(1);
|
|
584
|
+
expect(host.input("\x14")).toBeUndefined();
|
|
585
|
+
expect(host.inputRegistrations()).toBe(1);
|
|
586
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
587
|
+
expect(
|
|
588
|
+
(value as unknown as { interval?: ReturnType<typeof setInterval> }).interval,
|
|
589
|
+
).toBeDefined();
|
|
590
|
+
const rendersAfterTransition = requestRender.mock.calls.length;
|
|
591
|
+
vi.advanceTimersByTime(2_000);
|
|
592
|
+
expect(clear).toHaveBeenCalledTimes(1);
|
|
593
|
+
expect(requestRender).toHaveBeenCalledTimes(rendersAfterTransition);
|
|
594
|
+
expect(value.diagnostics.lastTimerWork).toBe(0);
|
|
595
|
+
expect(plain(assistant.render(80)).join("\n")).toContain("└─ • Timer cleanup");
|
|
596
|
+
|
|
597
|
+
expect(() => value.shutdown()).not.toThrow();
|
|
598
|
+
expect(clear).toHaveBeenCalledTimes(2);
|
|
599
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
600
|
+
expect(
|
|
601
|
+
(value as unknown as { interval?: ReturnType<typeof setInterval> }).interval,
|
|
602
|
+
).toBeUndefined();
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("keeps an always-throwing cleared timer callback inert with an honest reason", () => {
|
|
606
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
607
|
+
vi.useFakeTimers();
|
|
608
|
+
const config: ThinkingStepsComponentConfig = { enabled: true, mode: "streaming" };
|
|
609
|
+
const host = context();
|
|
610
|
+
const requestRender = vi.fn();
|
|
611
|
+
const value = controller(config, Date.now, requestRender);
|
|
612
|
+
expect(value.startSession(host.ctx)).toEqual({ applied: true });
|
|
613
|
+
const assistant = component();
|
|
614
|
+
const pending = message("# Permanent timer cleanup");
|
|
615
|
+
(pending as { stopReason: string }).stopReason = "pending";
|
|
616
|
+
assistant.updateContent(pending, true);
|
|
617
|
+
const clear = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {
|
|
618
|
+
throw new Error("timer cleanup denied");
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
config.mode = "rail";
|
|
622
|
+
expect(value.reconcile()).toEqual({
|
|
623
|
+
applied: true,
|
|
624
|
+
reason:
|
|
625
|
+
"Saved: Rail · Active: Rail · Streaming unavailable · Pi's thinking timer cleanup is unavailable",
|
|
626
|
+
});
|
|
627
|
+
const rendersAfterTransition = requestRender.mock.calls.length;
|
|
628
|
+
vi.advanceTimersByTime(2_000);
|
|
629
|
+
expect(clear).toHaveBeenCalledTimes(1);
|
|
630
|
+
expect(requestRender).toHaveBeenCalledTimes(rendersAfterTransition);
|
|
631
|
+
expect(host.inputRegistrations()).toBe(1);
|
|
632
|
+
expect(value.state).toMatchObject({
|
|
633
|
+
active: true,
|
|
634
|
+
activeMode: "rail",
|
|
635
|
+
streamingAvailable: false,
|
|
636
|
+
streamingPoisoned: true,
|
|
637
|
+
reason: "Pi's thinking timer cleanup is unavailable",
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
expect(() => value.shutdown()).not.toThrow();
|
|
641
|
+
expect(clear).toHaveBeenCalledTimes(2);
|
|
642
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
643
|
+
expect(
|
|
644
|
+
(value as unknown as { interval?: ReturnType<typeof setInterval> }).interval,
|
|
645
|
+
).toBeDefined();
|
|
646
|
+
expect(value.state.reason).toBe("Pi's thinking timer cleanup is unavailable");
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it("renders the final five native terminal rows, toggles full native reasoning, and refolds", () => {
|
|
650
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
651
|
+
let now = 8_100;
|
|
652
|
+
const config: ThinkingStepsComponentConfig = {
|
|
653
|
+
enabled: true,
|
|
654
|
+
mode: "streaming",
|
|
655
|
+
};
|
|
656
|
+
const host = context();
|
|
657
|
+
const value = controller(config, () => now);
|
|
658
|
+
value.startSession(host.ctx);
|
|
659
|
+
const assistant = component();
|
|
660
|
+
const reasoning = Array.from({ length: 8 }, (_, index) => `rendered row ${index + 1} `).join(
|
|
661
|
+
"\n",
|
|
662
|
+
);
|
|
663
|
+
const current = message(reasoning, 7_000, "Final **answer**");
|
|
664
|
+
assistant.updateContent(current, true);
|
|
665
|
+
const collapsed = plain(assistant.render(40));
|
|
666
|
+
expect(collapsed).toContain(" Thinking 0.0s (ctrl+t to expand)");
|
|
667
|
+
expect(collapsed.some((line) => line.includes("rendered row 1"))).toBe(false);
|
|
668
|
+
for (const row of [4, 5, 6, 7, 8])
|
|
669
|
+
expect(collapsed.some((line) => line.includes(`rendered row ${row}`))).toBe(true);
|
|
670
|
+
expect(collapsed.some((line) => line.includes("Final answer"))).toBe(true);
|
|
671
|
+
|
|
672
|
+
expect(host.input("\x14")).toEqual({ consume: true });
|
|
673
|
+
const expanded = plain(assistant.render(40));
|
|
674
|
+
expect(expanded.some((line) => line.includes("Thinking "))).toBe(false);
|
|
675
|
+
expect(expanded.some((line) => line.includes("rendered row 1"))).toBe(true);
|
|
676
|
+
expect(host.input("\x14")).toEqual({ consume: true });
|
|
677
|
+
expect(plain(assistant.render(40))).toContain(" Thinking 0.0s (ctrl+t to expand)");
|
|
678
|
+
|
|
679
|
+
now = 12_300;
|
|
680
|
+
assistant.updateContent(current, false);
|
|
681
|
+
const completed = plain(assistant.render(40));
|
|
682
|
+
expect(completed).toContain(" Thought for 4.2s (ctrl+t to expand)");
|
|
683
|
+
expect(completed.filter((line) => line.includes("rendered row"))).toEqual([]);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it("maps equal and colliding text/thinking sources by exact ordered child identity", () => {
|
|
687
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
688
|
+
const config: ThinkingStepsComponentConfig = {
|
|
689
|
+
enabled: true,
|
|
690
|
+
mode: "streaming",
|
|
691
|
+
};
|
|
692
|
+
const host = context();
|
|
693
|
+
const value = controller(config, () => 2_000);
|
|
694
|
+
expect(value.startSession(host.ctx)).toEqual({ applied: true });
|
|
695
|
+
|
|
696
|
+
const collision = component();
|
|
697
|
+
collision.updateContent(
|
|
698
|
+
messageWithContent([
|
|
699
|
+
{ type: "thinking", thinking: "A" },
|
|
700
|
+
{ type: "text", text: "B" },
|
|
701
|
+
{ type: "thinking", thinking: "B" },
|
|
702
|
+
]),
|
|
703
|
+
false,
|
|
704
|
+
);
|
|
705
|
+
const collisionFolded = plain(collision.render(80)).join("\n");
|
|
706
|
+
expect(collisionFolded).toContain("Thought");
|
|
707
|
+
expect(collisionFolded).not.toMatch(/^ A$/m);
|
|
708
|
+
expect(collisionFolded.match(/^ B$/gm)).toHaveLength(1);
|
|
709
|
+
expect(host.input("\x14")).toEqual({ consume: true });
|
|
710
|
+
const collisionExpanded = plain(collision.render(80)).join("\n");
|
|
711
|
+
expect(collisionExpanded.match(/^ A$/gm)).toHaveLength(1);
|
|
712
|
+
expect(collisionExpanded.match(/^ B$/gm)).toHaveLength(2);
|
|
713
|
+
expect(host.input("\x14")).toEqual({ consume: true });
|
|
714
|
+
|
|
715
|
+
const equal = component();
|
|
716
|
+
equal.updateContent(
|
|
717
|
+
messageWithContent([
|
|
718
|
+
{ type: "text", text: "SAME" },
|
|
719
|
+
{ type: "thinking", thinking: "SAME" },
|
|
720
|
+
]),
|
|
721
|
+
false,
|
|
722
|
+
);
|
|
723
|
+
const equalFolded = plain(equal.render(80)).join("\n");
|
|
724
|
+
expect(equalFolded.match(/^ SAME$/gm)).toHaveLength(1);
|
|
725
|
+
expect(equalFolded).toContain("Thought");
|
|
726
|
+
expect(host.input("\x14")).toEqual({ consume: true });
|
|
727
|
+
expect(
|
|
728
|
+
plain(equal.render(80))
|
|
729
|
+
.join("\n")
|
|
730
|
+
.match(/^ SAME$/gm),
|
|
731
|
+
).toHaveLength(2);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("coalesces contiguous thinking and preserves separated runs across tool-only boundaries", () => {
|
|
735
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
736
|
+
const config: ThinkingStepsComponentConfig = {
|
|
737
|
+
enabled: true,
|
|
738
|
+
mode: "streaming",
|
|
739
|
+
};
|
|
740
|
+
const host = context();
|
|
741
|
+
const value = controller(config);
|
|
742
|
+
expect(value.startSession(host.ctx)).toEqual({ applied: true });
|
|
743
|
+
const contiguous = component();
|
|
744
|
+
contiguous.updateContent(
|
|
745
|
+
messageWithContent([
|
|
746
|
+
{ type: "thinking", thinking: "contiguous A" },
|
|
747
|
+
{ type: "thinking", thinking: "contiguous B" },
|
|
748
|
+
]),
|
|
749
|
+
false,
|
|
750
|
+
);
|
|
751
|
+
const contiguousFolded = plain(contiguous.render(80)).join("\n");
|
|
752
|
+
expect(contiguousFolded).toContain("Thought");
|
|
753
|
+
expect(contiguousFolded).not.toContain("contiguous A");
|
|
754
|
+
expect(contiguousFolded).not.toContain("contiguous B");
|
|
755
|
+
|
|
756
|
+
const separated = component();
|
|
757
|
+
separated.updateContent(
|
|
758
|
+
messageWithContent([
|
|
759
|
+
{ type: "thinking", thinking: "separated A" },
|
|
760
|
+
{ type: "toolCall", id: "tool-1", name: "read", arguments: {} },
|
|
761
|
+
{ type: "thinking", thinking: "separated B" },
|
|
762
|
+
]),
|
|
763
|
+
false,
|
|
764
|
+
);
|
|
765
|
+
const separatedFolded = plain(separated.render(80)).join("\n");
|
|
766
|
+
expect(separatedFolded.match(/Thought/g)).toHaveLength(1);
|
|
767
|
+
expect(separatedFolded).not.toContain("separated A");
|
|
768
|
+
expect(separatedFolded).not.toContain("separated B");
|
|
769
|
+
expect(host.input("\x14")).toEqual({ consume: true });
|
|
770
|
+
const separatedExpanded = plain(separated.render(80)).join("\n");
|
|
771
|
+
expect(separatedExpanded).toContain("separated A");
|
|
772
|
+
expect(separatedExpanded).toContain("separated B");
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it.each(["rail", "tree"] as const)(
|
|
776
|
+
"groups a legacy adjacent thinking run into one %s title and run-level selection",
|
|
777
|
+
(mode) => {
|
|
778
|
+
installLegacyThinkingRenderer();
|
|
779
|
+
const value = controller({ enabled: true, mode });
|
|
780
|
+
expect(value.startSession(context().ctx)).toEqual({ applied: true });
|
|
781
|
+
const assistant = component();
|
|
782
|
+
assistant.updateContent(
|
|
783
|
+
messageWithContent([
|
|
784
|
+
{
|
|
785
|
+
type: "thinking",
|
|
786
|
+
thinking: ["# Legacy 1", "# Legacy 2", "# Legacy 3", "# Legacy 4"].join("\n"),
|
|
787
|
+
},
|
|
788
|
+
{
|
|
789
|
+
type: "thinking",
|
|
790
|
+
thinking: ["# Legacy 5", "# Legacy 6", "# Legacy 7", "# Legacy 8"].join("\n"),
|
|
791
|
+
},
|
|
792
|
+
]),
|
|
793
|
+
true,
|
|
794
|
+
);
|
|
795
|
+
const output = plain(assistant.render(80));
|
|
796
|
+
const titleRows = output.filter((row) => row.includes("Thinking"));
|
|
797
|
+
const labelRows = output.filter((row) => row.includes("Legacy"));
|
|
798
|
+
expect(titleRows).toHaveLength(1);
|
|
799
|
+
expect(labelRows).toHaveLength(mode === "rail" ? 8 : 5);
|
|
800
|
+
expect(labelRows.at(0)).toContain(mode === "rail" ? "Legacy 1" : "Legacy 4");
|
|
801
|
+
expect(labelRows.at(-1)).toContain("Legacy 8");
|
|
802
|
+
const titleIndex = output.findIndex((row) => row.includes("Thinking"));
|
|
803
|
+
expect(output.slice(titleIndex, titleIndex + labelRows.length + 1)).not.toContain("");
|
|
804
|
+
},
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
it.each(["modern", "legacy"] as const)(
|
|
808
|
+
"marks only an actually open final thinking run in exact %s streaming layouts",
|
|
809
|
+
(layout) => {
|
|
810
|
+
if (layout === "modern") bridgeSourceLoadedMarkdownIdentity();
|
|
811
|
+
else installLegacyThinkingRenderer();
|
|
812
|
+
for (const mode of ["rail", "tree"] as const) {
|
|
813
|
+
const value = controller({ enabled: true, mode });
|
|
814
|
+
expect(value.startSession(context().ctx)).toEqual({ applied: true });
|
|
815
|
+
const fixtures = [
|
|
816
|
+
{
|
|
817
|
+
name: "thinking→text→thinking",
|
|
818
|
+
content: [
|
|
819
|
+
{ type: "thinking" as const, thinking: "# Before" },
|
|
820
|
+
{ type: "text" as const, text: "boundary" },
|
|
821
|
+
{ type: "thinking" as const, thinking: "# After" },
|
|
822
|
+
],
|
|
823
|
+
rail: [" │ Thinking", " │ Before", " boundary", " │ Thinking", " │ • After"],
|
|
824
|
+
tree: [" ┆ Thinking", " └─ · Before", " boundary", " ┆ Thinking", " └─ • After"],
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
name: "thinking→tool→thinking",
|
|
828
|
+
content: [
|
|
829
|
+
{ type: "thinking" as const, thinking: "# Before" },
|
|
830
|
+
{ type: "toolCall" as const, id: "tool", name: "read", arguments: {} },
|
|
831
|
+
{ type: "thinking" as const, thinking: "# After" },
|
|
832
|
+
],
|
|
833
|
+
rail: [" │ Thinking", " │ Before", " │ Thinking", " │ • After"],
|
|
834
|
+
tree: [" ┆ Thinking", " └─ · Before", " ┆ Thinking", " └─ • After"],
|
|
835
|
+
},
|
|
836
|
+
{
|
|
837
|
+
name: "thinking→text",
|
|
838
|
+
content: [
|
|
839
|
+
{ type: "thinking" as const, thinking: "# Before" },
|
|
840
|
+
{ type: "text" as const, text: "boundary" },
|
|
841
|
+
],
|
|
842
|
+
rail: [" │ Thinking", " │ Before", " boundary"],
|
|
843
|
+
tree: [" ┆ Thinking", " └─ · Before", " boundary"],
|
|
844
|
+
},
|
|
845
|
+
] as const;
|
|
846
|
+
for (const fixture of fixtures) {
|
|
847
|
+
const assistant = component();
|
|
848
|
+
assistant.updateContent(messageWithContent([...fixture.content]), true);
|
|
849
|
+
const exactRows = plain(assistant.render(80)).filter(Boolean);
|
|
850
|
+
expect(exactRows, `${layout} ${mode} ${fixture.name}`).toEqual(fixture[mode]);
|
|
851
|
+
}
|
|
852
|
+
value.shutdown();
|
|
853
|
+
controllers.delete(value);
|
|
854
|
+
}
|
|
855
|
+
},
|
|
856
|
+
);
|
|
857
|
+
|
|
858
|
+
it("does not invoke an accessor setter that mutates native children before throwing", () => {
|
|
859
|
+
let originalChildren: Component[] = [];
|
|
860
|
+
const setter = vi.fn((replacement: Component[]) => {
|
|
861
|
+
originalChildren.splice(0, originalChildren.length, ...replacement);
|
|
862
|
+
throw new Error("atomic replacement rejected");
|
|
863
|
+
});
|
|
864
|
+
installLegacyThinkingRenderer((container, children) => {
|
|
865
|
+
originalChildren = children;
|
|
866
|
+
Object.defineProperty(container, "children", {
|
|
867
|
+
configurable: true,
|
|
868
|
+
get: () => children,
|
|
869
|
+
set: setter,
|
|
870
|
+
});
|
|
871
|
+
});
|
|
872
|
+
const value = controller({ enabled: true, mode: "rail" });
|
|
873
|
+
value.startSession(context().ctx);
|
|
874
|
+
const assistant = component();
|
|
875
|
+
expect(() =>
|
|
876
|
+
assistant.updateContent(
|
|
877
|
+
messageWithContent([
|
|
878
|
+
{ type: "thinking", thinking: "# Atomic 1" },
|
|
879
|
+
{ type: "thinking", thinking: "# Atomic 2" },
|
|
880
|
+
]),
|
|
881
|
+
true,
|
|
882
|
+
),
|
|
883
|
+
).not.toThrow();
|
|
884
|
+
const children = (assistant as unknown as { contentContainer: { children: Component[] } })
|
|
885
|
+
.contentContainer.children;
|
|
886
|
+
expect(setter).not.toHaveBeenCalled();
|
|
887
|
+
expect(children).toBe(originalChildren);
|
|
888
|
+
expect(children.map((child) => child.constructor.name)).toEqual([
|
|
889
|
+
"Spacer",
|
|
890
|
+
"Markdown",
|
|
891
|
+
"Spacer",
|
|
892
|
+
"Markdown",
|
|
893
|
+
]);
|
|
894
|
+
expect(children.some((child) => child.constructor.name === "ThinkingStepsRows")).toBe(false);
|
|
895
|
+
expect(value.diagnostics.trackedComponents).toBe(0);
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it.each(["nonwritable", "inherited"] as const)(
|
|
899
|
+
"retains native identity and output for a %s children property",
|
|
900
|
+
(shape) => {
|
|
901
|
+
let originalChildren: Component[] = [];
|
|
902
|
+
installLegacyThinkingRenderer((container, children) => {
|
|
903
|
+
originalChildren = children;
|
|
904
|
+
if (shape === "nonwritable") {
|
|
905
|
+
Object.defineProperty(container, "children", {
|
|
906
|
+
configurable: true,
|
|
907
|
+
writable: false,
|
|
908
|
+
value: children,
|
|
909
|
+
});
|
|
910
|
+
} else {
|
|
911
|
+
delete (container as { children?: Component[] }).children;
|
|
912
|
+
const inherited = Object.create(Object.getPrototypeOf(container)) as {
|
|
913
|
+
children: Component[];
|
|
914
|
+
};
|
|
915
|
+
inherited.children = children;
|
|
916
|
+
Object.setPrototypeOf(container, inherited);
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
const value = controller({ enabled: true, mode: "tree" });
|
|
920
|
+
value.startSession(context().ctx);
|
|
921
|
+
const assistant = component();
|
|
922
|
+
assistant.updateContent(message("# Native identity"), true);
|
|
923
|
+
const container = (assistant as unknown as { contentContainer: { children: Component[] } })
|
|
924
|
+
.contentContainer;
|
|
925
|
+
expect(container.children).toBe(originalChildren);
|
|
926
|
+
const output = plain(assistant.render(80)).join("\n");
|
|
927
|
+
expect(output).toContain("Native identity");
|
|
928
|
+
expect(output).not.toMatch(/[│┆][ •]? Thinking|[└├]─/);
|
|
929
|
+
expect(value.diagnostics.trackedComponents).toBe(0);
|
|
930
|
+
},
|
|
931
|
+
);
|
|
932
|
+
|
|
933
|
+
it("propagates the predecessor's exact thrown object without decoration containment", () => {
|
|
934
|
+
const marker = { source: "predecessor" };
|
|
935
|
+
Object.defineProperty(prototype, "updateContent", {
|
|
936
|
+
...originalDescriptor,
|
|
937
|
+
value() {
|
|
938
|
+
throw marker;
|
|
939
|
+
},
|
|
940
|
+
});
|
|
941
|
+
const value = controller({ enabled: true, mode: "tree" });
|
|
942
|
+
value.startSession(context().ctx);
|
|
943
|
+
let caught: unknown;
|
|
944
|
+
try {
|
|
945
|
+
component().updateContent(message("# Never decorated"), true);
|
|
946
|
+
} catch (error) {
|
|
947
|
+
caught = error;
|
|
948
|
+
}
|
|
949
|
+
expect(caught).toBe(marker);
|
|
950
|
+
expect(value.diagnostics.trackedComponents).toBe(0);
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
it.each([
|
|
954
|
+
["rail", "hidden"],
|
|
955
|
+
["rail", "no-thinking"],
|
|
956
|
+
["tree", "hidden"],
|
|
957
|
+
["tree", "no-thinking"],
|
|
958
|
+
] as const)(
|
|
959
|
+
"drops stale %s state when an authoritative %s predecessor mutates then throws",
|
|
960
|
+
(mode, transition) => {
|
|
961
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
962
|
+
const native = prototype.updateContent;
|
|
963
|
+
const marker = { source: `${mode}-${transition}` };
|
|
964
|
+
let throwAfterMutation = false;
|
|
965
|
+
Object.defineProperty(prototype, "updateContent", {
|
|
966
|
+
...Object.getOwnPropertyDescriptor(prototype, "updateContent"),
|
|
967
|
+
value: function mutateThenThrow(this: unknown, ...args: unknown[]) {
|
|
968
|
+
const result = Reflect.apply(native, this, args);
|
|
969
|
+
if (throwAfterMutation) throw marker;
|
|
970
|
+
return result;
|
|
971
|
+
},
|
|
972
|
+
});
|
|
973
|
+
const requestRender = vi.fn();
|
|
974
|
+
const value = controller({ enabled: true, mode }, Date.now, requestRender);
|
|
975
|
+
value.startSession(context().ctx);
|
|
976
|
+
const assistant = component(false);
|
|
977
|
+
const stale = message("# Stale custom thinking", 84_000);
|
|
978
|
+
value.beginMessage({ message: stale });
|
|
979
|
+
assistant.updateContent(stale, true);
|
|
980
|
+
expect(value.diagnostics).toMatchObject({ trackedComponents: 1, activeComponents: 1 });
|
|
981
|
+
|
|
982
|
+
const authoritative =
|
|
983
|
+
transition === "hidden" ? stale : message("", 84_001, "Authoritative text after failure");
|
|
984
|
+
if (transition === "hidden")
|
|
985
|
+
(assistant as unknown as { hideThinkingBlock: boolean }).hideThinkingBlock = true;
|
|
986
|
+
throwAfterMutation = true;
|
|
987
|
+
let caught: unknown;
|
|
988
|
+
try {
|
|
989
|
+
assistant.updateContent(authoritative, false);
|
|
990
|
+
} catch (error) {
|
|
991
|
+
caught = error;
|
|
992
|
+
}
|
|
993
|
+
expect(caught).toBe(marker);
|
|
994
|
+
expect(value.diagnostics).toMatchObject({ trackedComponents: 0, activeComponents: 0 });
|
|
995
|
+
expect((value as unknown as { timings: Map<number, unknown> }).timings.has(84_000)).toBe(
|
|
996
|
+
false,
|
|
997
|
+
);
|
|
998
|
+
expect(
|
|
999
|
+
(value as unknown as { currentMessage?: AssistantMessage }).currentMessage,
|
|
1000
|
+
).toBeUndefined();
|
|
1001
|
+
expect(requestRender).not.toHaveBeenCalled();
|
|
1002
|
+
|
|
1003
|
+
value.shutdown();
|
|
1004
|
+
expect(requestRender).not.toHaveBeenCalled();
|
|
1005
|
+
const output = plain(assistant.render(80)).join("\n");
|
|
1006
|
+
if (transition === "hidden") {
|
|
1007
|
+
expect(output).toContain("Thinking...");
|
|
1008
|
+
expect(output).not.toContain("Stale custom thinking");
|
|
1009
|
+
} else {
|
|
1010
|
+
expect(output).toContain("Authoritative text after failure");
|
|
1011
|
+
expect(output).not.toContain("Stale custom thinking");
|
|
1012
|
+
}
|
|
1013
|
+
},
|
|
1014
|
+
);
|
|
1015
|
+
|
|
1016
|
+
it.each(["rail", "tree"] as const)(
|
|
1017
|
+
"drops %s ownership after a visible-to-native-hidden authoritative host update",
|
|
1018
|
+
(mode) => {
|
|
1019
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
1020
|
+
const value = controller({ enabled: true, mode });
|
|
1021
|
+
value.startSession(context().ctx);
|
|
1022
|
+
const assistant = component(false);
|
|
1023
|
+
const current = message("# Visible\n# Latest", 81_000);
|
|
1024
|
+
assistant.updateContent(current, true);
|
|
1025
|
+
expect(value.diagnostics.trackedComponents).toBe(1);
|
|
1026
|
+
assistant.setHideThinkingBlock(true);
|
|
1027
|
+
assistant.updateContent(current, true);
|
|
1028
|
+
expect(value.diagnostics).toMatchObject({ trackedComponents: 0, activeComponents: 0 });
|
|
1029
|
+
expect((value as unknown as { timings: Map<number, unknown> }).timings.has(81_000)).toBe(
|
|
1030
|
+
false,
|
|
1031
|
+
);
|
|
1032
|
+
value.shutdown();
|
|
1033
|
+
const rendered = plain(assistant.render(80)).join("\n");
|
|
1034
|
+
expect(rendered).toContain("Thinking...");
|
|
1035
|
+
expect(rendered).not.toContain("Visible");
|
|
1036
|
+
},
|
|
1037
|
+
);
|
|
1038
|
+
|
|
1039
|
+
it.each(["rail", "tree"] as const)(
|
|
1040
|
+
"does not replay stale visible %s args when native-hidden ownership is displaced",
|
|
1041
|
+
(mode) => {
|
|
1042
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
1043
|
+
const value = controller({ enabled: true, mode });
|
|
1044
|
+
value.startSession(context().ctx);
|
|
1045
|
+
const assistant = component(false);
|
|
1046
|
+
const current = message("# Stale visible", 82_000);
|
|
1047
|
+
assistant.updateContent(current, true);
|
|
1048
|
+
assistant.setHideThinkingBlock(true);
|
|
1049
|
+
assistant.updateContent(current, true);
|
|
1050
|
+
const zentuiWrapper = prototype.updateContent;
|
|
1051
|
+
Object.defineProperty(prototype, "updateContent", {
|
|
1052
|
+
...Object.getOwnPropertyDescriptor(prototype, "updateContent"),
|
|
1053
|
+
value: function successor(this: unknown, ...args: unknown[]) {
|
|
1054
|
+
return Reflect.apply(zentuiWrapper, this, args);
|
|
1055
|
+
},
|
|
1056
|
+
});
|
|
1057
|
+
expect(value.state.displaced).toBe(true);
|
|
1058
|
+
const rendered = plain(assistant.render(80)).join("\n");
|
|
1059
|
+
expect(rendered).toContain("Thinking...");
|
|
1060
|
+
expect(rendered).not.toContain("Stale visible");
|
|
1061
|
+
},
|
|
1062
|
+
);
|
|
1063
|
+
|
|
1064
|
+
it.each(["streaming", "rail", "tree"] as const)(
|
|
1065
|
+
"drops %s ownership on an authoritative thinking-to-no-thinking update",
|
|
1066
|
+
(mode) => {
|
|
1067
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
1068
|
+
const value = controller({ enabled: true, mode });
|
|
1069
|
+
value.startSession(context().ctx);
|
|
1070
|
+
const assistant = component();
|
|
1071
|
+
assistant.updateContent(message("# Old thinking", 83_000), true);
|
|
1072
|
+
expect(value.diagnostics.trackedComponents).toBe(1);
|
|
1073
|
+
assistant.updateContent(message("", 83_001, "Authoritative text only"), false);
|
|
1074
|
+
expect(value.diagnostics).toMatchObject({ trackedComponents: 0, activeComponents: 0 });
|
|
1075
|
+
expect((value as unknown as { timings: Map<number, unknown> }).timings.has(83_000)).toBe(
|
|
1076
|
+
false,
|
|
1077
|
+
);
|
|
1078
|
+
value.shutdown();
|
|
1079
|
+
const rendered = plain(assistant.render(80)).join("\n");
|
|
1080
|
+
expect(rendered).toContain("Authoritative text only");
|
|
1081
|
+
expect(rendered).not.toContain("Old thinking");
|
|
1082
|
+
},
|
|
1083
|
+
);
|
|
1084
|
+
|
|
1085
|
+
it("keeps Streaming temporarily visible while active, then restores the latest native-hidden state", () => {
|
|
1086
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
1087
|
+
const value = controller({ enabled: true, mode: "streaming" }, () => 90_000);
|
|
1088
|
+
value.startSession(context().ctx);
|
|
1089
|
+
const assistant = component(false);
|
|
1090
|
+
const current = message("# Temporarily shown", 89_000);
|
|
1091
|
+
assistant.updateContent(current, true);
|
|
1092
|
+
assistant.setHideThinkingBlock(true);
|
|
1093
|
+
assistant.updateContent(current, true);
|
|
1094
|
+
expect(value.diagnostics.trackedComponents).toBe(1);
|
|
1095
|
+
expect(plain(assistant.render(80)).join("\n")).toContain("Thinking 0.0s");
|
|
1096
|
+
value.shutdown();
|
|
1097
|
+
const rendered = plain(assistant.render(80)).join("\n");
|
|
1098
|
+
expect(rendered).toContain("Thinking...");
|
|
1099
|
+
expect(rendered).not.toContain("Temporarily shown");
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
it.each(["count", "order", "text", "constructor"] as const)(
|
|
1103
|
+
"fails open through one marker-free native rerender on a %s mismatch",
|
|
1104
|
+
(mismatch) => {
|
|
1105
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
1106
|
+
const bridged = prototype.updateContent as (this: unknown, ...args: unknown[]) => unknown;
|
|
1107
|
+
let calls = 0;
|
|
1108
|
+
Object.defineProperty(prototype, "updateContent", {
|
|
1109
|
+
...Object.getOwnPropertyDescriptor(prototype, "updateContent"),
|
|
1110
|
+
value: function mismatchedNative(this: unknown, ...args: unknown[]) {
|
|
1111
|
+
calls += 1;
|
|
1112
|
+
const result = Reflect.apply(bridged, this, args);
|
|
1113
|
+
if (calls !== 1) return result;
|
|
1114
|
+
const children = (
|
|
1115
|
+
this as { contentContainer?: { children?: Array<Record<string, unknown>> } }
|
|
1116
|
+
).contentContainer?.children;
|
|
1117
|
+
if (!children) return result;
|
|
1118
|
+
const markdownIndexes = children.flatMap((child, index) =>
|
|
1119
|
+
child.constructor === Markdown ? [index] : [],
|
|
1120
|
+
);
|
|
1121
|
+
if (mismatch === "count") children.pop();
|
|
1122
|
+
else if (mismatch === "order") {
|
|
1123
|
+
const first = markdownIndexes[0];
|
|
1124
|
+
const second = markdownIndexes[1];
|
|
1125
|
+
if (first !== undefined && second !== undefined)
|
|
1126
|
+
[children[first], children[second]] = [children[second], children[first]];
|
|
1127
|
+
} else {
|
|
1128
|
+
const first = markdownIndexes[0];
|
|
1129
|
+
const child = first === undefined ? undefined : children[first];
|
|
1130
|
+
if (child && mismatch === "text") child.text = "__ZENTUI_MISMATCH_MARKER__";
|
|
1131
|
+
if (child && mismatch === "constructor") Object.setPrototypeOf(child, {});
|
|
1132
|
+
}
|
|
1133
|
+
return result;
|
|
1134
|
+
},
|
|
1135
|
+
});
|
|
1136
|
+
const config: ThinkingStepsComponentConfig = {
|
|
1137
|
+
enabled: true,
|
|
1138
|
+
mode: "streaming",
|
|
1139
|
+
};
|
|
1140
|
+
const value = controller(config);
|
|
1141
|
+
expect(value.startSession(context().ctx)).toEqual({ applied: true });
|
|
1142
|
+
const assistant = component();
|
|
1143
|
+
assistant.updateContent(
|
|
1144
|
+
messageWithContent([
|
|
1145
|
+
{ type: "thinking", thinking: "native A" },
|
|
1146
|
+
{ type: "text", text: "native B" },
|
|
1147
|
+
{ type: "thinking", thinking: "native C" },
|
|
1148
|
+
]),
|
|
1149
|
+
false,
|
|
1150
|
+
);
|
|
1151
|
+
expect(calls).toBe(2);
|
|
1152
|
+
expect(value.state).toMatchObject({ available: false, active: false });
|
|
1153
|
+
const rendered = plain(assistant.render(80)).join("\n");
|
|
1154
|
+
expect(rendered).toContain("native A");
|
|
1155
|
+
expect(rendered).toContain("native B");
|
|
1156
|
+
expect(rendered).toContain("native C");
|
|
1157
|
+
expect(rendered).not.toMatch(/Thinking \d|Thought(?: for)?|ZENTUI_MISMATCH_MARKER/);
|
|
1158
|
+
},
|
|
1159
|
+
);
|
|
1160
|
+
|
|
1161
|
+
it("omits the expansion hint when a live render does not overflow", () => {
|
|
1162
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
1163
|
+
const config: ThinkingStepsComponentConfig = {
|
|
1164
|
+
enabled: true,
|
|
1165
|
+
mode: "streaming",
|
|
1166
|
+
};
|
|
1167
|
+
const host = context();
|
|
1168
|
+
const value = controller(config, () => 2_000);
|
|
1169
|
+
value.startSession(host.ctx);
|
|
1170
|
+
const assistant = component();
|
|
1171
|
+
assistant.updateContent(message("one \ntwo", 1_000), true);
|
|
1172
|
+
const rendered = plain(assistant.render(80)).join("\n");
|
|
1173
|
+
expect(rendered).toContain("Thinking 0.0s");
|
|
1174
|
+
expect(rendered).not.toContain("to expand");
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
it("coalesces activation, timer, completion, and deactivation redraws", () => {
|
|
1178
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
1179
|
+
vi.useFakeTimers();
|
|
1180
|
+
vi.setSystemTime(10_000);
|
|
1181
|
+
const config: ThinkingStepsComponentConfig = {
|
|
1182
|
+
enabled: true,
|
|
1183
|
+
mode: "streaming",
|
|
1184
|
+
};
|
|
1185
|
+
const host = context();
|
|
1186
|
+
const requestRender = vi.fn();
|
|
1187
|
+
const value = controller(config, Date.now, requestRender);
|
|
1188
|
+
expect(value.startSession(host.ctx)).toEqual({ applied: true });
|
|
1189
|
+
expect(requestRender).toHaveBeenCalledTimes(0);
|
|
1190
|
+
|
|
1191
|
+
const current = message("live redraw reasoning", 5_000);
|
|
1192
|
+
(current as { stopReason: string }).stopReason = "pending";
|
|
1193
|
+
value.beginMessage({ message: current });
|
|
1194
|
+
const assistant = component();
|
|
1195
|
+
assistant.updateContent(current, true);
|
|
1196
|
+
expect(requestRender).toHaveBeenCalledTimes(0);
|
|
1197
|
+
vi.advanceTimersByTime(1_000);
|
|
1198
|
+
expect(requestRender).toHaveBeenCalledTimes(1);
|
|
1199
|
+
value.updateMessage({ message: current, assistantMessageEvent: { type: "text_delta" } });
|
|
1200
|
+
expect(requestRender).toHaveBeenCalledTimes(2);
|
|
1201
|
+
expect(plain(assistant.render(40)).join("\n")).toContain("Thought for 1.0s");
|
|
1202
|
+
|
|
1203
|
+
config.enabled = false;
|
|
1204
|
+
expect(value.reconcile()).toEqual({ applied: true });
|
|
1205
|
+
expect(value.state).toMatchObject({ active: false, restartRequired: false });
|
|
1206
|
+
value.shutdown();
|
|
1207
|
+
expect(requestRender).toHaveBeenCalledTimes(3);
|
|
1208
|
+
expect(plain(assistant.render(40)).join("\n")).not.toMatch(/Thinking \d|Thought for/);
|
|
1209
|
+
expect(plain(assistant.render(40)).join("\n")).toContain("live redraw reasoning");
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
it("freezes on stream boundaries, uses Thought without invented restored duration, and cleans resources", () => {
|
|
1213
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
1214
|
+
vi.useFakeTimers();
|
|
1215
|
+
vi.setSystemTime(10_000);
|
|
1216
|
+
const config: ThinkingStepsComponentConfig = {
|
|
1217
|
+
enabled: true,
|
|
1218
|
+
mode: "streaming",
|
|
1219
|
+
};
|
|
1220
|
+
const host = context();
|
|
1221
|
+
const requestRender = vi.fn();
|
|
1222
|
+
const value = controller(config, Date.now, requestRender);
|
|
1223
|
+
value.startSession(host.ctx);
|
|
1224
|
+
const current = message("private reasoning", 5_000);
|
|
1225
|
+
value.beginMessage({ message: current });
|
|
1226
|
+
const assistant = component();
|
|
1227
|
+
assistant.updateContent(current, true);
|
|
1228
|
+
const beforeTimer = requestRender.mock.calls.length;
|
|
1229
|
+
vi.advanceTimersByTime(1_000);
|
|
1230
|
+
expect(requestRender.mock.calls.length - beforeTimer).toBe(1);
|
|
1231
|
+
value.updateMessage({ message: current, assistantMessageEvent: { type: "text_delta" } });
|
|
1232
|
+
vi.setSystemTime(20_000);
|
|
1233
|
+
assistant.updateContent(current, true);
|
|
1234
|
+
expect(plain(assistant.render(80)).join("\n")).toContain("Thought for 1.0s");
|
|
1235
|
+
|
|
1236
|
+
const restored = component();
|
|
1237
|
+
restored.updateContent(message("old reasoning", 99), false);
|
|
1238
|
+
expect(plain(restored.render(80)).join("\n")).toContain("Thought (ctrl+t to expand)");
|
|
1239
|
+
expect(plain(restored.render(80)).join("\n")).not.toContain("Thought for");
|
|
1240
|
+
|
|
1241
|
+
config.enabled = false;
|
|
1242
|
+
expect(value.reconcile()).toEqual({ applied: true });
|
|
1243
|
+
expect(value.state).toMatchObject({ active: false, restartRequired: false });
|
|
1244
|
+
value.shutdown();
|
|
1245
|
+
expect(plain(assistant.render(80)).join("\n")).toContain("private reasoning");
|
|
1246
|
+
expect(host.stopInput).toHaveBeenCalledTimes(1);
|
|
1247
|
+
for (const method of Object.values(host.forbidden)) expect(method).not.toHaveBeenCalled();
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
it("refreshes Pi's latest visible↔hidden preference on every host update and restores ownership", () => {
|
|
1251
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
1252
|
+
const config: ThinkingStepsComponentConfig = {
|
|
1253
|
+
enabled: true,
|
|
1254
|
+
mode: "streaming",
|
|
1255
|
+
};
|
|
1256
|
+
const host = context();
|
|
1257
|
+
const value = controller(config, () => 2_000);
|
|
1258
|
+
expect(value.startSession(host.ctx)).toEqual({ applied: true });
|
|
1259
|
+
const reasoning = message(
|
|
1260
|
+
Array.from({ length: 8 }, (_, index) => `preference row ${index + 1} `).join("\n"),
|
|
1261
|
+
1_000,
|
|
1262
|
+
);
|
|
1263
|
+
const visibleToHidden = component(false);
|
|
1264
|
+
const visibleToHiddenState = visibleToHidden as unknown as { hideThinkingBlock: boolean };
|
|
1265
|
+
visibleToHidden.updateContent(reasoning, true);
|
|
1266
|
+
visibleToHidden.setHideThinkingBlock(true);
|
|
1267
|
+
visibleToHidden.updateContent(reasoning, true);
|
|
1268
|
+
expect(Object.hasOwn(visibleToHidden, "hideThinkingBlock")).toBe(true);
|
|
1269
|
+
expect(visibleToHiddenState.hideThinkingBlock).toBe(true);
|
|
1270
|
+
expect(plain(visibleToHidden.render(80)).join("\n")).toContain("Thinking 0.0s");
|
|
1271
|
+
|
|
1272
|
+
const hiddenToVisible = component(true);
|
|
1273
|
+
const hiddenToVisibleState = hiddenToVisible as unknown as { hideThinkingBlock: boolean };
|
|
1274
|
+
hiddenToVisible.updateContent(reasoning, true);
|
|
1275
|
+
hiddenToVisible.setHideThinkingBlock(false);
|
|
1276
|
+
hiddenToVisible.updateContent(reasoning, true);
|
|
1277
|
+
expect(Object.hasOwn(hiddenToVisible, "hideThinkingBlock")).toBe(true);
|
|
1278
|
+
expect(hiddenToVisibleState.hideThinkingBlock).toBe(false);
|
|
1279
|
+
expect(plain(hiddenToVisible.render(80)).join("\n")).toContain("Thinking 0.0s");
|
|
1280
|
+
|
|
1281
|
+
config.enabled = false;
|
|
1282
|
+
expect(value.reconcile()).toEqual({ applied: true });
|
|
1283
|
+
expect(value.state).toMatchObject({ active: false, restartRequired: false });
|
|
1284
|
+
value.shutdown();
|
|
1285
|
+
const hiddenNative = plain(visibleToHidden.render(80)).join("\n");
|
|
1286
|
+
expect(hiddenNative).toContain("Thinking...");
|
|
1287
|
+
expect(hiddenNative).not.toContain("preference row");
|
|
1288
|
+
expect(Object.hasOwn(visibleToHidden, "hideThinkingBlock")).toBe(true);
|
|
1289
|
+
expect(visibleToHiddenState.hideThinkingBlock).toBe(true);
|
|
1290
|
+
const visibleNative = plain(hiddenToVisible.render(80)).join("\n");
|
|
1291
|
+
expect(visibleNative).toContain("preference row 1");
|
|
1292
|
+
expect(visibleNative).not.toMatch(/Thinking \d|Thought(?: for)?/);
|
|
1293
|
+
expect(Object.hasOwn(hiddenToVisible, "hideThinkingBlock")).toBe(true);
|
|
1294
|
+
expect(hiddenToVisibleState.hideThinkingBlock).toBe(false);
|
|
1295
|
+
|
|
1296
|
+
value.shutdown();
|
|
1297
|
+
expect(visibleToHiddenState.hideThinkingBlock).toBe(true);
|
|
1298
|
+
expect(hiddenToVisibleState.hideThinkingBlock).toBe(false);
|
|
1299
|
+
for (const method of Object.values(host.forbidden)) expect(method).not.toHaveBeenCalled();
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
it("honors discovered remaps, falls back only without a definition, and ignores key releases", () => {
|
|
1303
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
1304
|
+
const config: ThinkingStepsComponentConfig = {
|
|
1305
|
+
enabled: true,
|
|
1306
|
+
mode: "streaming",
|
|
1307
|
+
};
|
|
1308
|
+
const remapped = {
|
|
1309
|
+
getDefinition: vi.fn(() => ({ defaultKeys: "ctrl+y" })),
|
|
1310
|
+
getKeys: vi.fn(() => ["ctrl+y"]),
|
|
1311
|
+
matches: vi.fn((data: string) => data === "\x19" || data.includes("121;5")),
|
|
1312
|
+
};
|
|
1313
|
+
const host = context();
|
|
1314
|
+
const value = controller(
|
|
1315
|
+
config,
|
|
1316
|
+
Date.now,
|
|
1317
|
+
vi.fn(),
|
|
1318
|
+
() => remapped,
|
|
1319
|
+
() => "ctrl+y",
|
|
1320
|
+
);
|
|
1321
|
+
value.startSession(host.ctx);
|
|
1322
|
+
const assistant = component();
|
|
1323
|
+
assistant.updateContent(message("remapped reasoning", 1_000), true);
|
|
1324
|
+
expect(host.input("\x14")).toBeUndefined();
|
|
1325
|
+
expect(plain(assistant.render(80)).join("\n")).toContain("Thinking");
|
|
1326
|
+
expect(host.input("\x19")).toEqual({ consume: true });
|
|
1327
|
+
expect(plain(assistant.render(80)).join("\n")).toContain("remapped reasoning");
|
|
1328
|
+
expect(host.input("\x1b[121;5:3u")).toEqual({ consume: true });
|
|
1329
|
+
expect(plain(assistant.render(80)).join("\n")).toContain("remapped reasoning");
|
|
1330
|
+
expect(host.input("\x19")).toEqual({ consume: true });
|
|
1331
|
+
expect(plain(assistant.render(80)).join("\n")).toContain("Thinking");
|
|
1332
|
+
|
|
1333
|
+
value.shutdown();
|
|
1334
|
+
controllers.delete(value);
|
|
1335
|
+
const fallbackHost = context();
|
|
1336
|
+
const fallback = controller(config, Date.now, vi.fn(), () => ({
|
|
1337
|
+
getDefinition: () => undefined,
|
|
1338
|
+
matches: () => false,
|
|
1339
|
+
}));
|
|
1340
|
+
fallback.startSession(fallbackHost.ctx);
|
|
1341
|
+
const fallbackAssistant = component();
|
|
1342
|
+
fallbackAssistant.updateContent(message("fallback reasoning", 2_000), true);
|
|
1343
|
+
expect(fallbackHost.input("\x14")).toEqual({ consume: true });
|
|
1344
|
+
expect(plain(fallbackAssistant.render(80)).join("\n")).toContain("fallback reasoning");
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
it("accepts symbol and modified-symbol bindings and uses labels only when provided", () => {
|
|
1348
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
1349
|
+
const config: ThinkingStepsComponentConfig = {
|
|
1350
|
+
enabled: true,
|
|
1351
|
+
mode: "streaming",
|
|
1352
|
+
};
|
|
1353
|
+
const cases = [
|
|
1354
|
+
{ key: "?", data: "question", keyText: "Question mark", label: "Question mark" },
|
|
1355
|
+
{ key: "/", data: "slash", keyText: "", label: "/" },
|
|
1356
|
+
{ key: "-", data: "minus", keyText: " ", label: "-" },
|
|
1357
|
+
{ key: "ctrl+]", data: "modified-bracket", keyText: "Ctrl+]", label: "Ctrl+]" },
|
|
1358
|
+
] as const;
|
|
1359
|
+
for (const testCase of cases) {
|
|
1360
|
+
const host = context();
|
|
1361
|
+
const bindings = {
|
|
1362
|
+
getDefinition: vi.fn(() => ({})),
|
|
1363
|
+
getKeys: vi.fn(() => [testCase.key]),
|
|
1364
|
+
matches: vi.fn((data: string) => data === testCase.data),
|
|
1365
|
+
};
|
|
1366
|
+
const value = controller(
|
|
1367
|
+
config,
|
|
1368
|
+
Date.now,
|
|
1369
|
+
vi.fn(),
|
|
1370
|
+
() => bindings,
|
|
1371
|
+
() => testCase.keyText,
|
|
1372
|
+
);
|
|
1373
|
+
expect(value.startSession(host.ctx)).toEqual({ applied: true });
|
|
1374
|
+
const assistant = component();
|
|
1375
|
+
assistant.updateContent(
|
|
1376
|
+
message(Array.from({ length: 8 }, (_, index) => `symbol row ${index + 1}`).join("\n")),
|
|
1377
|
+
true,
|
|
1378
|
+
);
|
|
1379
|
+
expect(plain(assistant.render(80)).join("\n")).toContain(`(${testCase.label} to expand)`);
|
|
1380
|
+
expect(host.input(testCase.data)).toEqual({ consume: true });
|
|
1381
|
+
expect(bindings.matches).toHaveBeenCalledWith(testCase.data, "app.thinking.toggle");
|
|
1382
|
+
expect(plain(assistant.render(80)).join("\n")).toContain("symbol row 1");
|
|
1383
|
+
value.shutdown();
|
|
1384
|
+
controllers.delete(value);
|
|
1385
|
+
}
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
it("rejects empty and nonstring bindings and deactivates on a throwing matcher", () => {
|
|
1389
|
+
const config: ThinkingStepsComponentConfig = {
|
|
1390
|
+
enabled: true,
|
|
1391
|
+
mode: "streaming",
|
|
1392
|
+
};
|
|
1393
|
+
for (const keys of [[], [""], [null], [42]]) {
|
|
1394
|
+
const value = controller(config, Date.now, vi.fn(), () => ({
|
|
1395
|
+
getDefinition: () => ({}),
|
|
1396
|
+
getKeys: () => keys as unknown as string[],
|
|
1397
|
+
matches: () => false,
|
|
1398
|
+
}));
|
|
1399
|
+
expect(value.startSession(context().ctx)).toMatchObject({
|
|
1400
|
+
applied: false,
|
|
1401
|
+
reason: expect.stringContaining("no usable configured binding"),
|
|
1402
|
+
});
|
|
1403
|
+
expect(value.state).toMatchObject({
|
|
1404
|
+
rendererAvailable: true,
|
|
1405
|
+
streamingAvailable: false,
|
|
1406
|
+
active: false,
|
|
1407
|
+
});
|
|
1408
|
+
value.shutdown();
|
|
1409
|
+
controllers.delete(value);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
1413
|
+
const host = context();
|
|
1414
|
+
const throwing = controller(
|
|
1415
|
+
config,
|
|
1416
|
+
Date.now,
|
|
1417
|
+
vi.fn(),
|
|
1418
|
+
() => ({
|
|
1419
|
+
getDefinition: () => ({}),
|
|
1420
|
+
getKeys: () => ["ctrl+y"],
|
|
1421
|
+
matches: () => {
|
|
1422
|
+
throw new Error("matcher failure");
|
|
1423
|
+
},
|
|
1424
|
+
}),
|
|
1425
|
+
() => "ctrl+y",
|
|
1426
|
+
);
|
|
1427
|
+
expect(throwing.startSession(host.ctx)).toEqual({ applied: true });
|
|
1428
|
+
const assistant = component();
|
|
1429
|
+
assistant.updateContent(message("must restore native", 3_000), true);
|
|
1430
|
+
expect(plain(assistant.render(80)).join("\n")).toContain("Thinking");
|
|
1431
|
+
expect(host.input("\x19")).toBeUndefined();
|
|
1432
|
+
expect(throwing.state).toMatchObject({
|
|
1433
|
+
rendererAvailable: true,
|
|
1434
|
+
streamingAvailable: false,
|
|
1435
|
+
active: false,
|
|
1436
|
+
reason: "Pi's thinking-toggle matcher failed at runtime",
|
|
1437
|
+
});
|
|
1438
|
+
const restored = plain(assistant.render(80)).join("\n");
|
|
1439
|
+
expect(restored).toContain("must restore native");
|
|
1440
|
+
expect(restored).not.toMatch(/Thinking \d|Thought(?: for)?/);
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
it("bounds settled history, restores an evicted native-hidden fold, and times only active refs", () => {
|
|
1444
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
1445
|
+
vi.useFakeTimers();
|
|
1446
|
+
vi.setSystemTime(10_000);
|
|
1447
|
+
const config: ThinkingStepsComponentConfig = {
|
|
1448
|
+
enabled: true,
|
|
1449
|
+
mode: "streaming",
|
|
1450
|
+
};
|
|
1451
|
+
const requestRender = vi.fn();
|
|
1452
|
+
const value = controller(config, Date.now, requestRender);
|
|
1453
|
+
expect(value.startSession(context().ctx)).toEqual({ applied: true });
|
|
1454
|
+
const first = component(true);
|
|
1455
|
+
first.updateContent(message("evicted native-hidden reasoning", 1), false);
|
|
1456
|
+
for (let index = 1; index <= THINKING_EXPERIMENTAL_MAX_TRACKED_COMPONENTS; index += 1) {
|
|
1457
|
+
component().updateContent(message(`settled reasoning ${index}`, index + 1), false);
|
|
1458
|
+
}
|
|
1459
|
+
expect(value.diagnostics).toMatchObject({
|
|
1460
|
+
trackedComponents: THINKING_EXPERIMENTAL_MAX_TRACKED_COMPONENTS,
|
|
1461
|
+
activeComponents: 0,
|
|
1462
|
+
});
|
|
1463
|
+
const restored = plain(first.render(80)).join("\n");
|
|
1464
|
+
expect(restored).toContain("Thinking...");
|
|
1465
|
+
expect(restored).not.toContain("evicted native-hidden reasoning");
|
|
1466
|
+
expect((first as unknown as { hideThinkingBlock: boolean }).hideThinkingBlock).toBe(true);
|
|
1467
|
+
|
|
1468
|
+
const live = component();
|
|
1469
|
+
const pending = message("only active timer work", 50_000);
|
|
1470
|
+
(pending as { stopReason: string }).stopReason = "pending";
|
|
1471
|
+
live.updateContent(pending, true);
|
|
1472
|
+
expect(value.diagnostics).toMatchObject({
|
|
1473
|
+
trackedComponents: THINKING_EXPERIMENTAL_MAX_TRACKED_COMPONENTS,
|
|
1474
|
+
activeComponents: 1,
|
|
1475
|
+
});
|
|
1476
|
+
vi.advanceTimersByTime(1_000);
|
|
1477
|
+
expect(value.diagnostics.lastTimerWork).toBe(1);
|
|
1478
|
+
value.updateMessage({ message: pending, assistantMessageEvent: { type: "text_delta" } });
|
|
1479
|
+
expect(value.diagnostics.activeComponents).toBe(0);
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
it("bounds current-session timings even for text-only assistant events", () => {
|
|
1483
|
+
const config: ThinkingStepsComponentConfig = {
|
|
1484
|
+
enabled: true,
|
|
1485
|
+
mode: "streaming",
|
|
1486
|
+
};
|
|
1487
|
+
const value = controller(config, () => 50_000);
|
|
1488
|
+
value.startSession(context().ctx);
|
|
1489
|
+
for (let index = 0; index < 300; index += 1) {
|
|
1490
|
+
const textOnly = message("", index + 1, `answer ${index}`);
|
|
1491
|
+
textOnly.content = [{ type: "text", text: `answer ${index}` }];
|
|
1492
|
+
value.beginMessage({ message: textOnly });
|
|
1493
|
+
value.endMessage({ message: textOnly });
|
|
1494
|
+
}
|
|
1495
|
+
const timings = (value as unknown as { timings: Map<number, unknown> }).timings;
|
|
1496
|
+
expect(timings.size).toBe(256);
|
|
1497
|
+
expect(timings.has(1)).toBe(false);
|
|
1498
|
+
expect(timings.has(300)).toBe(true);
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
it("derives a pending live render without a boolean argument and keeps completed messages settled", () => {
|
|
1502
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
1503
|
+
const config: ThinkingStepsComponentConfig = {
|
|
1504
|
+
enabled: true,
|
|
1505
|
+
mode: "streaming",
|
|
1506
|
+
};
|
|
1507
|
+
const value = controller(config, () => 5_000);
|
|
1508
|
+
value.startSession(context().ctx);
|
|
1509
|
+
const live = message("live no-argument reasoning", 4_000);
|
|
1510
|
+
(live as { stopReason: string }).stopReason = "pending";
|
|
1511
|
+
const liveAssistant = component();
|
|
1512
|
+
liveAssistant.updateContent(live);
|
|
1513
|
+
expect(plain(liveAssistant.render(80)).join("\n")).toContain("Thinking 0.0s");
|
|
1514
|
+
|
|
1515
|
+
const completed = component();
|
|
1516
|
+
completed.updateContent(message("completed no-argument reasoning", 3_000));
|
|
1517
|
+
expect(plain(completed.render(80)).join("\n")).toContain("Thought");
|
|
1518
|
+
expect(plain(completed.render(80)).join("\n")).not.toContain("Thinking 0.0s");
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
it("logically displaces an older same-adapter controller while preserving the shared wrapper", () => {
|
|
1522
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
1523
|
+
const config: ThinkingStepsComponentConfig = {
|
|
1524
|
+
enabled: true,
|
|
1525
|
+
mode: "streaming",
|
|
1526
|
+
};
|
|
1527
|
+
const firstHost = context();
|
|
1528
|
+
const firstRender = vi.fn();
|
|
1529
|
+
const first = controller(config, Date.now, firstRender);
|
|
1530
|
+
first.startSession(firstHost.ctx);
|
|
1531
|
+
const wrapper = prototype.updateContent;
|
|
1532
|
+
const assistant = component();
|
|
1533
|
+
assistant.updateContent(
|
|
1534
|
+
message(
|
|
1535
|
+
Array.from({ length: 8 }, (_, index) => `first owner row ${index + 1} `).join("\n"),
|
|
1536
|
+
1_000,
|
|
1537
|
+
),
|
|
1538
|
+
false,
|
|
1539
|
+
);
|
|
1540
|
+
expect(plain(assistant.render(80)).join("\n")).toContain("Thought");
|
|
1541
|
+
|
|
1542
|
+
const secondHost = context();
|
|
1543
|
+
const secondRender = vi.fn();
|
|
1544
|
+
const second = controller(config, Date.now, secondRender);
|
|
1545
|
+
expect(second.startSession(secondHost.ctx)).toEqual({ applied: true });
|
|
1546
|
+
expect(prototype.updateContent).toBe(wrapper);
|
|
1547
|
+
expect(firstHost.input("\x14")).toBeUndefined();
|
|
1548
|
+
expect(first.state).toMatchObject({ active: false, displaced: true });
|
|
1549
|
+
const restored = plain(assistant.render(80)).join("\n");
|
|
1550
|
+
expect(restored).not.toMatch(/Thinking \d|Thought(?: for)?/);
|
|
1551
|
+
expect(restored).toContain("first owner row 1");
|
|
1552
|
+
expect(restored).toContain("first owner row 8");
|
|
1553
|
+
expect(firstRender).toHaveBeenCalledTimes(1);
|
|
1554
|
+
expect(secondRender).toHaveBeenCalledTimes(0);
|
|
1555
|
+
expect(second.state).toMatchObject({ active: true, displaced: false });
|
|
1556
|
+
first.shutdown();
|
|
1557
|
+
controllers.delete(first);
|
|
1558
|
+
expect(prototype.updateContent).toBe(wrapper);
|
|
1559
|
+
second.shutdown();
|
|
1560
|
+
controllers.delete(second);
|
|
1561
|
+
expect(prototype.updateContent).not.toBe(wrapper);
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
it("aborts a failed multi-component fold and restores every original hidden state once", () => {
|
|
1565
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
1566
|
+
const config: ThinkingStepsComponentConfig = {
|
|
1567
|
+
enabled: true,
|
|
1568
|
+
mode: "streaming",
|
|
1569
|
+
};
|
|
1570
|
+
const host = context();
|
|
1571
|
+
const requestRender = vi.fn();
|
|
1572
|
+
const value = controller(config, () => 5_000, requestRender);
|
|
1573
|
+
value.startSession(host.ctx);
|
|
1574
|
+
const early = component();
|
|
1575
|
+
const laterHidden = component(true);
|
|
1576
|
+
const earlyMessage = message("early original reasoning", 1_000);
|
|
1577
|
+
const hiddenMessage = message("later native hidden reasoning", 2_000);
|
|
1578
|
+
early.updateContent(earlyMessage, true);
|
|
1579
|
+
laterHidden.updateContent(hiddenMessage, true);
|
|
1580
|
+
|
|
1581
|
+
const states = (
|
|
1582
|
+
value as unknown as {
|
|
1583
|
+
states: WeakMap<object, { args: unknown[] }>;
|
|
1584
|
+
}
|
|
1585
|
+
).states;
|
|
1586
|
+
const earlyState = states.get(early);
|
|
1587
|
+
expect(earlyState).toBeDefined();
|
|
1588
|
+
if (earlyState) earlyState.args = [message("incompatible replacement", 1_000), true];
|
|
1589
|
+
value.updateMessage({
|
|
1590
|
+
message: earlyMessage,
|
|
1591
|
+
assistantMessageEvent: { type: "text_delta" },
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
expect(value.state).toMatchObject({ available: false, active: false });
|
|
1595
|
+
expect(requestRender).toHaveBeenCalledTimes(1);
|
|
1596
|
+
const earlyNative = plain(early.render(80)).join("\n");
|
|
1597
|
+
expect(earlyNative).toContain("incompatible replacement");
|
|
1598
|
+
expect(earlyNative).not.toMatch(/Thinking \d|Thought(?: for)?/);
|
|
1599
|
+
const laterNative = plain(laterHidden.render(80)).join("\n");
|
|
1600
|
+
expect(laterNative).toContain("Thinking...");
|
|
1601
|
+
expect(laterNative).not.toContain("later native hidden reasoning");
|
|
1602
|
+
expect((laterHidden as unknown as { hideThinkingBlock: boolean }).hideThinkingBlock).toBe(true);
|
|
1603
|
+
});
|
|
1604
|
+
|
|
1605
|
+
it("makes a permanent renderer-shape failure reject retries", () => {
|
|
1606
|
+
const native = originalDescriptor?.value as (this: unknown, ...args: unknown[]) => unknown;
|
|
1607
|
+
Object.defineProperty(prototype, "updateContent", {
|
|
1608
|
+
...originalDescriptor,
|
|
1609
|
+
value: function incompatible(this: { contentContainer?: { children?: object[] } }) {
|
|
1610
|
+
this.contentContainer = { children: [] };
|
|
1611
|
+
},
|
|
1612
|
+
});
|
|
1613
|
+
const config: ThinkingStepsComponentConfig = {
|
|
1614
|
+
enabled: true,
|
|
1615
|
+
mode: "streaming",
|
|
1616
|
+
};
|
|
1617
|
+
const host = context();
|
|
1618
|
+
const value = controller(config);
|
|
1619
|
+
expect(value.startSession(host.ctx)).toEqual({ applied: true });
|
|
1620
|
+
Reflect.apply(prototype.updateContent, {}, [message("incompatible")]);
|
|
1621
|
+
expect(value.state).toMatchObject({ available: false, active: false });
|
|
1622
|
+
expect(value.startSession(host.ctx)).toMatchObject({ applied: false });
|
|
1623
|
+
expect(prototype.updateContent).not.toBe(native);
|
|
1624
|
+
});
|
|
1625
|
+
|
|
1626
|
+
it("fails open on foreign displacement and compare-and-swap cleanup preserves the successor", () => {
|
|
1627
|
+
bridgeSourceLoadedMarkdownIdentity();
|
|
1628
|
+
const config: ThinkingStepsComponentConfig = {
|
|
1629
|
+
enabled: true,
|
|
1630
|
+
mode: "streaming",
|
|
1631
|
+
};
|
|
1632
|
+
const host = context();
|
|
1633
|
+
const requestRender = vi.fn();
|
|
1634
|
+
const value = controller(config, Date.now, requestRender);
|
|
1635
|
+
value.startSession(host.ctx);
|
|
1636
|
+
const assistant = component();
|
|
1637
|
+
assistant.updateContent(
|
|
1638
|
+
message(
|
|
1639
|
+
Array.from({ length: 8 }, (_, index) => `displaced native row ${index + 1} `).join("\n"),
|
|
1640
|
+
1_000,
|
|
1641
|
+
),
|
|
1642
|
+
false,
|
|
1643
|
+
);
|
|
1644
|
+
expect(plain(assistant.render(80)).join("\n")).toContain("Thought");
|
|
1645
|
+
const zentuiWrapper = prototype.updateContent;
|
|
1646
|
+
const foreign = function foreignUpdate(this: unknown, ...args: unknown[]) {
|
|
1647
|
+
return Reflect.apply(zentuiWrapper, this, args);
|
|
1648
|
+
};
|
|
1649
|
+
Object.defineProperty(prototype, "updateContent", {
|
|
1650
|
+
...Object.getOwnPropertyDescriptor(prototype, "updateContent"),
|
|
1651
|
+
value: foreign,
|
|
1652
|
+
});
|
|
1653
|
+
expect(value.state).toMatchObject({
|
|
1654
|
+
available: false,
|
|
1655
|
+
active: false,
|
|
1656
|
+
displaced: true,
|
|
1657
|
+
restartRequired: true,
|
|
1658
|
+
});
|
|
1659
|
+
const restored = plain(assistant.render(80)).join("\n");
|
|
1660
|
+
expect(restored).not.toMatch(/Thinking \d|Thought(?: for)?/);
|
|
1661
|
+
expect(restored).toContain("displaced native row 1");
|
|
1662
|
+
expect(restored).toContain("displaced native row 8");
|
|
1663
|
+
expect(requestRender).toHaveBeenCalledTimes(1);
|
|
1664
|
+
expect(host.stopInput).toHaveBeenCalledTimes(1);
|
|
1665
|
+
value.shutdown();
|
|
1666
|
+
expect(prototype.updateContent).toBe(foreign);
|
|
1667
|
+
});
|
|
1668
|
+
});
|