@firstpick/pi-package-webui 0.5.4 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/pi-webui.mjs +55 -3
- package/package.json +4 -3
- package/public/app.js +1155 -100
- package/public/index.html +2 -2
- package/public/styles.css +307 -1
- package/tests/http-endpoints-harness.test.mjs +15 -1
- package/tests/mobile-static.test.mjs +49 -14
- package/tests/streaming-ui-coupling.test.mjs +175 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
7
|
+
const [app, doc] = await Promise.all([
|
|
8
|
+
readFile(join(root, "public", "app.js"), "utf8"),
|
|
9
|
+
readFile(join(root, "docs", "streaming-ui-coupling.md"), "utf8"),
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
function findFunctionBody(source, name) {
|
|
13
|
+
const signature = new RegExp(`function\\s+${name}\\s*\\(`, "m");
|
|
14
|
+
const match = signature.exec(source);
|
|
15
|
+
assert.ok(match, `${name} should be defined`);
|
|
16
|
+
let parenDepth = 0;
|
|
17
|
+
let openBrace = -1;
|
|
18
|
+
for (let index = match.index + match[0].length - 1; index < source.length; index += 1) {
|
|
19
|
+
const char = source[index];
|
|
20
|
+
if (char === "(") parenDepth += 1;
|
|
21
|
+
else if (char === ")") parenDepth -= 1;
|
|
22
|
+
else if (char === "{" && parenDepth === 0) {
|
|
23
|
+
openBrace = index;
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
assert.notEqual(openBrace, -1, `${name} body should open`);
|
|
28
|
+
let depth = 0;
|
|
29
|
+
for (let index = openBrace; index < source.length; index += 1) {
|
|
30
|
+
const char = source[index];
|
|
31
|
+
if (char === "{") depth += 1;
|
|
32
|
+
else if (char === "}") {
|
|
33
|
+
depth -= 1;
|
|
34
|
+
if (depth === 0) return source.slice(openBrace + 1, index);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
assert.fail(`${name} body should close`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function findCaseBody(source, caseLabel) {
|
|
41
|
+
const caseStart = source.indexOf(`case "${caseLabel}":`);
|
|
42
|
+
assert.notEqual(caseStart, -1, `case ${caseLabel} should exist`);
|
|
43
|
+
const nextCase = source.indexOf("\n case ", caseStart + 1);
|
|
44
|
+
const defaultCase = source.indexOf("\n default:", caseStart + 1);
|
|
45
|
+
const candidates = [nextCase, defaultCase].filter((index) => index !== -1);
|
|
46
|
+
const end = candidates.length ? Math.min(...candidates) : source.length;
|
|
47
|
+
return source.slice(caseStart, end);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function assertDocTheory(id, titleFragment) {
|
|
51
|
+
assert.match(doc, new RegExp(`^## ${id}\\. .*${titleFragment}`, "m"), `documentation should include theory ${id}: ${titleFragment}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const futureFailures = [];
|
|
55
|
+
function futureInvariant(name, assertion) {
|
|
56
|
+
try {
|
|
57
|
+
assertion();
|
|
58
|
+
} catch (error) {
|
|
59
|
+
futureFailures.push(`${name}\n ${error.message}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Keep the test suite explicitly tied to the audit theories it enforces.
|
|
64
|
+
assertDocTheory(0, "Live todo-progress widget rebuild");
|
|
65
|
+
assertDocTheory(1, "scrollChatToBottom");
|
|
66
|
+
assertDocTheory(2, "O\\(n²\\) re-parse");
|
|
67
|
+
assertDocTheory(3, "markdown re-render fallback");
|
|
68
|
+
assertDocTheory(4, "setRunIndicatorActivity");
|
|
69
|
+
assertDocTheory(5, "ingestEventTabActivity");
|
|
70
|
+
assertDocTheory(6, "markTabOutputSeen");
|
|
71
|
+
assertDocTheory(7, "Skill / auto-retry tracking");
|
|
72
|
+
assertDocTheory(8, "steer prompt");
|
|
73
|
+
|
|
74
|
+
const syncLiveTodoProgressWidgetFromText = findFunctionBody(app, "syncLiveTodoProgressWidgetFromText");
|
|
75
|
+
const scheduleLiveWidgetRender = findFunctionBody(app, "scheduleLiveWidgetRender");
|
|
76
|
+
const handleMessageUpdate = findFunctionBody(app, "handleMessageUpdate");
|
|
77
|
+
const scrollChatToBottom = findFunctionBody(app, "scrollChatToBottom");
|
|
78
|
+
const stripTodoProgressLines = findFunctionBody(app, "stripTodoProgressLines");
|
|
79
|
+
const liveTodoProgressWidgetLinesFromText = findFunctionBody(app, "liveTodoProgressWidgetLinesFromText");
|
|
80
|
+
const syncStreamingThinkingFormat = findFunctionBody(app, "syncStreamingThinkingFormat");
|
|
81
|
+
const renderStreamingAssistantText = findFunctionBody(app, "renderStreamingAssistantText");
|
|
82
|
+
const renderStreamingMarkdown = findFunctionBody(app, "renderStreamingMarkdown");
|
|
83
|
+
const setRunIndicatorActivity = findFunctionBody(app, "setRunIndicatorActivity");
|
|
84
|
+
const ingestEventTabActivity = findFunctionBody(app, "ingestEventTabActivity");
|
|
85
|
+
const handleEvent = findFunctionBody(app, "handleEvent");
|
|
86
|
+
const markTabOutputSeen = findFunctionBody(app, "markTabOutputSeen");
|
|
87
|
+
const trackSkillsFromEvent = findFunctionBody(app, "trackSkillsFromEvent");
|
|
88
|
+
const trackAutoRetryStateFromEvent = findFunctionBody(app, "trackAutoRetryStateFromEvent");
|
|
89
|
+
const requestGitFooterWebuiPayload = findFunctionBody(app, "requestGitFooterWebuiPayload");
|
|
90
|
+
|
|
91
|
+
// Fixed theory #0 should stay fixed while the remaining tests fail until their
|
|
92
|
+
// corresponding stream/UI coupling issues are removed.
|
|
93
|
+
assert.doesNotMatch(
|
|
94
|
+
syncLiveTodoProgressWidgetFromText,
|
|
95
|
+
/(^|\n)\s*updateOptionalFeatureAvailability\s*\(/,
|
|
96
|
+
"fixed theory #0: live todo-progress sync must not reconcile optional-feature chrome per token",
|
|
97
|
+
);
|
|
98
|
+
assert.match(syncLiveTodoProgressWidgetFromText, /scheduleLiveWidgetRender\s*\(/, "fixed theory #0: live todo-progress widget rendering should remain scheduler-based");
|
|
99
|
+
assert.match(scheduleLiveWidgetRender, /requestAnimationFrame\s*\(/, "fixed theory #0: live widget rebuilds should remain coalesced to animation frames");
|
|
100
|
+
assert.match(scheduleLiveWidgetRender, /liveWidgetRenderFrame !== null/, "fixed theory #0: repeated tokens in one frame should not queue duplicate widget rebuilds");
|
|
101
|
+
|
|
102
|
+
futureInvariant("theory #1: message_update streaming hot path must not call immediate scroll/layout work", () => {
|
|
103
|
+
assert.doesNotMatch(handleMessageUpdate, /scrollChatToBottom\s*\(/, "handleMessageUpdate should schedule/coalesce follow-scroll instead of calling scrollChatToBottom() directly");
|
|
104
|
+
});
|
|
105
|
+
futureInvariant("theory #1: scrollChatToBottom must not synchronously read scrollHeight and write scrollTop", () => {
|
|
106
|
+
assert.doesNotMatch(scrollChatToBottom, /setChatScrollTopInstant\(elements\.chat\.scrollHeight\)/, "scrollChatToBottom should route layout-sensitive scroll work through a frame-coalesced flusher");
|
|
107
|
+
});
|
|
108
|
+
futureInvariant("theory #1: disabled auto-follow must not refresh jump/sticky layout from the token path", () => {
|
|
109
|
+
assert.doesNotMatch(scrollChatToBottom, /!autoFollowChat[\s\S]*?updateJumpToLatestButton\(\)[\s\S]*?updateStickyUserPromptButton\(\)/, "jump/sticky button layout reads should be debounced or frame-coalesced, not run per token");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
futureInvariant("theory #2: text deltas must not re-read the full accumulated assistant message", () => {
|
|
113
|
+
assert.doesNotMatch(handleMessageUpdate, /assistantTextFromMessage\(assistantStreamingMessage\(event\), \{ streaming: true \}\)/, "text_delta should process only the new delta tail or a cached parse state");
|
|
114
|
+
});
|
|
115
|
+
futureInvariant("theory #2: thinking deltas must not re-read the full accumulated assistant message", () => {
|
|
116
|
+
assert.doesNotMatch(handleMessageUpdate, /assistantThinkingTextFromMessage\(assistantStreamingMessage\(event\), \{ streaming: true \}\)/, "thinking_delta should process only the new delta tail or a cached parse state");
|
|
117
|
+
});
|
|
118
|
+
futureInvariant("theory #2: streaming todo stripping must not split the full accumulated stream each render", () => {
|
|
119
|
+
assert.doesNotMatch(stripTodoProgressLines, /raw\.split\(\/\\r\?\\n\//, "stripTodoProgressLines should be incremental/cached for streaming input");
|
|
120
|
+
});
|
|
121
|
+
futureInvariant("theory #2: live todo widget extraction must not split the full accumulated stream each token", () => {
|
|
122
|
+
assert.doesNotMatch(liveTodoProgressWidgetLinesFromText, /raw\.split\(\/\\r\?\\n\//, "liveTodoProgressWidgetLinesFromText should process the new tail or cached block state");
|
|
123
|
+
});
|
|
124
|
+
futureInvariant("theory #2: thinking-format parsing must not reparse the full accumulated assistant text", () => {
|
|
125
|
+
assert.doesNotMatch(syncStreamingThinkingFormat, /splitThinkingFormatText\(assistantText, \{ streaming: true \}\)/, "syncStreamingThinkingFormat should use incremental/cached parsing while streaming");
|
|
126
|
+
});
|
|
127
|
+
futureInvariant("theory #2: streaming assistant render must not derive all views from streamRawText on every render", () => {
|
|
128
|
+
assert.doesNotMatch(renderStreamingAssistantText, /stripTodoProgressLines\(streamRawText, \{ streaming: true \}\)/, "renderStreamingAssistantText should consume cached/incremental visible-text state instead of rescanning streamRawText");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
futureInvariant("theory #3: streaming markdown must not full-rebuild when earlier derived text changes", () => {
|
|
132
|
+
assert.doesNotMatch(renderStreamingMarkdown, /!text\.startsWith\(state\.stableText\)[\s\S]*?block\.replaceChildren\(\)/, "retroactive todo/thinking rewrites should be confined to an unstable tail, not block.replaceChildren()");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
futureInvariant("theory #4: run-indicator activity changes must not render/scroll synchronously from token paths", () => {
|
|
136
|
+
assert.doesNotMatch(setRunIndicatorActivity, /if \(needsRender\) renderRunIndicator\(\{ scroll \}\)/, "setRunIndicatorActivity should schedule/coalesce indicator rendering instead of rendering immediately");
|
|
137
|
+
});
|
|
138
|
+
futureInvariant("theory #4: run-indicator token updates must not touch composer chrome unconditionally", () => {
|
|
139
|
+
assert.doesNotMatch(setRunIndicatorActivity, /updateComposerModeButtons\(\)/, "composer mode button reconciliation should be gated/coalesced outside steady-state token updates");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
futureInvariant("theory #5: tab activity ingestion must not rebuild tabs synchronously per event", () => {
|
|
143
|
+
assert.doesNotMatch(ingestEventTabActivity, /if \(changed\) renderTabs\(\)/, "tab chrome should be updated via a frame-coalesced affected-tab render, not renderTabs() directly");
|
|
144
|
+
});
|
|
145
|
+
futureInvariant("theory #5: handleEvent must not run tab chrome ingestion for every raw server event", () => {
|
|
146
|
+
assert.doesNotMatch(handleEvent, /^\s*ingestEventTabActivity\(event\);/m, "tab activity ingestion should be filtered/coalesced before global event dispatch touches chrome");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
futureInvariant("theory #6: output-seen tab refresh should remain out of the message_update token path", () => {
|
|
150
|
+
assert.doesNotMatch(handleMessageUpdate, /markTabOutputSeen\s*\(/, "markTabOutputSeen should stay event-end driven, not token driven");
|
|
151
|
+
});
|
|
152
|
+
futureInvariant("theory #6: event-end output-seen refresh should not synchronously rebuild all tabs", () => {
|
|
153
|
+
assert.doesNotMatch(markTabOutputSeen, /renderTabs\(\)/, "output-seen serial changes should schedule/coalesce tab chrome updates");
|
|
154
|
+
});
|
|
155
|
+
assert.match(findCaseBody(handleEvent, "agent_end"), /markTabOutputSeen\(\)/, "theory #6: output-seen marking should still happen when a run ends");
|
|
156
|
+
assert.match(findCaseBody(handleEvent, "compaction_end"), /markTabOutputSeen\(\)/, "theory #6: output-seen marking should still happen when compaction ends");
|
|
157
|
+
|
|
158
|
+
futureInvariant("theory #7: skill tracking must not inspect every message_update event", () => {
|
|
159
|
+
assert.doesNotMatch(trackSkillsFromEvent, /event\.type === "message_update"/, "skill tracking should be event-filtered so plain text/thinking deltas do not enter tracking code");
|
|
160
|
+
});
|
|
161
|
+
futureInvariant("theory #7: auto-retry and skill tracking must not run before every event dispatch", () => {
|
|
162
|
+
assert.doesNotMatch(handleEvent, /^\s*trackAutoRetryStateFromEvent\(event\);\n\s*trackSkillsFromEvent\(event\);/m, "tracking hooks should be case-specific or pre-filtered, not invoked for every server event");
|
|
163
|
+
});
|
|
164
|
+
assert.match(trackAutoRetryStateFromEvent, /event\.type === "auto_retry_start"/, "theory #7: auto-retry bookkeeping should remain scoped to retry events");
|
|
165
|
+
|
|
166
|
+
// Theory #8 is a correctness guard: the current design still uses a steer prompt,
|
|
167
|
+
// but it must never run while the agent is active.
|
|
168
|
+
assert.match(requestGitFooterWebuiPayload, /currentState\?\.isStreaming \|\| currentState\?\.isCompacting/, "theory #8: git-footer steer refresh must remain guarded during active streaming/compaction");
|
|
169
|
+
assert.match(findCaseBody(handleEvent, "agent_end"), /currentState\) currentState = \{ \.\.\.currentState, isStreaming: false \};[\s\S]*?requestGitFooterWebuiPayload\(tabContext, \{ force: true \}\)/, "theory #8: forced git-footer refresh should only happen after agent_end clears streaming state");
|
|
170
|
+
|
|
171
|
+
if (futureFailures.length) {
|
|
172
|
+
assert.fail(`streaming/UI coupling invariants still failing (${futureFailures.length}):\n\n${futureFailures.map((failure, index) => `${index + 1}. ${failure}`).join("\n\n")}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
console.log("streaming-ui-coupling.test.mjs passed");
|