diff --git a/core/client.js b/core/client.js index 73b328ad4..09506442c 100644 --- a/core/client.js +++ b/core/client.js @@ -6207,7 +6207,38 @@ function Nothing() { } var HIDDEN_REMOVED_TOOLS = /* @__PURE__ */ new Set(["ask_user_question"]); var isConnectorTool = (name) => typeof name === "string" && /^mcp__(github|composio)__/.test(name); -var isHiddenNotice = (block) => block?.type === "tool-removal" && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName)) || block?.type === "tool-addition" && (isConnectorTool(block.toolName) || isPluginTool(block.toolName)); +var SILENT_REGISTRATION_TOOLS = /* @__PURE__ */ new Set([ + "list_bots", + "message_bot", + "stop_bot", + "create_bot", + "update_bot", + "list_models", + "bot_schedules", + "create_group", + "update_group", + "delete_group", + "post_to_group", + "ask_user", + "track_tasks", + "request_secret", + "list_secrets", + "group_relay", + "read_own_chat", + "read_group_chat", + "remember", + "forget", + "recall", + "read_browser", + "list_capabilities", + "save_capability", + "delete_capability", + "attach_capability", + "detach_capability", + "track_links" +]); +var isSilentRegistration = (name) => typeof name === "string" && SILENT_REGISTRATION_TOOLS.has(name); +var isHiddenNotice = (block) => block?.type === "tool-removal" && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName)) || block?.type === "tool-addition" && (isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName)); function isHiddenToolRemoval(node) { const content = node?.data?.content; if (!Array.isArray(content) || content.length === 0) return false; diff --git a/core/src/client/cells.js b/core/src/client/cells.js index bae37b105..966f0d477 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -654,14 +654,31 @@ 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. +// Hide those notices. A tool a person turns on, and an unrelated removal, still show. export const HIDDEN_REMOVED_TOOLS = new Set(['ask_user_question']) // Connector tools come and go with a Bot's connector switch (#335), not with anything the Bot did. const isConnectorTool = name => typeof name === 'string' && /^mcp__(github|composio)__/.test(name) // Plugin tools a per-Bot mask hides (tree's create_child_bot, list_children, …) come and go with the // Bot's place, never with anything a person did, so their rows stay out of every chat (#419). -const isHiddenNotice = block => (block?.type === 'tool-removal' && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName))) - || (block?.type === 'tool-addition' && (isConnectorTool(block.toolName) || isPluginTool(block.toolName))) +// The product's own tools join a session's request header the same way: DSH writes one +// tool-addition (or tool-removal) row, and a plain turn prints it. That row is the +// registration, not a step of the chat (#1101). The names are TEAM_TOOLS in +// src/host/constants.js; the test walks that list, so a new team tool fails until it +// is listed here. track_links is the same notice on the other home of the report; +// this tree does not register that name. +const SILENT_REGISTRATION_TOOLS = new Set([ + 'list_bots', 'message_bot', 'stop_bot', 'create_bot', 'update_bot', 'list_models', + 'bot_schedules', + 'create_group', 'update_group', 'delete_group', 'post_to_group', 'ask_user', 'track_tasks', + 'request_secret', 'list_secrets', 'group_relay', 'read_own_chat', + 'read_group_chat', 'remember', 'forget', 'recall', 'read_browser', + 'list_capabilities', 'save_capability', 'delete_capability', + 'attach_capability', 'detach_capability', + 'track_links', +]) +const isSilentRegistration = name => typeof name === 'string' && SILENT_REGISTRATION_TOOLS.has(name) +const isHiddenNotice = block => (block?.type === 'tool-removal' && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName))) + || (block?.type === 'tool-addition' && (isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName))) export function isHiddenToolRemoval(node) { const content = node?.data?.content diff --git a/core/test/hidden-tool-removal.test.js b/core/test/hidden-tool-removal.test.js index c4ebe58c9..994d299c4 100644 --- a/core/test/hidden-tool-removal.test.js +++ b/core/test/hidden-tool-removal.test.js @@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { runInNewContext } from 'node:vm' import { build } from 'esbuild' +import { TEAM_TOOLS } from '../src/host/constants.js' // 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. @@ -132,3 +133,36 @@ test('#419: plugin tools a per-Bot mask takes away (tree) leave no Tool removed indexRoster({ bots: [], rooms: [] }) assert.match(text(expand(ContextCell({ node: block('tool-removal', 'create_child_bot') }))), /Tool removed: create_child_bot/) }) + +// DSH appends one developer row when a session's request header gains or loses a tool +// (source.kind tool-registry). The product's own tools join that way on a plain turn; +// the row is the registration, not a step of the chat (#1101). track_links is the same +// notice from the other home of that report. A tool a person turns on still shows. +test('#1101: product tool registration leaves no Tool added / Tool removed line', () => { + const block = (type, toolName) => ({ data: { content: [{ type, toolName }] } }) + // TEAM_TOOLS plus track_links, the same notice on the other home of the report. + for (const name of [...TEAM_TOOLS, 'track_links']) { + assert.equal(isHiddenToolRemoval(block('tool-addition', name)), true, name) + assert.equal(isHiddenToolRemoval(block('tool-removal', name)), true, name) + assert.equal(text(expand(ContextCell({ node: block('tool-addition', name) }))), '', name) + assert.equal(text(expand(ContextCell({ node: block('tool-removal', name) }))), '', name) + } + const mixed = { + data: { + content: [ + { type: 'tool-addition', toolName: 'track_tasks' }, + { type: 'tool-addition', toolName: 'track_links' }, + { type: 'tool-addition', toolName: 'web_search' }, + ], + }, + } + assert.equal(isHiddenToolRemoval(mixed), false) + assert.equal(text(expand(ContextCell({ node: mixed }))), 'Tool added: web_search') + // Using the tool is a different row. This one only drops the registration notice. + const call = { + kind: 'tool-result', name: 'message_bot', callId: 'used', + call: { name: 'message_bot', argsRaw: JSON.stringify({ to: 'Writer' }) }, + content: [{ type: 'text', text: '' }], isError: false, subCalls: [], + } + assert.match(showTool(call), /Messaged.*Writer/) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index d51cdac8b..272b53a3a 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -205,7 +205,8 @@ A group or face room shows only its own work (#553, owner decision 2026-10-20 23 ### 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`. +- 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`). A tool a person turns on, and an unrelated removal, still show; `tool-call` cells are unchanged. Empty shadowed nodes collapse via the existing `:empty` flow-item rule. Covered by `core/test/tool-cells.test.js`. +- The product's own tools are the same kind of row (#1101). DSH appends a developer message when a session's request header gains or loses a tool. `ContextCell` does not print `Tool added` / `Tool removed` for the team-tool names (`TEAM_TOOLS` in `src/host/constants.js`; the client keeps the same list) or for `track_links`, the notice the other home of that report showed on a plain math turn. A call the Bot actually makes stays a tool cell. `web_search` and `bash` still show. Covered by `core/test/hidden-tool-removal.test.js`. - A Bot's held reply to a friend is drawn on its own turn (#418): `AssistantCell` (`cells.js`) finds it in the Bot's `heldReplies` (`text.js` `heldReplyOf`, by session and turn) and wraps the bubbles in `HeldReply` (`bt-held-reply`, `data-state` pending / approved / rejected / failed; pending and not-sent bubbles are dashed and muted). The turn's closing text carries `HeldReplyActions` (`held-reply.js`): while it waits, the holding policy's inline `flybot.approvals` entry (`inline: true`, props `{ held, decide, call, actions }`) or plain Approve / Reject, and it claims the id so no overlay card or pointer repeats it; after the decision a quiet `Sent to {to}` / `Not sent` line (Retry for a failed send that `canRetry()` allows; a terminal failure draws no button, #968); a reply that went through Auto-send (`auto`, #417) is a plain bubble with `Auto-sent to {to}`. The recipient is the person who gets the reply (#418, #498): the host's `toOwner` (from the hold's `recipientName`, stored, so the same live and after a reload), else the owner of the friend's chat (`friendBotOwner`), so the card reads `Chief → SelfB1233`. The sidebar preview (`previewOf` `unsentTurns`) shows the friend's line for a turn whose reply did not go. The overlay ignores inline entries. - The held-send overlay (`FlyBotApprovals`, `flybot-slots.js`) runs every hook before its `if (pending.length === 0) return null`: a hook after that return made React throw on the first held message and no card ever drew (#254). `test/overlay-approvals.test.js` renders it with a stand-in that enforces React's hook-count rule. diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index e7cad5918..badcbd3dc 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -796,20 +796,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,38 @@ export function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi +@@ -411,6 +411,55 @@ 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. ++// Hide those notices. A tool a person turns on, and an unrelated removal, still show. +export const HIDDEN_REMOVED_TOOLS = new Set(['ask_user_question']) +// Connector tools come and go with a Bot's connector switch (#335), not with anything the Bot did. +const isConnectorTool = name => typeof name === 'string' && /^mcp__(github|composio)__/.test(name) +// Plugin tools a per-Bot mask hides (tree's create_child_bot, list_children, …) come and go with the +// Bot's place, never with anything a person did, so their rows stay out of every chat (#419). -+const isHiddenNotice = block => (block?.type === 'tool-removal' && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName))) -+ || (block?.type === 'tool-addition' && (isConnectorTool(block.toolName) || isPluginTool(block.toolName))) ++// The product's own tools join a session's request header the same way: DSH writes one ++// tool-addition (or tool-removal) row, and a plain turn prints it. That row is the ++// registration, not a step of the chat (#1101). The names are TEAM_TOOLS in ++// src/host/constants.js; the test walks that list, so a new team tool fails until it ++// is listed here. track_links is the same notice on the other home of the report; ++// this tree does not register that name. ++const SILENT_REGISTRATION_TOOLS = new Set([ ++ 'list_bots', 'message_bot', 'stop_bot', 'create_bot', 'update_bot', 'list_models', ++ 'bot_schedules', ++ 'create_group', 'update_group', 'delete_group', 'post_to_group', 'ask_user', 'track_tasks', ++ 'request_secret', 'list_secrets', 'group_relay', 'read_own_chat', ++ 'read_group_chat', 'remember', 'forget', 'recall', 'read_browser', ++ 'list_capabilities', 'save_capability', 'delete_capability', ++ 'attach_capability', 'detach_capability', ++ 'track_links', ++]) ++const isSilentRegistration = name => typeof name === 'string' && SILENT_REGISTRATION_TOOLS.has(name) ++const isHiddenNotice = block => (block?.type === 'tool-removal' && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName))) ++ || (block?.type === 'tool-addition' && (isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName))) + +export function isHiddenToolRemoval(node) { + const content = node?.data?.content diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 1ee66d71c..29ca6ffd0 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -3995,7 +3995,7 @@ diff --git a/client.js b/client.js .bt-account{flex:none;width:36px;height:36px;border:0;padding:0;background:none;border-radius:50%;cursor:pointer;display:inline-flex} .bt-account:hover .bt-you,.bt-account[aria-expanded=true] .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} /* Popover keyframes move translate and scale, which compose with a transform the popover -@@ -2488,2292 +2488,4042 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} +@@ -2488,2292 +2488,4073 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} @keyframes bt-rise{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}} @media (prefers-reduced-motion:reduce){.bt-member,.bt-addlist{animation:none}} @@ -7047,7 +7047,38 @@ diff --git a/client.js b/client.js - return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); +var HIDDEN_REMOVED_TOOLS = /* @__PURE__ */ new Set(["ask_user_question"]); +var isConnectorTool = (name) => typeof name === "string" && /^mcp__(github|composio)__/.test(name); -+var isHiddenNotice = (block) => block?.type === "tool-removal" && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName)) || block?.type === "tool-addition" && (isConnectorTool(block.toolName) || isPluginTool(block.toolName)); ++var SILENT_REGISTRATION_TOOLS = /* @__PURE__ */ new Set([ ++ "list_bots", ++ "message_bot", ++ "stop_bot", ++ "create_bot", ++ "update_bot", ++ "list_models", ++ "bot_schedules", ++ "create_group", ++ "update_group", ++ "delete_group", ++ "post_to_group", ++ "ask_user", ++ "track_tasks", ++ "request_secret", ++ "list_secrets", ++ "group_relay", ++ "read_own_chat", ++ "read_group_chat", ++ "remember", ++ "forget", ++ "recall", ++ "read_browser", ++ "list_capabilities", ++ "save_capability", ++ "delete_capability", ++ "attach_capability", ++ "detach_capability", ++ "track_links" ++]); ++var isSilentRegistration = (name) => typeof name === "string" && SILENT_REGISTRATION_TOOLS.has(name); ++var isHiddenNotice = (block) => block?.type === "tool-removal" && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName)) || block?.type === "tool-addition" && (isConnectorTool(block.toolName) || isPluginTool(block.toolName) || isSilentRegistration(block.toolName)); +function isHiddenToolRemoval(node) { + const content = node?.data?.content; + if (!Array.isArray(content) || content.length === 0) return false; diff --git a/patches/ds-bot/files/test/hidden-tool-removal.test.js b/patches/ds-bot/files/test/hidden-tool-removal.test.js index c4ebe58c9..994d299c4 100644 --- a/patches/ds-bot/files/test/hidden-tool-removal.test.js +++ b/patches/ds-bot/files/test/hidden-tool-removal.test.js @@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { runInNewContext } from 'node:vm' import { build } from 'esbuild' +import { TEAM_TOOLS } from '../src/host/constants.js' // 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. @@ -132,3 +133,36 @@ test('#419: plugin tools a per-Bot mask takes away (tree) leave no Tool removed indexRoster({ bots: [], rooms: [] }) assert.match(text(expand(ContextCell({ node: block('tool-removal', 'create_child_bot') }))), /Tool removed: create_child_bot/) }) + +// DSH appends one developer row when a session's request header gains or loses a tool +// (source.kind tool-registry). The product's own tools join that way on a plain turn; +// the row is the registration, not a step of the chat (#1101). track_links is the same +// notice from the other home of that report. A tool a person turns on still shows. +test('#1101: product tool registration leaves no Tool added / Tool removed line', () => { + const block = (type, toolName) => ({ data: { content: [{ type, toolName }] } }) + // TEAM_TOOLS plus track_links, the same notice on the other home of the report. + for (const name of [...TEAM_TOOLS, 'track_links']) { + assert.equal(isHiddenToolRemoval(block('tool-addition', name)), true, name) + assert.equal(isHiddenToolRemoval(block('tool-removal', name)), true, name) + assert.equal(text(expand(ContextCell({ node: block('tool-addition', name) }))), '', name) + assert.equal(text(expand(ContextCell({ node: block('tool-removal', name) }))), '', name) + } + const mixed = { + data: { + content: [ + { type: 'tool-addition', toolName: 'track_tasks' }, + { type: 'tool-addition', toolName: 'track_links' }, + { type: 'tool-addition', toolName: 'web_search' }, + ], + }, + } + assert.equal(isHiddenToolRemoval(mixed), false) + assert.equal(text(expand(ContextCell({ node: mixed }))), 'Tool added: web_search') + // Using the tool is a different row. This one only drops the registration notice. + const call = { + kind: 'tool-result', name: 'message_bot', callId: 'used', + call: { name: 'message_bot', argsRaw: JSON.stringify({ to: 'Writer' }) }, + content: [{ type: 'text', text: '' }], isError: false, subCalls: [], + } + assert.match(showTool(call), /Messaged.*Writer/) +})