@sanqianx/project-knowledge 5.0.20 → 5.0.22

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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "project-knowledge",
12
- "version": "5.0.20",
12
+ "version": "5.0.22",
13
13
  "source": "./plugins/project-knowledge",
14
14
  "description": "Search durable project decisions and implementation history through MCP."
15
15
  }
package/CHANGELOG.md CHANGED
@@ -1,6 +1,17 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [5.0.22] - 2026-10-20
4
+
5
+ - 将启动时的 Agent 接入检查和自动修复放入独立进程,避免慢客户端命令阻塞后台就绪通知、网页和实例控制;超时会记录可重试状态,停止服务同时清理自有检查进程。
6
+ - 仅检查用户已连接的客户端;未能核实的状态不再当成未安装并触发自动修复。保留并排工作区、已有接入选择及知识数据。
7
+ - 补充真实后台 CLI 在原生命令阻塞时的就绪、HTTP、状态查询和停止回归,以及超时、崩溃、手动修复结果优先级验证。
8
+
9
+ ## [5.0.21] - 2026-10-20
10
+
11
+ - 开始工作采用并排工作区:从加号新建子代理或按需添加知识引用,使用统一的对话、工具步骤、文件修改和输入框;移除面向客户的任务标识、派发阶段及诊断信息。
12
+ - 子代理首次发送时继承当前项目、Agent、模型及主对话的冻结上下文,之后独立交流;原生任务可从主对话打开,并基于结果继续创建子对话。
13
+ - 知识引用仅保留会话内单一标签,检索不自动展开、不抢焦点;修复刷新时内容提前投递的问题,并跟随产品深浅主题。
14
+ - 主子草稿、标签和阅读位置可恢复;关闭标签不停止任务,窄窗口自动使用抽屉。精确锁定 Workbench 0.13.3。
4
15
 
5
16
  - 默认维护模型明确报告额度不足时,保留并展示可重试的失败原因;请求限流、认证失败分别展示。同项目后续提交等待前一条恢复,失败及排队记录跨重启保留。
6
17
  - 事件消费在分析任务建单前受阻时,活动页与系统状态展示故障,不再只显示空记录;故障及恢复通过现有实时连接更新,稳定状态不触发额外数据轮询。
@@ -58,10 +58,11 @@ async function main() {
58
58
  assert.equal(browser.exceptions.length, 0, JSON.stringify(browser.exceptions));
59
59
  console.log('agent-capture-mcp-ui-test PASS (MCP line rendered separately; hostile detail escaped; no XSS execution)');
60
60
  } finally {
61
+ runtime.integrationStatusScan?.close();
61
62
  await browser?.close(); server.closeAllConnections(); await new Promise(resolve => server.close(resolve));
62
63
  offline.closeAllConnections(); await new Promise(resolve => offline.close(resolve)); await runtime.logger.close();
63
64
  assert.equal(path.dirname(temp), path.resolve(os.tmpdir())); assert.ok(path.basename(temp).startsWith('kb-agent-mcp-ui-'));
64
- fs.rmSync(temp, { recursive: true, force: true, maxRetries: 12, retryDelay: 200 });
65
+ await fs.promises.rm(temp, { recursive: true, force: true, maxRetries: 12, retryDelay: 200 });
65
66
  }
66
67
  }
67
68
  main().catch(error => { console.error(error.stack); process.exitCode = 1; });
@@ -91,10 +91,11 @@ async function main() {
91
91
  assert.equal(browser.exceptions.length, 0, JSON.stringify(browser.exceptions));
92
92
  console.log(`agent-capture-ui-test PASS (1920x1080; single-switch install/repair/Hook/journal/error logs/intent/removal/light+dark; core=${coreRoot})`);
93
93
  } finally {
94
+ runtime.integrationStatusScan?.close();
94
95
  await browser?.close(); server.closeAllConnections(); await new Promise(resolve => server.close(resolve));
95
96
  offline.closeAllConnections(); await new Promise(resolve => offline.close(resolve)); await runtime.logger.close();
96
97
  assert.equal(path.dirname(temp), path.resolve(os.tmpdir())); assert.ok(path.basename(temp).startsWith('kb-agent-capture-ui-'));
97
- fs.rmSync(temp, { recursive: true, force: true, maxRetries: 12, retryDelay: 200 });
98
+ await fs.promises.rm(temp, { recursive: true, force: true, maxRetries: 12, retryDelay: 200 });
98
99
  }
99
100
  }
100
101
  main().catch(error => { console.error(error.stack); process.exitCode = 1; });
@@ -0,0 +1,12 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const root = process.env.KB_LAUNCHER_TEST_PACKAGE || path.resolve(__dirname, '../../..');
5
+ const { IntegrationManager } = require(path.join(root, '_site/lib/integration-installer'));
6
+ IntegrationManager.prototype.statusAll = async function () {
7
+ fs.writeFileSync(process.env.KB_STARTUP_INTEGRATION_MARKER, String(process.pid));
8
+ // Models a synchronous native CLI probe, without running or repairing any
9
+ // real client integration on the developer or CI machine.
10
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 8000);
11
+ return { clients: [{ client: 'codex', developmentCapture: { installed: true } }] };
12
+ };
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+ process.once('message', input => {
3
+ const mode = input.managerOptions.homeDir;
4
+ if (mode === 'crash') return process.exit(1);
5
+ if (mode === 'hang') return setInterval(() => {}, 1000);
6
+ setTimeout(() => {
7
+ process.send({ ok: true, results: {
8
+ claude: { state: 'failed', error: 'native repair failed' },
9
+ codex: { state: 'repaired', error: null },
10
+ zcode: { state: 'failed', error: 'unrequested result' },
11
+ } }, () => process.disconnect());
12
+ }, 80);
13
+ });
@@ -88,5 +88,26 @@ const makeManager = ({ statusClients, onInstall }) => {
88
88
  snapshot.claude = { state: 'tampered' };
89
89
  assert.equal(repair.status().claude.state, 'repaired', 'status() returns a copy');
90
90
  }
91
+ {
92
+ const dir = tmpDataDir();
93
+ markIntent(dir, 'codex', 'disconnected');
94
+ const manager = makeManager({ statusClients: [], onInstall: () => { throw new Error('must not repair'); } });
95
+ const repair = new IntegrationAutoRepair({ dataDir: dir, createManager: () => manager });
96
+ await repair.runOnce();
97
+ assert.equal(manager.calls.statusAll, 0, 'disconnected clients must not run native startup probes');
98
+ markIntent(dir, 'codex', 'connected');
99
+ manager.statusAll = async options => {
100
+ assert.deepEqual(options.clients, ['codex']);
101
+ return { clients: [{ client: 'codex', developmentCapture: { state: 'check-failed' } }] };
102
+ };
103
+ await repair.runOnce();
104
+ assert.equal(manager.calls.installAll.length, 0, 'an unknown status must not be treated as an absent integration');
105
+ manager.statusAll = async () => {
106
+ markIntent(dir, 'codex', 'disconnected');
107
+ return { clients: [{ client: 'codex', developmentCapture: { installed: false } }] };
108
+ };
109
+ await repair.runOnce();
110
+ assert.equal(manager.calls.installAll.length, 0, 'a user disconnect during status scanning must cancel the repair');
111
+ }
91
112
  console.log('integration auto repair test PASS');
92
113
  })().catch(error => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,154 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const vm = require('node:vm');
7
+ const source = fs.readFileSync(path.join(__dirname, '../../ui/product.js'), 'utf8');
8
+ const lifecycle = source.slice(source.indexOf(' function knowledgePanelFailed('), source.indexOf(' function panelRequest('))
9
+ + source.slice(source.indexOf(' async function resetKnowledgePanel('), source.indexOf(' function setView('));
10
+ const settle = async () => { for (let i = 0; i < 5; i++) await new Promise(resolve => setImmediate(resolve)); };
11
+ function harness(options = {}) {
12
+ const calls = [], notices = [], timers = new Map();
13
+ const panel = { providerId: 'knowledge', registered: true, registering: false, failed: false, error: null, panelId: '', version: 0, pending: null, sending: null, queued: false, deliveredPanelId: '' };
14
+ const state = { projectId: 'project-a', epoch: 0 };
15
+ let serial = 0, context;
16
+ const deliver = (id, content = null, error = null) => context.handlePanelState({ panels: id ? [{ providerId: 'knowledge', panelId: id, content, error }] : [] });
17
+ context = vm.createContext({
18
+ knowledgePanel: panel, state, frames: { terminal: { ready: true } },
19
+ knowledgePayload: id => ({ projectId: id, references: [{ title: state.reference || id + '-fact' }] }),
20
+ toast: text => notices.push(text),
21
+ setTimeout(fn) { timers.set(++serial, fn); return serial; }, clearTimeout: id => timers.delete(id),
22
+ panelRequest: async (type, payload) => {
23
+ calls.push({ type, payload });
24
+ if (options.request) {
25
+ const response = options.request(type, payload, { deliver, panel, state });
26
+ if (response !== undefined) return response;
27
+ }
28
+ if (type === 'kb:panel/register') return { ok: true };
29
+ if (type === 'kb:panel/content') {
30
+ deliver(payload.panelId, { ack: { version: payload.version, ok: true } });
31
+ return { ok: true };
32
+ }
33
+ throw new Error('Knowledge delivery cannot open, activate or unregister a view: ' + type);
34
+ },
35
+ });
36
+ vm.runInContext(lifecycle, context);
37
+ const show = async id => { deliver(id); await settle(); };
38
+ return { context, panel, state, calls, notices, timers, deliver, show };
39
+ }
40
+
41
+ test('startup registration and cached updates never create a knowledge tab', async () => {
42
+ const h = harness();
43
+ await h.context.resetKnowledgePanel();
44
+ assert.equal(h.panel.registered, true);
45
+ assert.equal(h.panel.panelId, '');
46
+ await h.context.pushKnowledgeToPanel();
47
+ h.state.projectId = 'project-b'; h.state.epoch++;
48
+ await h.context.pushKnowledgeToPanel();
49
+ assert.deepEqual(h.calls.map(call => call.type), ['kb:panel/register']);
50
+ assert.equal(h.timers.size, 0);
51
+ });
52
+
53
+ test('user-added tabs receive content and fast acknowledgements without opening another view', async () => {
54
+ const h = harness();
55
+ await h.show('tab-1');
56
+ assert.equal(h.panel.pending, null);
57
+ assert.equal(h.panel.deliveredPanelId, 'tab-1');
58
+ assert.equal(h.timers.size, 0);
59
+ assert.deepEqual(h.calls.map(call => call.type), ['kb:panel/content']);
60
+ });
61
+
62
+ test('closing keeps cached updates invisible until the user adds the page again', async () => {
63
+ const h = harness();
64
+ await h.show('tab-1');
65
+ h.deliver(null);
66
+ h.state.reference = 'updated while closed';
67
+ await h.context.pushKnowledgeToPanel();
68
+ assert.equal(h.panel.panelId, '');
69
+ assert.equal(h.calls.length, 1, 'closing or receiving knowledge must not create another view');
70
+ await h.show('tab-2');
71
+ assert.equal(h.calls.length, 2);
72
+ assert.equal(h.calls[1].payload.panelId, 'tab-2');
73
+ assert.equal(h.calls[1].payload.content.references[0].title, 'updated while closed');
74
+ assert.equal(h.panel.deliveredPanelId, 'tab-2');
75
+ h.deliver('tab-2', { ack: { version: h.panel.version, ok: true } });
76
+ await settle();
77
+ assert.equal(h.calls.length, 2, 'focus and repeated receipts cannot create a delivery loop');
78
+ });
79
+
80
+ test('errors never create an outside fallback and a newly added page can retry', async () => {
81
+ let first = true;
82
+ const h = harness({ request(type) {
83
+ if (type === 'kb:panel/content' && first) { first = false; return { ok: false }; }
84
+ } });
85
+ await h.show('tab-1');
86
+ assert.equal(h.panel.failed, true);
87
+ assert.equal(h.panel.registered, true, 'the page remains available in the dock selector');
88
+ assert.match(h.notices.join(' '), /关闭引用标签/);
89
+ assert.deepEqual(h.calls.map(call => call.type), ['kb:panel/content']);
90
+ h.deliver(null);
91
+ await h.show('tab-2');
92
+ assert.equal(h.panel.failed, false);
93
+ assert.equal(h.panel.deliveredPanelId, 'tab-2');
94
+ });
95
+
96
+ test('closing during a content transfer makes late replies inert', async () => {
97
+ let reply;
98
+ const h = harness({ request(type) {
99
+ if (type === 'kb:panel/content') return new Promise(resolve => { reply = resolve; });
100
+ } });
101
+ await h.show('tab-1');
102
+ h.deliver(null);
103
+ reply({ ok: true });
104
+ await settle();
105
+ assert.equal(h.panel.panelId, '');
106
+ assert.equal(h.panel.pending, null);
107
+ assert.equal(h.timers.size, 0);
108
+ assert.equal(h.calls.length, 1);
109
+ });
110
+
111
+ test('an older failed acknowledgement cannot close the current reference view', async () => {
112
+ const h = harness({ request(type) { if (type === 'kb:panel/content') return { ok: true }; } });
113
+ await h.show('tab-1');
114
+ await h.context.pushKnowledgeToPanel();
115
+ const version = h.panel.pending.version;
116
+ h.deliver('tab-1', { ack: { version: version - 1, ok: false } }, 'CONTENT_ACK_ERROR');
117
+ await settle();
118
+ assert.equal(h.panel.pending.version, version);
119
+ assert.equal(h.panel.failed, false);
120
+ assert.equal(h.calls.length, 2);
121
+ h.deliver('tab-1', { ack: { version, ok: true } });
122
+ assert.equal(h.panel.deliveredPanelId, 'tab-1');
123
+ });
124
+
125
+ test('a queued project switch sends only the latest scope and rejects a stale acknowledgement', async () => {
126
+ let oldReply;
127
+ const h = harness({ request(type, payload) {
128
+ if (type === 'kb:panel/content' && payload.content.projectId === 'project-a') return new Promise(resolve => { oldReply = resolve; });
129
+ } });
130
+ await h.show('tab-1');
131
+ h.state.projectId = 'project-b'; h.state.epoch++;
132
+ await h.context.pushKnowledgeToPanel();
133
+ h.deliver('tab-1', { ack: { version: 1, ok: true } });
134
+ assert.equal(h.panel.deliveredPanelId, '');
135
+ oldReply({ ok: true });
136
+ await settle();
137
+ assert.deepEqual(h.calls.map(call => call.payload.content.projectId), ['project-a', 'project-b']);
138
+ assert.equal(h.panel.deliveredPanelId, 'tab-1');
139
+ assert.equal(h.panel.pending, null);
140
+ });
141
+
142
+ test('restored inactive reference tabs wait for their page to load before content delivery', async () => {
143
+ const h = harness();
144
+ await h.context.resetKnowledgePanel();
145
+ h.context.handlePanelState({ panels: [{ providerId: 'knowledge', panelId: 'restored', ready: false }] });
146
+ await h.context.pushKnowledgeToPanel(); await settle();
147
+ assert.deepEqual(h.calls.map(call => call.type), ['kb:panel/register']);
148
+ assert.equal(h.panel.failed, false);
149
+ assert.equal(h.timers.size, 0);
150
+ h.context.handlePanelState({ panels: [{ providerId: 'knowledge', panelId: 'restored', ready: true }] });
151
+ await settle();
152
+ assert.deepEqual(h.calls.map(call => call.type), ['kb:panel/register', 'kb:panel/content']);
153
+ assert.equal(h.panel.deliveredPanelId, 'restored');
154
+ });
@@ -1,112 +1,110 @@
1
- 'use strict';
2
- // K1 knowledge panel contract: the shell registers a same-origin provider with
3
- // the workbench, streams structured references, folds the fixed column only
4
- // after a version-matched panel ack, restores it on any failure, and the panel
5
- // page itself renders external strings through textContent only.
6
- const assert = require('node:assert/strict');
7
- const fs = require('node:fs');
8
- const path = require('node:path');
9
- const root = path.resolve(__dirname, '../..');
10
- const html = fs.readFileSync(path.join(root, 'ui/index.html'), 'utf8');
11
- const script = fs.readFileSync(path.join(root, 'ui/product.js'), 'utf8');
12
- const css = fs.readFileSync(path.join(root, 'ui/product.css'), 'utf8');
13
- const panelPath = path.join(root, 'ui/knowledge-panel.html');
14
- const panelHtml = fs.readFileSync(panelPath, 'utf8');
15
-
16
- /* ===================== panel page source contract ===================== */
17
- assert.ok(panelHtml.includes('KB-K1-KNOWLEDGE-PANEL-v1'), 'the panel page shows the stable marker');
18
- assert.ok(panelHtml.includes('event.source !== window.parent'), 'the panel only accepts its Workbench parent as the message source');
19
- assert.ok(panelHtml.includes("data.type !== 'kb:panel/content'"), 'kb:panel/content is the only inbound verb');
20
- assert.ok(panelHtml.includes("'kb:panel/content-ack'"), 'the panel answers with a content ack');
21
- assert.ok(!panelHtml.includes('innerHTML') && !panelHtml.includes('insertAdjacentHTML') && !panelHtml.includes('document.write'), 'no string becomes markup');
22
- assert.ok(!/<(?:script|link|img)[^>]+(?:src|href)=/.test(panelHtml.replace(/<script>[\s\S]*?<\/script>/, '')), 'the panel page loads no external resources');
23
- assert.ok(!panelHtml.includes('fetch(') && !panelHtml.includes('XMLHttpRequest'), 'the sandboxed panel never talks to APIs directly');
24
- const panelScriptMatch = panelHtml.match(/<script>([\s\S]*?)<\/script>/);
25
- assert.ok(panelScriptMatch, 'the panel page carries an inline script');
26
- assert.doesNotThrow(() => new Function(panelScriptMatch[1]));
27
-
28
- /* ===================== panel page behavior (minimal DOM) ===================== */
29
- function runPanelPage() {
30
- const acks = [];
31
- const node = () => ({ children: [], textContent: '', append(...items) { this.children.push(...items); }, replaceChildren() { this.children.length = 0; } });
32
- const elements = { panelTitle: node(), panelProject: node(), panelStatus: node(), panelReferences: node(), panelMarker: node() };
33
- let listener = null;
34
- const parent = { postMessage(message) { acks.push(message); } };
35
- const fakeWindow = { parent, addEventListener(type, fn) { if (type === 'message') listener = fn; } };
36
- const fakeDocument = {
37
- getElementById: id => elements[id] || null,
38
- createElement: () => node(),
39
- };
40
- new Function('window', 'document', panelScriptMatch[1])(fakeWindow, fakeDocument);
41
- assert.equal(typeof listener, 'function', 'the panel registers a message listener');
42
- const deliver = (data, source = parent) => listener({ source, data });
43
- return { acks, elements, deliver };
44
- }
45
-
46
- {
47
- const page = runPanelPage();
48
- page.deliver({ type: 'kb:panel/content', panelId: 'tab-1', version: 3, content: {
49
- title: '本次知识引用', projectId: 'proj-a', status: 'ready',
50
- references: [{ entryId: 'docs/a.md', title: 'T', text: '<img src=x onerror=alert(1)>', source: 'proj / docs/a.md' }],
51
- } });
52
- const ack = page.acks[page.acks.length - 1];
53
- assert.equal(ack.type, 'kb:panel/content-ack');
54
- assert.equal(ack.panelId, 'tab-1');
55
- assert.equal(ack.version, 3);
56
- assert.equal(ack.ok, true);
57
- assert.equal(page.elements.panelTitle.textContent, '本次知识引用');
58
- assert.equal(page.elements.panelProject.textContent, 'proj-a');
59
- const reference = page.elements.panelReferences.children[0];
60
- assert.ok(reference, 'one reference card rendered');
61
- const text = reference.children.find(child => child.textContent.includes('<img'));
62
- assert.ok(text, 'the reference body exists');
63
- assert.equal(text.textContent, '<img src=x onerror=alert(1)>', 'markup-looking strings stay inert text');
64
- }
65
- {
66
- const page = runPanelPage();
67
- const stranger = { postMessage() {} };
68
- page.deliver({ type: 'kb:panel/content', panelId: 'tab-1', version: 1, content: { title: 'x' } }, stranger);
69
- assert.equal(page.acks.length, 0, 'a non-parent source is ignored without any ack');
70
- page.deliver({ type: 'kb:panel/command', panelId: 'tab-1' });
71
- assert.equal(page.acks.length, 0, 'non-content verbs are ignored');
72
- page.deliver({ type: 'kb:panel/content', panelId: 'tab-1', version: 2, content: ['not', 'an', 'object'] });
73
- const failure = page.acks[page.acks.length - 1];
74
- assert.equal(failure.ok, false, 'illegal content is refused with an explicit receipt');
75
- assert.ok(failure.error, 'the refusal carries an error message');
76
- }
77
-
78
- /* ===================== shell (product.js) contract ===================== */
79
- assert.ok(script.includes('kb.knowledge.references'), 'the provider id is stable');
80
- assert.match(script, /url: '\/knowledge-panel\.html'/, 'the provider URL is the KB-owned panel page');
81
- assert.match(script, /panelType: 'knowledge', title: '本次知识引用'/, 'panel type and title match the product');
82
- assert.match(script, /capabilities: \['read-only'\]/, 'the panel declares itself read-only');
83
- assert.match(script, /multiInstance: 'single'/, 'the provider is single-instance');
84
- assert.match(script, /if \(message\.type === 'kb:ready'\)[\s\S]*?if \(key === 'terminal'\) void resetKnowledgePanel\(\)/, 'registration starts from the terminal kb:ready');
85
- assert.ok(script.includes("'kb:panel/register'") && script.includes("'kb:panel/open'") && script.includes("'kb:panel/content'"), 'the shell uses the W3 verbs');
86
- assert.match(script, /if \(message\.type === 'kb:knowledge' && key === 'terminal'\)[\s\S]*?renderContext\(\); void pushKnowledgeToPanel/, 'kb:knowledge still refreshes the fixed column first, then streams the panel');
87
- assert.match(script, /const ack = panel\.content\?\.ack;[\s\S]*?if \(ack && String\(ack\.version\) === String\(pending\.version\)\)/, 'collapse waits for the version-matched ack');
88
- assert.match(script, /if \(ack\.ok && !panel\.error\) \{ setKnowledgeCollapsed\(true\)/, 'folding requires an error-free ack');
89
- assert.match(script, /if \(!panel\) \{[\s\S]*?if \(knowledgePanel\.collapsed\) setKnowledgeCollapsed\(false\);[\s\S]*?return;/, 'closing the dock panel unfolds the fixed column again');
90
- assert.match(script, /function knowledgePanelFailed\(reason\)[\s\S]*?setKnowledgeCollapsed\(false\);[\s\S]*?panelNotice\(/, 'every failure path restores the full column with a non-blocking notice');
91
- assert.match(script, /function selectProject\(id\)[\s\S]*?setKnowledgeCollapsed\(false\);[\s\S]*?pushKnowledgeToPanel\(id\)/, 'a project switch re-scopes the panel to the new project before folding again');
92
- assert.match(script, /renderProjectOptions\(\); setKnowledgeCollapsed\(false\); render\(\)/, 'unbinding a project also restores the fixed column');
93
- assert.match(script, /\.slice\(0, 50\)\.map\(r => \(\{/, 'the payload bounds the reference count');
94
- assert.match(script, /text: String\(r\.text \|\| ''\)\.slice\(0, 800\)/, 'the payload bounds reference text');
95
- assert.match(script, /knowledgePayload\(projectId\)[\s\S]*?entryId: String\(/, 'payload fields are coerced to plain strings');
96
- assert.equal((script.match(/classList\.toggle\('kb-panel-collapsed'/g) || []).length, 1, 'collapse is driven only through the work-layout class');
97
- assert.ok(!/data-action="collapse|toggle-context|折叠知识栏/.test(script + html), 'no duplicate column control button is introduced');
98
- assert.match(script, /panelRequest\('kb:panel\/content', \{ panelId: knowledgePanel\.panelId, providerId: knowledgePanel\.providerId, version, content: knowledgePayload\(projectId\) \}\)/, 'content carries panelId, providerId and version');
99
- assert.match(script, /message\.type\.startsWith\('kb:panel\/'\)/, 'panel replies are routed before the frame-context staleness drop');
100
- assert.match(script, /if \(message\.requestId && message\.requestId !== frames\[key\]\.requestId\) return;/, 'the legacy staleness drop stays intact for module traffic');
101
- assert.doesNotThrow(() => new Function(script));
102
-
103
- /* ===================== markup and css contract ===================== */
104
- assert.equal((html.match(/id="panelNotice"/g) || []).length, 1, 'exactly one non-blocking panel notice exists');
105
- assert.ok(html.includes('knowledge-context') && html.includes('id="contextContent"'), 'the fixed knowledge column markup is untouched');
106
- assert.ok(css.includes('.work-layout.kb-panel-collapsed{grid-template-columns:minmax(0,1fr) 0px}'), 'the fold animates the grid track instead of removing the column');
107
- assert.match(css, /\.work-layout\{[^}]*transition:grid-template-columns/, 'the fold is a smooth transition');
108
- assert.match(css, /@media \(prefers-reduced-motion: reduce\)\{\.work-layout,\.knowledge-context\{transition:none\}\}/, 'reduced-motion users get no animation');
109
- assert.ok(css.includes('.panel-notice'), 'the notice has styling');
110
- assert.match(css, /\.work-layout\{display:grid;grid-template-columns:minmax\(0,1fr\) 288px/, 'the default full-width column is unchanged');
111
-
112
- console.log('knowledge panel UI contract test PASS (source contract + sandboxed render behavior; not browser acceptance)');
1
+ 'use strict';
2
+ // K1 knowledge panel contract: the shell registers a same-origin provider with
3
+ // the workbench, streams structured references only to user-added tabs,
4
+ // validates matching receipts, never renders an outside column, and the panel
5
+ // page itself renders external strings through textContent only.
6
+ const assert = require('node:assert/strict');
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+ const root = path.resolve(__dirname, '../..');
10
+ const html = fs.readFileSync(path.join(root, 'ui/index.html'), 'utf8');
11
+ const script = fs.readFileSync(path.join(root, 'ui/product.js'), 'utf8');
12
+ const css = fs.readFileSync(path.join(root, 'ui/product.css'), 'utf8');
13
+ const panelPath = path.join(root, 'ui/knowledge-panel.html');
14
+ const panelHtml = fs.readFileSync(panelPath, 'utf8');
15
+
16
+ /* ===================== panel page source contract ===================== */
17
+ assert.ok(!panelHtml.includes('KB-K1-KNOWLEDGE-PANEL-v1'), 'test contract markers never enter customer markup');
18
+ assert.ok(panelHtml.includes('event.source !== window.parent'), 'the panel only accepts its Workbench parent as the message source');
19
+ assert.ok(panelHtml.includes("data.type !== 'kb:panel/content'"), 'kb:panel/content is the only inbound verb');
20
+ assert.ok(panelHtml.includes("'kb:panel/content-ack'"), 'the panel answers with a content ack');
21
+ assert.ok(!panelHtml.includes('innerHTML') && !panelHtml.includes('insertAdjacentHTML') && !panelHtml.includes('document.write'), 'no string becomes markup');
22
+ assert.ok(!/<(?:script|link|img)[^>]+(?:src|href)=/.test(panelHtml.replace(/<script>[\s\S]*?<\/script>/, '')), 'the panel page loads no external resources');
23
+ assert.ok(!panelHtml.includes('fetch(') && !panelHtml.includes('XMLHttpRequest'), 'the sandboxed panel never talks to APIs directly');
24
+ const panelScriptMatch = panelHtml.match(/<script>([\s\S]*?)<\/script>/);
25
+ assert.ok(panelScriptMatch, 'the panel page carries an inline script');
26
+ assert.doesNotThrow(() => new Function(panelScriptMatch[1]));
27
+
28
+ /* ===================== panel page behavior (minimal DOM) ===================== */
29
+ function runPanelPage() {
30
+ const acks = [];
31
+ const node = () => ({ children: [], textContent: '', append(...items) { this.children.push(...items); }, replaceChildren() { this.children.length = 0; } });
32
+ const elements = { panelTitle: node(), panelProject: node(), panelStatus: node(), panelReferences: node(), panelMarker: node() };
33
+ let listener = null;
34
+ const parent = { postMessage(message) { acks.push(message); } };
35
+ const fakeWindow = { parent, addEventListener(type, fn) { if (type === 'message') listener = fn; } };
36
+ const fakeDocument = {
37
+ documentElement: { dataset: {} },
38
+ getElementById: id => elements[id] || null,
39
+ createElement: () => node(),
40
+ };
41
+ new Function('window', 'document', panelScriptMatch[1])(fakeWindow, fakeDocument);
42
+ assert.equal(typeof listener, 'function', 'the panel registers a message listener');
43
+ const deliver = (data, source = parent) => listener({ source, data });
44
+ return { acks, elements, deliver, document: fakeDocument };
45
+ }
46
+
47
+ {
48
+ const page = runPanelPage();
49
+ page.deliver({ type: 'kb:panel/content', panelId: 'tab-1', version: 3, content: {
50
+ title: '本次知识引用', projectId: 'proj-a', projectName: '项目 A', status: 'ready', theme: 'dark',
51
+ references: [{ entryId: 'docs/a.md', title: 'T', text: '<img src=x onerror=alert(1)>', source: 'proj / docs/a.md' }],
52
+ } });
53
+ const ack = page.acks[page.acks.length - 1];
54
+ assert.equal(ack.type, 'kb:panel/content-ack');
55
+ assert.equal(ack.panelId, 'tab-1');
56
+ assert.equal(ack.version, 3);
57
+ assert.equal(ack.ok, true);
58
+ assert.equal(page.elements.panelTitle.textContent, '本次知识引用');
59
+ assert.equal(page.elements.panelProject.textContent, '项目 A');
60
+ assert.equal(page.document.documentElement.dataset.theme, 'dark', 'the reference page follows the host theme');
61
+ const reference = page.elements.panelReferences.children[0];
62
+ assert.ok(reference, 'one reference card rendered');
63
+ assert.equal(reference.children.length, 3, 'reference cards show title, source and excerpt without a repeated internal entry identifier');
64
+ const text = reference.children.find(child => child.textContent.includes('<img'));
65
+ assert.ok(text, 'the reference body exists');
66
+ assert.equal(text.textContent, '<img src=x onerror=alert(1)>', 'markup-looking strings stay inert text');
67
+ }
68
+ {
69
+ const page = runPanelPage();
70
+ const stranger = { postMessage() {} };
71
+ page.deliver({ type: 'kb:panel/content', panelId: 'tab-1', version: 1, content: { title: 'x' } }, stranger);
72
+ assert.equal(page.acks.length, 0, 'a non-parent source is ignored without any ack');
73
+ page.deliver({ type: 'kb:panel/command', panelId: 'tab-1' });
74
+ assert.equal(page.acks.length, 0, 'non-content verbs are ignored');
75
+ page.deliver({ type: 'kb:panel/content', panelId: 'tab-1', version: 2, content: ['not', 'an', 'object'] });
76
+ const failure = page.acks[page.acks.length - 1];
77
+ assert.equal(failure.ok, false, 'illegal content is refused with an explicit receipt');
78
+ assert.ok(failure.error, 'the refusal carries an error message');
79
+ }
80
+
81
+ /* ===================== shell (product.js) contract ===================== */
82
+ assert.ok(script.includes('kb.knowledge.references'), 'the provider id is stable');
83
+ assert.match(script, /url: '\/knowledge-panel\.html'/, 'the provider URL is the KB-owned panel page');
84
+ assert.match(script, /panelType: 'knowledge', title: '本次知识引用'/, 'panel type and title match the product');
85
+ assert.match(script, /capabilities: \['read-only'\]/, 'the panel declares itself read-only');
86
+ assert.match(script, /multiInstance: 'single'/, 'the provider is single-instance');
87
+ assert.match(script, /if \(message\.type === 'kb:ready'\)[\s\S]*?if \(key === 'terminal'\) void resetKnowledgePanel\(\)/, 'registration starts from the terminal kb:ready');
88
+ assert.ok(script.includes("'kb:panel/register'") && script.includes("'kb:panel/content'"), 'the host registers the page and supplies its content');
89
+ assert.ok(!script.includes("'kb:panel/open'"), 'registration and retrieval cannot open a page');
90
+ assert.match(script, /if \(message\.type === 'kb:knowledge' && key === 'terminal'\)[\s\S]*?state\.contexts\.set[\s\S]*?void pushKnowledgeToPanel/, 'retrieved knowledge stays cached and updates existing pages only');
91
+ assert.match(script, /const ack = panel\.content\?\.ack;[\s\S]*?if \(ack && String\(ack\.version\) === String\(pending\.version\)\)/, 'receipts match the currently pending content');
92
+ assert.match(script, /if \(ack\.ok && !panel\.error\) \{ knowledgePanel\.deliveredPanelId = panel\.panelId;/, 'delivery is confirmed only by an error-free acknowledgement');
93
+ assert.match(script, /if \(!panel\) \{[\s\S]*?knowledgePanel\.panelId = '';[\s\S]*?return;/, 'closing a page cancels its delivery');
94
+ assert.match(script, /if \(!knowledgePanel\.registered \|\| knowledgePanel\.failed \|\| !projectId \|\| !panelId\) return;/, 'closed pages cannot be opened by background updates');
95
+ assert.ok(!/setKnowledgeCollapsed|panelNotice|contextContent|knowledge-context/.test(script), 'the old outside-column paths are removed');
96
+ assert.match(script, /\.slice\(0, 50\)\.map\(r => \(\{/, 'the payload bounds the reference count');
97
+ assert.match(script, /text: String\(r\.text \|\| ''\)\.slice\(0, 800\)/, 'the payload bounds reference text');
98
+ assert.match(script, /knowledgePayload\(projectId\)[\s\S]*?entryId: String\(/, 'payload fields are coerced to plain strings');
99
+ assert.ok(!/data-action="collapse|toggle-context|折叠知识栏/.test(script + html), 'no duplicate column control button is introduced');
100
+ assert.match(script, /panelRequest\('kb:panel\/content', \{ panelId: knowledgePanel\.panelId, providerId: knowledgePanel\.providerId, version, content: knowledgePayload\(projectId\) \}\)/, 'content carries panelId, providerId and version');
101
+ assert.match(script, /message\.type\.startsWith\('kb:panel\/'\)/, 'panel replies are routed before the frame-context staleness drop');
102
+ assert.match(script, /if \(message\.requestId && message\.requestId !== frames\[key\]\.requestId\) return;/, 'the legacy staleness drop stays intact for module traffic');
103
+ assert.doesNotThrow(() => new Function(script));
104
+
105
+ /* ===================== markup and css contract ===================== */
106
+ assert.ok(!/knowledge-context|contextContent|contextScope|panelNotice/.test(html), 'there is no outside knowledge column, including hidden markup');
107
+ assert.ok(!/knowledge-context|kb-panel-collapsed|panel-notice/.test(css), 'no space or fallback layout is reserved for a second column');
108
+ assert.match(css, /\.work-layout\{display:grid;grid-template-columns:minmax\(0,1fr\);/, 'the host gives the whole workspace to the conversation and its dock');
109
+
110
+ console.log('knowledge panel UI contract test PASS (source contract + sandboxed render behavior; not browser acceptance)');
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const net = require('node:net');
7
+ const { spawn } = require('node:child_process');
8
+ const control = require('../lib/runtime-control');
9
+ const { markIntent } = require('../lib/integration-intent');
10
+ const root = process.env.KB_LAUNCHER_TEST_PACKAGE || path.resolve(__dirname, '../..');
11
+ const data = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-startup-integration-'));
12
+ const marker = path.join(data, 'slow-native-check.pid');
13
+ const preload = path.join(__dirname, 'fixtures/slow-startup-integration.cjs').replace(/\\/g, '/');
14
+ let instance;
15
+ async function freePort() {
16
+ const server = net.createServer();
17
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
18
+ const port = server.address().port;
19
+ await new Promise(resolve => server.close(resolve)); return port;
20
+ }
21
+ async function main() {
22
+ markIntent(data, 'codex', 'connected');
23
+ const port = await freePort();
24
+ const started = Date.now();
25
+ const launched = await new Promise((resolve, reject) => {
26
+ const child = spawn(process.execPath, [path.join(root, 'bin/project-knowledge.js'), '--no-open', '--port', String(port)], {
27
+ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
28
+ env: { ...process.env, KB_DATA_DIR: data, KB_MODULES_AUTOSTART: '0',
29
+ KB_LAUNCHER_TEST_PACKAGE: root, KB_STARTUP_INTEGRATION_MARKER: marker,
30
+ AI_CODING_EVENT_BRIDGE_HOME: path.join(data, 'events'), NODE_OPTIONS: `--require="${preload}"` },
31
+ });
32
+ let stdout = '', stderr = '';
33
+ const timer = setTimeout(() => { child.kill(); reject(new Error('Background launch timed out')); }, 20000);
34
+ child.stdout.on('data', value => { stdout += value; }); child.stderr.on('data', value => { stderr += value; });
35
+ child.once('error', reject); child.once('exit', code => { clearTimeout(timer); resolve({ code, stdout, stderr }); });
36
+ });
37
+ assert.equal(launched.code, 0, launched.stderr);
38
+ instance = (await control.request(data, { action: 'status' }, 1000)).instance;
39
+ assert(Date.now() - started < 5000, 'native integration checks must not delay the launcher ready acknowledgement');
40
+ for (let i = 0; i < 60 && !fs.existsSync(marker); i++) await new Promise(resolve => setTimeout(resolve, 50));
41
+ assert(fs.existsSync(marker), 'startup auto repair must still run');
42
+ assert.notEqual(Number(fs.readFileSync(marker, 'utf8')), instance.pid, 'native checks must run outside the HTTP process');
43
+ for (let i = 0; i < 3; i++) {
44
+ const response = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(1000) });
45
+ assert.equal(response.status, 200, 'HTTP must remain usable while a native check is blocked');
46
+ assert.equal((await control.request(data, { action: 'status' }, 1000)).instance.pid, instance.pid);
47
+ }
48
+ const stopAt = Date.now();
49
+ await control.request(data, { action: 'stop', pid: instance.pid, port, startedAt: instance.startedAt }, 4000);
50
+ assert(Date.now() - stopAt < 4000, 'shutdown must cancel the blocked repair worker');
51
+ instance = null;
52
+ console.log('Startup integration isolation PASS: background ready, HTTP/control usable during a blocked native probe, owned worker stopped');
53
+ }
54
+ main().catch(error => { console.error(error); process.exitCode = 1; }).finally(async () => {
55
+ try {
56
+ const info = instance || (await control.request(data, { action: 'status' }, 1000)).instance;
57
+ await control.request(data, { action: 'stop', pid: info.pid, port: info.port, startedAt: info.startedAt });
58
+ } catch {}
59
+ });