From 69bdf28dc8e378e105081b5a3419895d87bd7e1d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 14:35:14 +0000 Subject: [PATCH] Hide the DSH Tool removed: ask_user_question chat row FlyBot already restrict()s ask_user_question off Bot sessions. DSH then writes a developer tool-removal context cell, and the chat UI prints "Tool removed: ask_user_question". Shadow that context node and hide only those notices; other tool cells stay. Fixes #140. Co-authored-by: Danyang Chen --- changelog.d/140-tool-removed-cell.fixed.md | 1 + core/client.js | 21 ++++ core/src/client/cells.js | 24 +++++ core/src/client/index.js | 5 +- core/test/hidden-tool-removal.test.js | 98 +++++++++++++++++++ docs/subsystems/core-client.md | 4 + patches/ds-bot/0003-flybot-api-client.patch | 49 +++++++++- patches/ds-bot/0008-client-bundle.patch | 56 ++++++++--- .../files/test/hidden-tool-removal.test.js | 98 +++++++++++++++++++ 9 files changed, 338 insertions(+), 18 deletions(-) create mode 100644 changelog.d/140-tool-removed-cell.fixed.md create mode 100644 core/test/hidden-tool-removal.test.js create mode 100644 patches/ds-bot/files/test/hidden-tool-removal.test.js diff --git a/changelog.d/140-tool-removed-cell.fixed.md b/changelog.d/140-tool-removed-cell.fixed.md new file mode 100644 index 000000000..553312948 --- /dev/null +++ b/changelog.d/140-tool-removed-cell.fixed.md @@ -0,0 +1 @@ +The raw **Tool removed: ask_user_question** line no longer appears in Bot chats. FlyBot already takes that DSH tool off Bot sessions; the leftover context row is hidden. Other tool cells are unchanged. ([#140](https://github.com/ThinkFlowLab/FlyBot/issues/140)) diff --git a/core/client.js b/core/client.js index d6d94cd3a..ff8175582 100644 --- a/core/client.js +++ b/core/client.js @@ -4957,6 +4957,24 @@ function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTu function Nothing() { return null; } +var HIDDEN_REMOVED_TOOLS = /* @__PURE__ */ new Set(["ask_user_question"]); +function isHiddenToolRemoval(node) { + const content = node?.data?.content; + if (!Array.isArray(content) || content.length === 0) return false; + return content.every((block) => block?.type === "tool-removal" && HIDDEN_REMOVED_TOOLS.has(block.toolName)); +} +function ContextCell({ node }) { + if (isHiddenToolRemoval(node)) return null; + const content = Array.isArray(node?.data?.content) ? node.data.content : []; + const parts = []; + for (const block of content) { + if (block?.type === "tool-removal" && HIDDEN_REMOVED_TOOLS.has(block.toolName)) continue; + if (block?.type === "tool-removal" && block.toolName) parts.push(`Tool removed: ${block.toolName}`); + else if (block?.type === "tool-addition" && block.toolName) parts.push(`Tool added: ${block.toolName}`); + } + if (parts.length === 0) return null; + return (0, import_react7.createElement)("div", { className: "bt-event" }, parts.join(" · ")); +} var SEPARATOR_GAP_MS = 30 * 60 * 1e3; function clockLabel(time, now = Date.now()) { const date = new Date(time); @@ -15138,6 +15156,9 @@ function apply(ctx) { // every message. shadow("conversation.chat.node", { key: "question-reply", priority: -10, locale: "chat" }, AnsweredCell), shadow("conversation.chat.node", { key: "compaction", priority: -10, locale: "chat" }, Nothing), + // DSH's context row is "Tool removed: ask_user_question" after Bot sessions + // restrict that tool; hide it, keep other tool-change notices (#140). + shadow("conversation.chat.node", { key: "context", priority: -10, locale: "chat" }, ContextCell), ...canDrawUserMessages() ? ["user", "steering"].map((key) => shadow("conversation.chat.node", { key, priority: -10, locale: "chat", inject: face }, UserMessageCell)) : [], shadow("conversation.input.permission", { priority: -10 }, Nothing), shadow("conversation.input.plan", { priority: -10 }, Nothing), diff --git a/core/src/client/cells.js b/core/src/client/cells.js index 3e8ca2165..24675ee03 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -496,6 +496,30 @@ export function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi export function Nothing() { return null } +// DSH writes a developer `tool-removal` context row when FlyBot `restrict()`s +// `ask_user_question` off Bot sessions. The chat then shows "Tool removed: …". +// Hide only those notices; other context (tool-added, unrelated removals) stays. +export const HIDDEN_REMOVED_TOOLS = new Set(['ask_user_question']) + +export function isHiddenToolRemoval(node) { + const content = node?.data?.content + if (!Array.isArray(content) || content.length === 0) return false + return content.every(block => block?.type === 'tool-removal' && HIDDEN_REMOVED_TOOLS.has(block.toolName)) +} + +export function ContextCell({ node }) { + if (isHiddenToolRemoval(node)) return null + const content = Array.isArray(node?.data?.content) ? node.data.content : [] + const parts = [] + for (const block of content) { + if (block?.type === 'tool-removal' && HIDDEN_REMOVED_TOOLS.has(block.toolName)) continue + if (block?.type === 'tool-removal' && block.toolName) parts.push(`Tool removed: ${block.toolName}`) + else if (block?.type === 'tool-addition' && block.toolName) parts.push(`Tool added: ${block.toolName}`) + } + if (parts.length === 0) return null + return h('div', { className: 'bt-event' }, parts.join(' · ')) +} + // The time separator: "Today 1:12 AM", "Yesterday 9:03 PM", "Thu, Sep 3 4:20 PM". const SEPARATOR_GAP_MS = 30 * 60 * 1000 export function clockLabel(time, now = Date.now()) { diff --git a/core/src/client/index.js b/core/src/client/index.js index ae30938ed..0ef90805a 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -15,7 +15,7 @@ import { CHATS_SOURCE, atSources } from './at-menu.js' import { VOICE_BUNDLE } from './speech.js' import { sameVersion } from './dsh-offer.js' import { AnsweredCell, QuestionCard, QuestionDock } from './questions.js' -import { AssistantCell, TriggerCell, TurnProcessCell, Nothing, TurnTailCell, ToolCell } from './cells.js' +import { AssistantCell, TriggerCell, TurnProcessCell, Nothing, TurnTailCell, ToolCell, ContextCell } from './cells.js' import { Overlays } from './overlays.js' import { createBotBrowsers, serveBrowserReads } from './bot-browsers.js' import { BROWSER_CSS } from './browser-pane.js' @@ -806,6 +806,9 @@ export function apply(ctx) { // every message. shadow('conversation.chat.node', { key: 'question-reply', priority: -10, locale: 'chat' }, AnsweredCell), shadow('conversation.chat.node', { key: 'compaction', priority: -10, locale: 'chat' }, Nothing), + // DSH's context row is "Tool removed: ask_user_question" after Bot sessions + // restrict that tool; hide it, keep other tool-change notices (#140). + shadow('conversation.chat.node', { key: 'context', priority: -10, locale: 'chat' }, ContextCell), ...(canDrawUserMessages() ? ['user', 'steering'].map(key => shadow('conversation.chat.node', { key, priority: -10, locale: 'chat', inject: face }, UserMessageCell)) : []), shadow('conversation.input.permission', { priority: -10 }, Nothing), shadow('conversation.input.plan', { priority: -10 }, Nothing), diff --git a/core/test/hidden-tool-removal.test.js b/core/test/hidden-tool-removal.test.js new file mode 100644 index 000000000..24c1e1cae --- /dev/null +++ b/core/test/hidden-tool-removal.test.js @@ -0,0 +1,98 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +// ContextCell lives next to ToolCell; bundle it the same way so the DSH +// "Tool removed: ask_user_question" hide can be checked without a browser. +const { outputFiles } = await build({ + stdin: { + contents: "export { ContextCell, isHiddenToolRemoval, HIDDEN_REMOVED_TOOLS, ToolCell } from './src/client/cells.js'", + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) +const Fragment = Symbol('Fragment') +const h = (type, props, ...children) => ({ type, props: { ...props, children } }) +const react = { + createElement: h, Fragment, + useEffect: () => {}, useLayoutEffect: () => {}, + useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], + useMemo: make => make(), useRef: current => ({ current }), useId: () => 'id', +} +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, + document: { hidden: false }, + setTimeout: () => 0, clearTimeout: () => {}, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return { MarkdownText: ({ text }) => text } + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { ContextCell, isHiddenToolRemoval, HIDDEN_REMOVED_TOOLS, ToolCell } = module.exports + +const expand = (node) => { + if (node === null || node === undefined || typeof node === 'boolean') return null + if (Array.isArray(node)) return node.map(expand) + if (typeof node !== 'object') return node + if (typeof node.type === 'function') return expand(node.type(node.props)) + return { ...node, props: { ...node.props, children: expand(node.props.children) } } +} +const text = (node) => { + if (node === null || node === undefined) return '' + if (Array.isArray(node)) return node.map(text).join('') + return typeof node === 'object' ? text(node.props.children) : String(node) +} + +const writer = { id: 'writer', name: 'Writer', color: 'blue' } +const chief = { id: 'chief', name: 'Chief', color: 'green' } +const roster = { + bots: [writer, chief], byId: { writer, chief }, rooms: [], roomsById: {}, + exchangeTurns: {}, questions: {}, secretRequests: {}, secrets: [], +} +const showTool = (root) => text(expand(ToolCell({ + node: { key: 'node', data: { root }, location: { turn: { turn: 1 } } }, + sessionId: chief.id, + useRoster: select => select(roster), + useUi: select => select({ reactions: {}, visits: {} }), + actions: {}, +}))) + +test('#140: the context row hides FlyBot-removed tools, not other tool cells', () => { + const index = readFileSync(new URL('../src/client/index.js', import.meta.url), 'utf8') + assert.match(index, /key: 'context'/) + assert.match(index, /ContextCell/) + assert.equal(HIDDEN_REMOVED_TOOLS.has('ask_user_question'), true) + const removal = toolName => ({ data: { content: [{ type: 'tool-removal', toolName }] } }) + assert.equal(isHiddenToolRemoval(removal('ask_user_question')), true) + assert.equal(isHiddenToolRemoval(removal('bash')), false) + assert.equal(isHiddenToolRemoval({ data: { content: [{ type: 'tool-addition', toolName: 'web_search' }] } }), false) + assert.equal(ContextCell({ node: removal('ask_user_question') }), null) + assert.equal(text(expand(ContextCell({ node: removal('ask_user_question') }))), '') + assert.match(text(expand(ContextCell({ node: removal('bash') }))), /Tool removed: bash/) + assert.match(text(expand(ContextCell({ node: { data: { content: [{ type: 'tool-addition', toolName: 'web_search' }] } } }))), /Tool added: web_search/) + const mixed = { + data: { + content: [ + { type: 'tool-removal', toolName: 'ask_user_question' }, + { type: 'tool-addition', toolName: 'web_search' }, + ], + }, + } + assert.equal(isHiddenToolRemoval(mixed), false) + const mixedText = text(expand(ContextCell({ node: mixed }))) + assert.match(mixedText, /Tool added: web_search/) + assert.doesNotMatch(mixedText, /ask_user_question/) + const native = { + kind: 'tool-result', name: 'message_bot', callId: 'direct', + call: { name: 'message_bot', argsRaw: JSON.stringify({ to: 'Writer' }) }, + content: [{ type: 'text', text: '' }], isError: false, subCalls: [], + } + assert.match(showTool(native), /Messaged.*Writer/) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 2aab25b52..8905b51bd 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -79,6 +79,10 @@ The default card (`flybot-slots.js`) shows where a held request came from: `send - Sidebar **+** (`mode === 'new'`) opens a short action menu (`data-menu`, no To:/composer) until **New chat** or **Create group chat**. That menu does not list individual Bots (Chief and the rest stay behind **New chat**). The Agent row in that FlyBot menu is **Add Agent**; **Add DSH Agent** stays the DSH product string on Agent rows elsewhere (#126). - Group bubbles, notices, and failed-reply lines never show a raw `` wrap or a developer error (`llm-deepseek: no API key`). `remoteMessageText` unwraps stored fences; `failureLabel` maps missing-key / unknown model failures to a short friendly line. Details stay in Settings → Feedback. +### Hidden DSH tool-removal notices + +- FlyBot `restrict()`s DSH's `ask_user_question` off Bot sessions (`hideBlockingQuestion`). DSH then appends a developer `tool-removal` context row, and `@deepseek-ai/dsh-client-ui-chat` renders it as "Tool removed: {name}". `ContextCell` shadows `conversation.chat.node` `context` and returns null for those notices (`HIDDEN_REMOVED_TOOLS`). Other tool-change context (tool-added, unrelated removals) still shows; `tool-call` cells are unchanged. Empty shadowed nodes collapse via the existing `:empty` flow-item rule. Covered by `core/test/tool-cells.test.js`. + ## Tests `npm test` in `core/` (it also fails when `client.js` is stale). diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 2ab48161a..bd0c9e5b8 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -231,6 +231,37 @@ diff --git a/src/client/cells.js b/src/client/cells.js const room = roster.roomsById[sessionId] const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t('a Bot'), color: 'gray' } return h(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }) +@@ -411,6 +411,30 @@ export function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi + + export function Nothing() { return null } + ++// DSH writes a developer `tool-removal` context row when FlyBot `restrict()`s ++// `ask_user_question` off Bot sessions. The chat then shows "Tool removed: …". ++// Hide only those notices; other context (tool-added, unrelated removals) stays. ++export const HIDDEN_REMOVED_TOOLS = new Set(['ask_user_question']) ++ ++export function isHiddenToolRemoval(node) { ++ const content = node?.data?.content ++ if (!Array.isArray(content) || content.length === 0) return false ++ return content.every(block => block?.type === 'tool-removal' && HIDDEN_REMOVED_TOOLS.has(block.toolName)) ++} ++ ++export function ContextCell({ node }) { ++ if (isHiddenToolRemoval(node)) return null ++ const content = Array.isArray(node?.data?.content) ? node.data.content : [] ++ const parts = [] ++ for (const block of content) { ++ if (block?.type === 'tool-removal' && HIDDEN_REMOVED_TOOLS.has(block.toolName)) continue ++ if (block?.type === 'tool-removal' && block.toolName) parts.push(`Tool removed: ${block.toolName}`) ++ else if (block?.type === 'tool-addition' && block.toolName) parts.push(`Tool added: ${block.toolName}`) ++ } ++ if (parts.length === 0) return null ++ return h('div', { className: 'bt-event' }, parts.join(' · ')) ++} ++ + // The time separator: "Today 1:12 AM", "Yesterday 9:03 PM", "Thu, Sep 3 4:20 PM". + const SEPARATOR_GAP_MS = 30 * 60 * 1000 + export function clockLabel(time, now = Date.now()) { @@ -571,11 +571,14 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi replies.map((reply, index) => { const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? 'gray' } @@ -350,7 +381,7 @@ diff --git a/src/client/index.js b/src/client/index.js import { call } from './api.js' import { isHex, inkText, paintCss } from './inks.js' import { DEFAULT_MASK, SHAPES, markupCache, maskOf } from './characters.js' -@@ -5,10 +5,11 @@ import { countCards, createSource, EMPTY_ROSTER, indexRoster, isAgentSession } f +@@ -5,15 +5,16 @@ import { countCards, createSource, EMPTY_ROSTER, indexRoster, isAgentSession } f import { themeCss, shellTokens, registry, registerShape, registerTheme, allThemes } from './themes.js' import { MENTION_ORIGIN, currentPart } from './text.js' import { SHELL_CSS, CONVERSATION_CSS, installStyles, installCompiledStyles } from './styles.js' @@ -363,6 +394,12 @@ diff --git a/src/client/index.js b/src/client/index.js import { CHATS_SOURCE, atSources } from './at-menu.js' import { VOICE_BUNDLE } from './speech.js' import { sameVersion } from './dsh-offer.js' + import { AnsweredCell, QuestionCard, QuestionDock } from './questions.js' +-import { AssistantCell, TriggerCell, TurnProcessCell, Nothing, TurnTailCell, ToolCell } from './cells.js' ++import { AssistantCell, TriggerCell, TurnProcessCell, Nothing, TurnTailCell, ToolCell, ContextCell } from './cells.js' + import { Overlays } from './overlays.js' + import { createBotBrowsers, serveBrowserReads } from './bot-browsers.js' + import { BROWSER_CSS } from './browser-pane.js' @@ -37,7 +37,16 @@ import { noticeApprovals } from './approval-notices.js' export const inject = ['sessions', 'uiWorkspace', 'uiConversation', 'slots', 'theme', 'layout'] @@ -413,6 +450,16 @@ diff --git a/src/client/index.js b/src/client/index.js ] const installConversation = () => [ installCompiledStyles(CONVERSATION_CSS, 'conversation'), +@@ -790,6 +790,9 @@ export function apply(ctx) { + // every message. + shadow('conversation.chat.node', { key: 'question-reply', priority: -10, locale: 'chat' }, AnsweredCell), + shadow('conversation.chat.node', { key: 'compaction', priority: -10, locale: 'chat' }, Nothing), ++ // DSH's context row is "Tool removed: ask_user_question" after Bot sessions ++ // restrict that tool; hide it, keep other tool-change notices (#140). ++ shadow('conversation.chat.node', { key: 'context', priority: -10, locale: 'chat' }, ContextCell), + ...(canDrawUserMessages() ? ['user', 'steering'].map(key => shadow('conversation.chat.node', { key, priority: -10, locale: 'chat', inject: face }, UserMessageCell)) : []), + shadow('conversation.input.permission', { priority: -10 }, Nothing), + shadow('conversation.input.plan', { priority: -10 }, Nothing), @@ -799,6 +799,7 @@ export function apply(ctx) { shadow('conversation.composer.dock', { id: 'activity', order: 0, priority: -10 }, Nothing), shadow('conversation.composer.dock', { id: 'usage', order: 1, priority: -10 }, Nothing), diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index a1cf2b156..e3dbdfb76 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1209,14 +1209,10 @@ diff --git a/client.js b/client.js .bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} .bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} .bt-event>span{padding:0 2px} -@@ -2821,260 +2821,142 @@ function compileFragmentSelectors(text, names) { - function installCompiledStyles(text, tag) { - const style = installStyles("", tag); - const cache = /* @__PURE__ */ new WeakMap(); -- const render = () => { -- const names = /* @__PURE__ */ new Set(); -- for (const sheet of document.styleSheets) { -- if (sheet.ownerNode?.dataset?.dshBot) continue; +@@ -2825,256 +2825,138 @@ function installCompiledStyles(text, tag) { + const names = /* @__PURE__ */ new Set(); + for (const sheet of document.styleSheets) { + if (sheet.ownerNode?.dataset?.dshBot) continue; - let list = cache.get(sheet); - if (!list) { - list = sheetClassNames(sheet); @@ -1372,10 +1368,6 @@ diff --git a/client.js b/client.js - } - const half = frame / 2; - return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; -+ const render = () => { -+ const names = /* @__PURE__ */ new Set(); -+ for (const sheet of document.styleSheets) { -+ if (sheet.ownerNode?.dataset?.dshBot) continue; + let list = cache.get(sheet); + if (!list) { + list = sheetClassNames(sheet); @@ -2483,8 +2475,7 @@ diff --git a/client.js b/client.js bot.name, - room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null + room2?.admin === bot.id ? (0, import_react7.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null - ), -- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) ++ ), + (0, import_react7.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + ); +} @@ -2519,7 +2510,8 @@ diff --git a/client.js b/client.js + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, + name, + (0, import_react7.createElement)("span", { className: "bt-remote-handle", title: source.remote.home ?? handle, "aria-label": t("Friend's Bot") }, (0, import_react7.createElement)(GlobeMark), handle) -+ ), + ), +- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + (0, import_react7.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), + files.length ? (0, import_react7.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null + ); @@ -2773,7 +2765,7 @@ diff --git a/client.js b/client.js actions.noteTurnTime(sessionId, turn, "first", start); }, [sessionId, turn, start]); const visit = useUi((state) => state.visits?.[sessionId]); -@@ -4199,7 +4199,7 @@ function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTu +@@ -4199,11 +4199,29 @@ function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTu const own = state[sessionId]?.[turn]; return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; }); @@ -2782,6 +2774,28 @@ diff --git a/client.js b/client.js } function Nothing() { return null; + } ++var HIDDEN_REMOVED_TOOLS = /* @__PURE__ */ new Set(["ask_user_question"]); ++function isHiddenToolRemoval(node) { ++ const content = node?.data?.content; ++ if (!Array.isArray(content) || content.length === 0) return false; ++ return content.every((block) => block?.type === "tool-removal" && HIDDEN_REMOVED_TOOLS.has(block.toolName)); ++} ++function ContextCell({ node }) { ++ if (isHiddenToolRemoval(node)) return null; ++ const content = Array.isArray(node?.data?.content) ? node.data.content : []; ++ const parts = []; ++ for (const block of content) { ++ if (block?.type === "tool-removal" && HIDDEN_REMOVED_TOOLS.has(block.toolName)) continue; ++ if (block?.type === "tool-removal" && block.toolName) parts.push(`Tool removed: ${block.toolName}`); ++ else if (block?.type === "tool-addition" && block.toolName) parts.push(`Tool added: ${block.toolName}`); ++ } ++ if (parts.length === 0) return null; ++ return (0, import_react7.createElement)("div", { className: "bt-event" }, parts.join(" · ")); ++} + var SEPARATOR_GAP_MS = 30 * 60 * 1e3; + function clockLabel(time, now = Date.now()) { + const date = new Date(time); @@ -4236,7 +4236,7 @@ var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > vis function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { const turn = location2.turn; @@ -10010,6 +10024,16 @@ diff --git a/client.js b/client.js ]; const installConversation = () => [ installCompiledStyles(CONVERSATION_CSS, "conversation"), +@@ -14057,6 +14057,9 @@ function apply(ctx) { + // every message. + shadow("conversation.chat.node", { key: "question-reply", priority: -10, locale: "chat" }, AnsweredCell), + shadow("conversation.chat.node", { key: "compaction", priority: -10, locale: "chat" }, Nothing), ++ // DSH's context row is "Tool removed: ask_user_question" after Bot sessions ++ // restrict that tool; hide it, keep other tool-change notices (#140). ++ shadow("conversation.chat.node", { key: "context", priority: -10, locale: "chat" }, ContextCell), + ...canDrawUserMessages() ? ["user", "steering"].map((key) => shadow("conversation.chat.node", { key, priority: -10, locale: "chat", inject: face }, UserMessageCell)) : [], + shadow("conversation.input.permission", { priority: -10 }, Nothing), + shadow("conversation.input.plan", { priority: -10 }, Nothing), @@ -14066,6 +14066,7 @@ function apply(ctx) { shadow("conversation.composer.dock", { id: "activity", order: 0, priority: -10 }, Nothing), shadow("conversation.composer.dock", { id: "usage", order: 1, priority: -10 }, Nothing), diff --git a/patches/ds-bot/files/test/hidden-tool-removal.test.js b/patches/ds-bot/files/test/hidden-tool-removal.test.js new file mode 100644 index 000000000..24c1e1cae --- /dev/null +++ b/patches/ds-bot/files/test/hidden-tool-removal.test.js @@ -0,0 +1,98 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +// ContextCell lives next to ToolCell; bundle it the same way so the DSH +// "Tool removed: ask_user_question" hide can be checked without a browser. +const { outputFiles } = await build({ + stdin: { + contents: "export { ContextCell, isHiddenToolRemoval, HIDDEN_REMOVED_TOOLS, ToolCell } from './src/client/cells.js'", + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) +const Fragment = Symbol('Fragment') +const h = (type, props, ...children) => ({ type, props: { ...props, children } }) +const react = { + createElement: h, Fragment, + useEffect: () => {}, useLayoutEffect: () => {}, + useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], + useMemo: make => make(), useRef: current => ({ current }), useId: () => 'id', +} +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, + document: { hidden: false }, + setTimeout: () => 0, clearTimeout: () => {}, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return { MarkdownText: ({ text }) => text } + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { ContextCell, isHiddenToolRemoval, HIDDEN_REMOVED_TOOLS, ToolCell } = module.exports + +const expand = (node) => { + if (node === null || node === undefined || typeof node === 'boolean') return null + if (Array.isArray(node)) return node.map(expand) + if (typeof node !== 'object') return node + if (typeof node.type === 'function') return expand(node.type(node.props)) + return { ...node, props: { ...node.props, children: expand(node.props.children) } } +} +const text = (node) => { + if (node === null || node === undefined) return '' + if (Array.isArray(node)) return node.map(text).join('') + return typeof node === 'object' ? text(node.props.children) : String(node) +} + +const writer = { id: 'writer', name: 'Writer', color: 'blue' } +const chief = { id: 'chief', name: 'Chief', color: 'green' } +const roster = { + bots: [writer, chief], byId: { writer, chief }, rooms: [], roomsById: {}, + exchangeTurns: {}, questions: {}, secretRequests: {}, secrets: [], +} +const showTool = (root) => text(expand(ToolCell({ + node: { key: 'node', data: { root }, location: { turn: { turn: 1 } } }, + sessionId: chief.id, + useRoster: select => select(roster), + useUi: select => select({ reactions: {}, visits: {} }), + actions: {}, +}))) + +test('#140: the context row hides FlyBot-removed tools, not other tool cells', () => { + const index = readFileSync(new URL('../src/client/index.js', import.meta.url), 'utf8') + assert.match(index, /key: 'context'/) + assert.match(index, /ContextCell/) + assert.equal(HIDDEN_REMOVED_TOOLS.has('ask_user_question'), true) + const removal = toolName => ({ data: { content: [{ type: 'tool-removal', toolName }] } }) + assert.equal(isHiddenToolRemoval(removal('ask_user_question')), true) + assert.equal(isHiddenToolRemoval(removal('bash')), false) + assert.equal(isHiddenToolRemoval({ data: { content: [{ type: 'tool-addition', toolName: 'web_search' }] } }), false) + assert.equal(ContextCell({ node: removal('ask_user_question') }), null) + assert.equal(text(expand(ContextCell({ node: removal('ask_user_question') }))), '') + assert.match(text(expand(ContextCell({ node: removal('bash') }))), /Tool removed: bash/) + assert.match(text(expand(ContextCell({ node: { data: { content: [{ type: 'tool-addition', toolName: 'web_search' }] } } }))), /Tool added: web_search/) + const mixed = { + data: { + content: [ + { type: 'tool-removal', toolName: 'ask_user_question' }, + { type: 'tool-addition', toolName: 'web_search' }, + ], + }, + } + assert.equal(isHiddenToolRemoval(mixed), false) + const mixedText = text(expand(ContextCell({ node: mixed }))) + assert.match(mixedText, /Tool added: web_search/) + assert.doesNotMatch(mixedText, /ask_user_question/) + const native = { + kind: 'tool-result', name: 'message_bot', callId: 'direct', + call: { name: 'message_bot', argsRaw: JSON.stringify({ to: 'Writer' }) }, + content: [{ type: 'text', text: '' }], isError: false, subCalls: [], + } + assert.match(showTool(native), /Messaged.*Writer/) +})