Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
23 changes: 20 additions & 3 deletions core/src/client/cells.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions core/test/hidden-tool-removal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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/)
})
3 changes: 2 additions & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 21 additions & 4 deletions patches/ds-bot/0003-flybot-api-client.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 33 additions & 2 deletions patches/ds-bot/0008-client-bundle.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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}}

Expand Down Expand Up @@ -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;
Expand Down
34 changes: 34 additions & 0 deletions patches/ds-bot/files/test/hidden-tool-removal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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/)
})
Loading