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
1 change: 1 addition & 0 deletions changelog.d/140-tool-removed-cell.fixed.md
Original file line number Diff line number Diff line change
@@ -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))
21 changes: 21 additions & 0 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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),
Expand Down
24 changes: 24 additions & 0 deletions core/src/client/cells.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Comment on lines +516 to +517

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route context labels through the locale service

When the DSH language is Chinese and hidePluginTools changes a Bot's allowed plugin tools (core/src/host/hooks.js:642-656), this replacement renders those non-hidden addition/removal blocks with hard-coded English. Client copy is meant to follow the active DSH language (core/src/client/i18n.js:1-3), and unlike the surrounding cells these labels never call t(), so the rows stay English and cannot update on a language switch; route both labels through the locale service and add their Chinese entries.

Useful? React with 👍 / 👎.

}
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()) {
Expand Down
5 changes: 4 additions & 1 deletion core/src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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),
Expand Down
98 changes: 98 additions & 0 deletions core/test/hidden-tool-removal.test.js
Original file line number Diff line number Diff line change
@@ -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/)
})
4 changes: 4 additions & 0 deletions docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<untrusted-message>` 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).
49 changes: 48 additions & 1 deletion patches/ds-bot/0003-flybot-api-client.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down Expand Up @@ -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'
Expand All @@ -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']

Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading