mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
chore: clean up LOBE-XXX code annotations (#15135)
* chore: clean up LOBE-XXX annotations from codebase comments - Remove 【LOBE-XXX】 bracket markers - Remove LOBE-XXXX references from inline comments - Clean up test descriptions containing LOBE identifiers - Preserve linear.app URLs and code-level regex patterns - Generated: 2026-05-23 02:30:09 * 🐛 fix(tests): restore () in arrow callbacks broken by annotation cleanup The LOBE-XXX annotation cleanup script over-matched `(LOBE-XXXX', () =>` and stripped the callback `()`, leaving invalid syntax like `describe(..., => {` and `it(..., async => {` across 24 test files. This caused parse failures in Test Packages, Test Desktop App, Test Database lint, and Test App shard runs. Restoring `()` / `async ()` unblocks the suites while keeping the ticket-text cleanup intact. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 🐛 fix(hintFormat-test): restore label + ellipsis in stripMarkdownLinks fixture The annotation cleanup stripped `LOBE-8516` from a markdown-link's *label* (`[LOBE-8516](/task/T-1)` → `[](/task/T-1)`), which then survived `stripMarkdownLinks` because the pattern requires non-empty link text — the test expected the link to disappear and asserted equality on a LOBE-free output. The same line also lost a `.` from the trailing `...` indicator in both input and expected strings. Substitute a neutral Chinese label (`发布计划`) so the link continues to exercise the multi-link substitution path, and restore the full `...` ellipsis. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Arvin Xu <arvinxx@lobehub.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -810,7 +810,7 @@ export function registerBotCommand(program: Command) {
|
||||
name: 'group-allowlist',
|
||||
});
|
||||
|
||||
// ── watch-keywords (LOBE-8891) ────────────────────────
|
||||
// ── watch-keywords () ────────────────────────
|
||||
|
||||
registerWatchKeywordsCommand(bot);
|
||||
|
||||
|
||||
@@ -605,7 +605,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AskUserQuestion MCP server (LOBE-8725) ───
|
||||
// ─── AskUserQuestion MCP server () ───
|
||||
|
||||
/**
|
||||
* Lazy single-instance MCP server for CC's AskUserQuestion replacement.
|
||||
@@ -651,7 +651,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
|
||||
// `alwaysLoad: true` is the undocumented CC flag that promotes our
|
||||
// server's tool out of the deferred set so the model calls it directly
|
||||
// (no ToolSearch hop). See LOBE-8725 spike notes — falls back to the
|
||||
// (no ToolSearch hop). See spike notes — falls back to the
|
||||
// 2-hop ToolSearch path if a future CC drops the flag, no breakage.
|
||||
const config = {
|
||||
mcpServers: {
|
||||
|
||||
@@ -717,7 +717,7 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
* `stdout.on('end')` handler can schedule `pipeline.flush()` onto the
|
||||
* broadcast queue), then drain the queue, then broadcast complete.
|
||||
*/
|
||||
describe('exit-before-end ordering (LOBE-8516 phase 0 race)', () => {
|
||||
describe('exit-before-end ordering (phase 0 race)', () => {
|
||||
let broadcasts: Array<{ channel: string; data: any }>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -808,7 +808,7 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('app-quit cleanup of AskUserQuestion temp configs (LOBE-8725)', () => {
|
||||
describe('app-quit cleanup of AskUserQuestion temp configs ()', () => {
|
||||
// The async exit-handler cleanup races Electron's main-process teardown
|
||||
// and used to leak `lobe-cc-mcp-<opId>.json` files in `os.tmpdir()` on
|
||||
// every quit. The controller now unlinks pending intervention temp
|
||||
|
||||
@@ -28,7 +28,7 @@ export const claudeCodeDriver: HeterogeneousAgentDriver = {
|
||||
args: [
|
||||
...DESKTOP_CLAUDE_CODE_ARGS,
|
||||
// Wire the controller-managed temp mcp.json (AskUserQuestion server,
|
||||
// see LOBE-8725) when present. Path-based config is required — CC
|
||||
// see ) when present. Path-based config is required — CC
|
||||
// does not accept inline JSON for `--mcp-config`.
|
||||
...(mcpConfigPath ? ['--mcp-config', mcpConfigPath] : []),
|
||||
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
|
||||
|
||||
@@ -530,7 +530,7 @@ export class GeneralChatAgent implements Agent {
|
||||
// Silent-drop diagnostic: LLM emitted raw tool_calls but every one
|
||||
// failed to resolve to a known tool (e.g. malformed names without the
|
||||
// `____` separator). Surface this in reasonDetail so dashboards can
|
||||
// distinguish it from a genuine no-tool completion. See LOBE-8696.
|
||||
// distinguish it from a genuine no-tool completion. See .
|
||||
const rawToolCallCount = result?.tool_calls?.length ?? 0;
|
||||
const hasUnresolvedToolCalls = rawToolCallCount > 0;
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ describe('GeneralChatAgent', () => {
|
||||
expect(result).toEqual(expectCompressionInstruction(state.messages));
|
||||
});
|
||||
|
||||
// LOBE-8973 Bug B: state.tools must feed into the compression budget,
|
||||
// Bug B: state.tools must feed into the compression budget,
|
||||
// otherwise large tool manifests (16-22K tokens observed on openrouter)
|
||||
// slip past the threshold and overflow the model context window.
|
||||
it('should fold state.tools into the compression budget on init', async () => {
|
||||
@@ -227,7 +227,7 @@ describe('GeneralChatAgent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Regression for LOBE-8696: when the LLM emits tool_calls whose names
|
||||
// Regression for when the LLM emits tool_calls whose names
|
||||
// can't be resolved (e.g. `activateTools` instead of
|
||||
// `lobe-activator____activateTools`), the agent used to silently finish
|
||||
// with "completed without tool calls". Surface the unresolved names so
|
||||
@@ -766,7 +766,7 @@ describe('GeneralChatAgent', () => {
|
||||
expect(result).toEqual(expectCompressionInstruction(state.messages));
|
||||
});
|
||||
|
||||
// LOBE-8973 follow-up: when state.forceFinish is set, RuntimeExecutors strips
|
||||
// follow-up: when state.forceFinish is set, RuntimeExecutors strips
|
||||
// every tool before the LLM call (buildStepToolDelta returns deactivatedToolIds
|
||||
// ['*']). The compression budget must mirror that stripping — otherwise the
|
||||
// tool schemas push the budget over threshold and we burn an extra summarization
|
||||
|
||||
@@ -1172,7 +1172,7 @@ describe('AgentRuntime', () => {
|
||||
expect(result.nextContext?.payload).toHaveProperty('toolCount', 3);
|
||||
});
|
||||
|
||||
// Regression test for LOBE-7759: Gemini 3 thoughtSignature must survive the
|
||||
// Regression test for Gemini 3 thoughtSignature must survive the
|
||||
// OpenAI ToolsCalling -> ChatToolPayload normalization in call_tools_batch,
|
||||
// otherwise Gemini 3 400s on the second tool_call turn.
|
||||
it('should preserve thoughtSignature when normalizing call_tools_batch ToolsCalling payload', async () => {
|
||||
@@ -1431,9 +1431,9 @@ describe('AgentRuntime', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multi-Round Batch Tool Execution (LOBE-1657)', () => {
|
||||
describe('Multi-Round Batch Tool Execution ()', () => {
|
||||
/**
|
||||
* This test verifies the fix for LOBE-1657:
|
||||
* This test verifies the fix for
|
||||
* When executing multiple rounds of batch tool calls, tool messages should not be duplicated.
|
||||
*
|
||||
* Root cause: The mergeToolResults method was extracting ALL tool messages from each result,
|
||||
|
||||
@@ -132,7 +132,7 @@ describe('tokenCounter', () => {
|
||||
expect(result.currentTokenCount).toBe(0);
|
||||
});
|
||||
|
||||
// LOBE-8973 Bug B: tool definitions also occupy the input window, so a
|
||||
// Bug B: tool definitions also occupy the input window, so a
|
||||
// message payload that fits when tools are absent can overflow once tool
|
||||
// definitions are accounted for. Without this, compression only fires on
|
||||
// message size and leaves the tool budget to silently push the request
|
||||
|
||||
@@ -63,7 +63,7 @@ export interface CompressionCheckResult {
|
||||
*
|
||||
* Uses {@link countContextTokens} under the hood, so the input estimate
|
||||
* accounts for tool calls, reasoning, and tool definitions in addition to
|
||||
* `content` (see LOBE-8964 for the calibration data).
|
||||
* `content` (see for the calibration data).
|
||||
*/
|
||||
export function shouldCompress(
|
||||
messages: UIChatMessage[],
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
*
|
||||
* Variables (auto-injected by context-engine):
|
||||
* - {{date}} - Current date (e.g., "12/25/2023")
|
||||
* - {{model}} - Current model ID (requires LOBE-1803)
|
||||
* - {{provider}} - Current provider (requires LOBE-1803)
|
||||
* - {{model}} - Current model ID (requires )
|
||||
* - {{provider}} - Current provider (requires )
|
||||
*/
|
||||
export const supervisorSystemRole = `You are LobeAI, an intelligent team coordinator developed by LobeHub, powered by {{model}}. You are orchestrating the multi-agent group "{{GROUP_TITLE}}". Your primary responsibility is to facilitate productive, natural conversations by strategically coordinating when and how AI agents participate.
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ class AgentManagementExecutor extends BaseExecutor<typeof AgentManagementApiName
|
||||
}
|
||||
|
||||
// Register afterCompletion to execute the agent.
|
||||
// Runtime routing is fully delegated to dispatchNonHeteroSubAgent (LOBE-8927).
|
||||
// Runtime routing is fully delegated to dispatchNonHeteroSubAgent ().
|
||||
ctx.registerAfterCompletion(async () => {
|
||||
const get = useChatStore.getState;
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ const formatTimeout = (ms: number | undefined): string | undefined => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Dedicated inspector for CC's long-running `Monitor` tool (LOBE-8998).
|
||||
* Dedicated inspector for CC's long-running `Monitor` tool ().
|
||||
*
|
||||
* Visual contract:
|
||||
* [Monitor] <MonitorIcon> <description-or-command> · <timeout> [✓/✗]
|
||||
|
||||
@@ -44,7 +44,7 @@ export const ClaudeCodeInspectors = {
|
||||
translationKey: ClaudeCodeApiName.Grep,
|
||||
}),
|
||||
// Monitor is a long-running tracked tool — its turns drive a SignalCallbacks
|
||||
// accordion below the AssistantGroup (LOBE-8998). The dedicated inspector
|
||||
// accordion below the AssistantGroup (). The dedicated inspector
|
||||
// uses the lucide `Monitor` (screen) icon to match the tool name.
|
||||
[ClaudeCodeApiName.Monitor]: MonitorInspector,
|
||||
[ClaudeCodeApiName.Read]: ReadInspector,
|
||||
|
||||
@@ -51,7 +51,7 @@ interface QABlockProps {
|
||||
* One question/answer pair for the completed Render. The original question
|
||||
* stays visible (header + body); the answer renders as one card per picked
|
||||
* option (multi-select fans out into multiple rows). When `answer` is
|
||||
* absent — older messages persisted before LOBE-8725 added structured
|
||||
* absent — older messages persisted before added structured
|
||||
* storage — we show a `—` placeholder so the layout stays uniform.
|
||||
*/
|
||||
const QABlock = memo<QABlockProps>(({ question, answer }) => {
|
||||
|
||||
@@ -26,7 +26,7 @@ export enum ClaudeCodeApiName {
|
||||
/**
|
||||
* Synthetic apiName the adapter rewrites the local
|
||||
* `mcp__lobe_cc__ask_user_question` MCP tool to. Routes the dedicated
|
||||
* intervention UI for CC's clarifying-question flow (LOBE-8725); not
|
||||
* intervention UI for CC's clarifying-question flow (); not
|
||||
* something CC's CLI emits directly.
|
||||
*/
|
||||
AskUserQuestion = 'askUserQuestion',
|
||||
@@ -38,7 +38,7 @@ export enum ClaudeCodeApiName {
|
||||
* Long-running command monitor (CC 2.1+). Spawns `command` as a tracked
|
||||
* background task; CC re-invokes the LLM each time the task pushes new
|
||||
* stdout (`system task_started` registers the task, `task_notification`
|
||||
* terminates it — see LOBE-8998 in the adapter). Rendered by a dedicated
|
||||
* terminates it — see in the adapter). Rendered by a dedicated
|
||||
* `MonitorInspector` so the chip iconography matches the SignalCallbacks
|
||||
* accordion underneath.
|
||||
*/
|
||||
@@ -104,7 +104,7 @@ export interface SkillArgs {
|
||||
/**
|
||||
* Arguments for CC's built-in `Monitor` tool — long-running command monitor.
|
||||
* CC spawns `command` as a tracked background task; `system task_started`
|
||||
* registers it and `system task_notification` ends it (see LOBE-8998 in the
|
||||
* registers it and `system task_notification` ends it (see in the
|
||||
* CC adapter). Each stdout push between those two lifecycle events fires a
|
||||
* new LLM turn that's surfaced as a SignalCallbacks entry in the UI.
|
||||
*
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* (not `directory`), so cwd fell back to `process.cwd()`. With no glob/include
|
||||
* filter, `tool.*name.*mcp` matched every dist/* bundle and tsbuildinfo.
|
||||
*
|
||||
* See LOBE-8666 / the agent screenshot that reported the leak.
|
||||
* See / the agent screenshot that reported the leak.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
|
||||
@@ -436,7 +436,7 @@ export class MessagesEngine {
|
||||
// PlaceholderVariablesProcessor only walks `message.content`, so it MUST
|
||||
// run after the hoist or it would silently miss every placeholder buried
|
||||
// inside an assistantGroup. (Regression discovered while wiring lobehub
|
||||
// skill identity placeholders — see LOBE-6882.)
|
||||
// skill identity placeholders — see .)
|
||||
new PlaceholderVariablesProcessor({ variableGenerators: variableGenerators || {} }),
|
||||
|
||||
// =============================================
|
||||
|
||||
@@ -714,7 +714,7 @@ describe('ToolNameResolver', () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
// Regression for LOBE-8696: some models (e.g. deepseek-v4-pro) drop the
|
||||
// Regression for some models (e.g. deepseek-v4-pro) drop the
|
||||
// `<identifier>____` prefix and emit only the bare API name. When that
|
||||
// bare name uniquely matches an API in the available manifests, we should
|
||||
// recover the identifier from the manifest instead of silently dropping
|
||||
|
||||
@@ -23,7 +23,7 @@ const log = debug('context-engine:processor:MessageContentProcessor');
|
||||
* does not declare vision capability. Dropping the part silently loses the
|
||||
* conversational signal that an image ever existed, while leaving the raw part
|
||||
* in the payload causes provider-side 400s (e.g. DeepSeek rejects the
|
||||
* `image_url` variant outright — see LOBE-7214).
|
||||
* `image_url` variant outright — see ).
|
||||
*/
|
||||
export const VISION_DOWNGRADE_PLACEHOLDER = '[image omitted: not supported by this model]';
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ export class ToolCallProcessor extends BaseProcessor {
|
||||
// Sanitize `arguments` as a last line of defense against historical messages
|
||||
// whose tool_calls arguments are invalid JSON (e.g. persisted before the
|
||||
// server-side sanitizer landed, or produced by an older client). Strict
|
||||
// providers like NVIDIA NIM otherwise 400 on the entire request. See LOBE-7761.
|
||||
// providers like NVIDIA NIM otherwise 400 on the entire request. See .
|
||||
const tool_calls = message.tools.map(
|
||||
(tool: any): MessageToolCall => ({
|
||||
function: {
|
||||
|
||||
@@ -148,7 +148,7 @@ describe('MessageContentProcessor', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// LOBE-7214 regression: historical messages are often persisted in the
|
||||
// regression: historical messages are often persisted in the
|
||||
// multimodal parts form (content is an array of {type: 'text' | 'image_url'}).
|
||||
// They bypass the legacy `imageList` code path. Switching to a non-vision
|
||||
// model (e.g. deepseek-chat) previously caused the processor to forward the
|
||||
@@ -954,7 +954,7 @@ describe('MessageContentProcessor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// LOBE-7214: assistant multimodal content (image generation output) must
|
||||
// assistant multimodal content (image generation output) must
|
||||
// also be downgraded when the target model lacks vision. Without this,
|
||||
// image parts get serialized back to `image_url` and DeepSeek 400s.
|
||||
it('should downgrade assistant multimodal image parts to placeholder text when vision is disabled', async () => {
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import type { PipelineContext } from '../../types';
|
||||
import { PlaceholderVariablesProcessor } from '../PlaceholderVariables';
|
||||
|
||||
/**
|
||||
* Regression for LOBE-6882 placeholder approach.
|
||||
* Regression for placeholder approach.
|
||||
*
|
||||
* Confirms that PlaceholderVariablesProcessor does substitute `{{...}}` tokens
|
||||
* inside `role: 'tool'` messages. If this test ever fails, it means the
|
||||
@@ -79,7 +79,7 @@ describe('PlaceholderVariablesProcessor — tool message substitution', () => {
|
||||
expect(result.messages[0].content).toBe('agent={{agent_id}}');
|
||||
});
|
||||
|
||||
// Regression for LOBE-9408: a tool error result (e.g. budget-exceeded) can
|
||||
// Regression for a tool error result (e.g. budget-exceeded) can
|
||||
// arrive with `content: undefined`. The content-preview logging step used to
|
||||
// call `JSON.stringify(undefined).slice(...)` — which throws because
|
||||
// `JSON.stringify(undefined)` returns `undefined`, not a string — crashing
|
||||
|
||||
@@ -264,7 +264,7 @@ describe('ToolCallProcessor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// LOBE-7761: protect against history poisoning from invalid tool_call arguments
|
||||
// protect against history poisoning from invalid tool_call arguments
|
||||
it('should sanitize invalid tool arguments in history to "{}"', async () => {
|
||||
const processor = new ToolCallProcessor(defaultConfig);
|
||||
const context = createContext([
|
||||
@@ -275,7 +275,7 @@ describe('ToolCallProcessor', () => {
|
||||
tools: [
|
||||
{
|
||||
apiName: 'executeCode',
|
||||
// exact shape from the LOBE-7761 NVIDIA/Qwen trace
|
||||
// exact shape from the NVIDIA/Qwen trace
|
||||
arguments: '{, "description": "Create data models", "language": "python"}',
|
||||
id: 'call_1',
|
||||
identifier: 'code',
|
||||
|
||||
@@ -346,7 +346,7 @@ describe('AgentDocumentInjector', () => {
|
||||
expect(injected).not.toContain('Progressive content hidden');
|
||||
});
|
||||
|
||||
// Regression: LOBE-9385 — `policyLoad: 'disabled'` rows were being routed
|
||||
// Regression: — `policyLoad: 'disabled'` rows were being routed
|
||||
// into the full-content bucket (the old `!== 'progressive'` filter), so
|
||||
// documents the user explicitly turned off still got inlined into the LLM
|
||||
// payload. The disabled row must show up in neither bucket.
|
||||
|
||||
@@ -131,7 +131,7 @@ describe('SystemRoleInjector', () => {
|
||||
expect(result.metadata.systemRoleInjected).toBe(true);
|
||||
});
|
||||
|
||||
it('should append systemRole after AgentDocumentBeforeSystemInjector output (regression for LOBE-6892)', async () => {
|
||||
it('should append systemRole after AgentDocumentBeforeSystemInjector output (regression for )', async () => {
|
||||
const provider = new SystemRoleInjector({
|
||||
systemRole: '你是一个思维活跃的工程师,擅长 Python、JavaScript、Docker、SQL。',
|
||||
});
|
||||
|
||||
@@ -231,7 +231,7 @@ export class ContextTreeBuilder {
|
||||
// Recursively collect all assistant messages in this group
|
||||
this.messageCollector.collectAssistantGroupMessages(message, idNode, children);
|
||||
|
||||
// Append external-signal callback blocks (LOBE-8998) at the END of
|
||||
// Append external-signal callback blocks () at the END of
|
||||
// children — one block per source tool that fired callbacks. They
|
||||
// ride INSIDE the AssistantGroup but BELOW the main-chain zigzag,
|
||||
// since the toolless reactive replies aren't part of the
|
||||
@@ -240,7 +240,7 @@ export class ContextTreeBuilder {
|
||||
children.push(...signalCallbacks);
|
||||
|
||||
// After the callbacks block, append the post-task-summary turns
|
||||
// (LOBE-8998) — toolless assistants tagged with
|
||||
// () — toolless assistants tagged with
|
||||
// `signal.type === 'task-completion'`, fired by the LLM after CC's
|
||||
// `task_notification` ended a long-running tool. They're peers of
|
||||
// the callbacks under the same tool_result; collecting them here
|
||||
|
||||
@@ -223,7 +223,7 @@ export class FlatListBuilder {
|
||||
processedIds,
|
||||
);
|
||||
|
||||
// Gather external-signal callback blocks (LOBE-8998) for any
|
||||
// Gather external-signal callback blocks () for any
|
||||
// tool in the chain that fired toolless reactive replies
|
||||
// (Monitor stdout pushes, etc.). Snapshot now so the UI doesn't
|
||||
// need to query messageMap; mark callback messages as processed
|
||||
@@ -233,7 +233,7 @@ export class FlatListBuilder {
|
||||
allMessages,
|
||||
);
|
||||
|
||||
// Post-task-summary turns (LOBE-8998) — toolless siblings of
|
||||
// Post-task-summary turns () — toolless siblings of
|
||||
// the callbacks under the same tool_result, tagged with
|
||||
// `signal.type === 'task-completion'`. Belong inside the same
|
||||
// AssistantGroup, rendered AFTER the SignalCallbacks accordion.
|
||||
@@ -942,7 +942,7 @@ export class FlatListBuilder {
|
||||
}
|
||||
|
||||
// Snapshot signal-callback blocks onto the virtual group message
|
||||
// (LOBE-8998) so AssistantGroupMessage can render `<SignalCallbacks>`
|
||||
// () so AssistantGroupMessage can render `<SignalCallbacks>`
|
||||
// without re-querying the store. Each callback Message becomes a
|
||||
// compact UISignalCallback with content + model/provider/sequence.
|
||||
if (signalCallbackBlocks && signalCallbackBlocks.length > 0) {
|
||||
@@ -961,7 +961,7 @@ export class FlatListBuilder {
|
||||
}));
|
||||
}
|
||||
|
||||
// Snapshot post-task-summary turns as content blocks (LOBE-8998).
|
||||
// Snapshot post-task-summary turns as content blocks ().
|
||||
// They render after `<SignalCallbacks>` inside the same group, via
|
||||
// a second `<Group>` that pulls live content from the store using
|
||||
// the block id (no need to denormalize text here — keeps streaming
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ContextNode, IdNode, Message, MessageNode, SignalCallbacksNode } f
|
||||
* Locally duplicated to avoid a cross-package import for a single
|
||||
* structural type.
|
||||
*
|
||||
* Phase 2 (LOBE-8999) promotes this to a dedicated `messages.signal`
|
||||
* Phase 2 () promotes this to a dedicated `messages.signal`
|
||||
* jsonb column. To migrate, swap the `metadata?.signal` lookup in
|
||||
* `getMessageSignal` below for `(msg as any).signal ?? msg.metadata?.signal`
|
||||
* — UI and node shape are unchanged.
|
||||
@@ -25,7 +25,7 @@ interface MessageSignal {
|
||||
* knows whether the step will end up using tools, so the collector
|
||||
* must defang that mismatch here.
|
||||
*
|
||||
* Phase 2 compat seam (LOBE-8999): when the `messages.signal` column
|
||||
* Phase 2 compat seam (): when the `messages.signal` column
|
||||
* lands, prefer it over `metadata.signal`.
|
||||
*/
|
||||
const getMessageSignal = (msg: Message): MessageSignal | undefined => {
|
||||
@@ -118,7 +118,7 @@ export class MessageCollector {
|
||||
// Only continue if the next assistant has the SAME agentId
|
||||
// Different agentId means it's a different agent responding (e.g., via speak tool)
|
||||
const isSameAgent = nextMsg.agentId === groupAgentId;
|
||||
// Skip signal-tagged toolless callbacks (LOBE-8998) — they're a
|
||||
// Skip signal-tagged toolless callbacks () — they're a
|
||||
// side-channel under the same parent tool and get collected
|
||||
// separately by `collectFlatSignalCallbacks`.
|
||||
if (getMessageSignal(nextMsg)) continue;
|
||||
@@ -204,7 +204,7 @@ export class MessageCollector {
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat-list variant — find post-task-summary assistants (LOBE-8998),
|
||||
* Flat-list variant — find post-task-summary assistants (),
|
||||
* i.e. toolless assistants tagged with
|
||||
* `metadata.signal.type === 'task-completion'`, fired by the LLM after
|
||||
* CC delivers `task_notification` for a long-running tool.
|
||||
@@ -278,7 +278,7 @@ export class MessageCollector {
|
||||
}
|
||||
|
||||
// Find the next main-chain assistant under this tool. Signal-tagged
|
||||
// toolless siblings (Monitor callbacks etc., LOBE-8998) share the
|
||||
// toolless siblings (Monitor callbacks etc., ) share the
|
||||
// same parent tool but live on a side-channel — skip them here so
|
||||
// the main chain still walks the real follower. The signal blocks
|
||||
// are emitted separately by `collectSignalCallbacks`.
|
||||
@@ -370,7 +370,7 @@ export class MessageCollector {
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect post-task-summary toolless siblings (LOBE-8998) — assistants
|
||||
* Collect post-task-summary toolless siblings () — assistants
|
||||
* tagged with `metadata.signal.type === 'task-completion'`, fired by
|
||||
* the LLM after CC delivers `system task_notification` for a long-
|
||||
* running tool (Monitor, etc.). Each one belongs inside the same
|
||||
@@ -495,7 +495,7 @@ export class MessageCollector {
|
||||
|
||||
// Pick the next main-chain assistant under this tool. Mirror the
|
||||
// skip rule used by `collectAssistantGroupMessages`: signal-tagged
|
||||
// toolless siblings (Monitor callbacks etc., LOBE-8998) share the
|
||||
// toolless siblings (Monitor callbacks etc., ) share the
|
||||
// parent tool but live on a side-channel — if they appear before
|
||||
// the real follower, blindly taking children[0] would end the
|
||||
// walk on a callback node and truncate the AssistantGroup tail.
|
||||
|
||||
@@ -429,9 +429,9 @@ describe('ContextTreeBuilder', () => {
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
// LOBE-8998: AssistantGroupNode embeds SignalCallbacksNode children
|
||||
// AssistantGroupNode embeds SignalCallbacksNode children
|
||||
// ────────────────────────────────────────────────────
|
||||
describe('AssistantGroup with signal callbacks (LOBE-8998)', () => {
|
||||
describe('AssistantGroup with signal callbacks ()', () => {
|
||||
it('appends SignalCallbacksNode at the end of AssistantGroup children', () => {
|
||||
const signalMeta = (sequence: number) => ({
|
||||
signal: {
|
||||
|
||||
@@ -707,9 +707,9 @@ describe('FlatListBuilder', () => {
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
// LOBE-8998: signal callbacks attached on virtual AssistantGroup
|
||||
// signal callbacks attached on virtual AssistantGroup
|
||||
// ────────────────────────────────────────────────────
|
||||
describe('signal callbacks (LOBE-8998)', () => {
|
||||
describe('signal callbacks ()', () => {
|
||||
it('attaches signalCallbacks to the virtual group and processes callback messages', () => {
|
||||
const signalMeta = (sequence: number) =>
|
||||
({
|
||||
|
||||
@@ -206,7 +206,7 @@ describe('MessageCollector', () => {
|
||||
});
|
||||
|
||||
it('skips signal-tagged callbacks when locating the group tail', () => {
|
||||
// LOBE-8998: when [signal callback, next tool-using assistant]
|
||||
// when [signal callback, next tool-using assistant]
|
||||
// both live under the same tool, the tail finder must follow the
|
||||
// real main-chain assistant — taking children[0] blindly lands on
|
||||
// the callback (which is a leaf) and truncates the AssistantGroup.
|
||||
@@ -304,7 +304,7 @@ describe('MessageCollector', () => {
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
// LOBE-8998: external signal callback collection
|
||||
// external signal callback collection
|
||||
// ────────────────────────────────────────────────────
|
||||
describe('collectSignalCallbacks', () => {
|
||||
const mkAssistant = (id: string, opts?: Partial<Message>): Message => ({
|
||||
@@ -522,7 +522,7 @@ describe('MessageCollector', () => {
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
// LOBE-8998: collectAssistantGroupMessages skips signal-tagged children
|
||||
// collectAssistantGroupMessages skips signal-tagged children
|
||||
// ────────────────────────────────────────────────────
|
||||
describe('collectAssistantGroupMessages with signal callbacks', () => {
|
||||
it('skips signal-tagged callbacks when picking the main-chain follower', () => {
|
||||
|
||||
@@ -51,7 +51,7 @@ If you don't have it, please run \`openssl rand -base64 32\` to create one.
|
||||
const client = new NeonPool({ connectionString });
|
||||
// NeonPool runs over WebSocket; transient drops surface as 'error' on the pool.
|
||||
// Without a listener Node escalates it to uncaughtException — on Vercel this killed
|
||||
// the entire Lambda 1800+ times in 5 minutes (see LOBE-8704).
|
||||
// the entire Lambda 1800+ times in 5 minutes (see ).
|
||||
client.on('error', (err: Error) => {
|
||||
console.error('[NeonPool] idle client error (swallowed to prevent process crash):', {
|
||||
code: (err as NodeJS.ErrnoException).code,
|
||||
|
||||
@@ -455,7 +455,7 @@ describe('DocumentModel', () => {
|
||||
});
|
||||
|
||||
describe('findBySource', () => {
|
||||
// Crawl dedupe (LOBE-9384) leans on this finder — same URL + sourceType
|
||||
// Crawl dedupe () leans on this finder — same URL + sourceType
|
||||
// must always return the existing row so repeated crawls update in place
|
||||
// instead of stacking new rows.
|
||||
it('finds a document by (source, sourceType)', async () => {
|
||||
|
||||
@@ -162,7 +162,7 @@ export class DocumentModel {
|
||||
*
|
||||
* Crawl-style ingestion flows (`sourceType: 'web'`) use this to dedupe by URL
|
||||
* so repeated crawls of the same page update the existing row instead of
|
||||
* appending a fresh one — see LOBE-9384.
|
||||
* appending a fresh one — see .
|
||||
*/
|
||||
findBySource = async (
|
||||
source: string,
|
||||
|
||||
@@ -389,7 +389,7 @@ describe('CompressionRepository', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests for LOBE-2066: MessageGroup aggregation in queryMessage
|
||||
* Tests for MessageGroup aggregation in queryMessage
|
||||
*
|
||||
* These tests verify the expected behavior for querying messages with compression groups,
|
||||
* specifically focusing on:
|
||||
|
||||
@@ -13,7 +13,7 @@ type LazyLoaderFactory = () => Promise<new () => FileLoaderInterface>;
|
||||
// caused the chunk to back-reference the main bundle for `detectUtf16NoBom`,
|
||||
// re-evaluating the main entry and re-running `new App()` →
|
||||
// `protocol.registerSchemesAsPrivileged` after app ready → throw on every
|
||||
// readFile of .md / .json / .ts / etc. See LOBE-* for the regression.
|
||||
// readFile of .md / .json / .ts / etc. See for the regression.
|
||||
const lazyFileLoaders: Record<Exclude<SupportedFileType, 'txt'>, LazyLoaderFactory> = {
|
||||
doc: async () => {
|
||||
const { DocLoader } = await import('./doc');
|
||||
|
||||
@@ -337,7 +337,7 @@ describe('ClaudeCodeAdapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToolSearch tool_reference content (LOBE-7369)', () => {
|
||||
describe('ToolSearch tool_reference content ()', () => {
|
||||
// CC CLI serializes ToolSearch results as `tool_reference` blocks — no
|
||||
// `text` or `content` field — which the generic array mapper dropped to
|
||||
// empty content, leaving the tool message in DB with `content: ''` and
|
||||
@@ -458,7 +458,7 @@ describe('ClaudeCodeAdapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Read tool image content (LOBE-7338)', () => {
|
||||
describe('Read tool image content ()', () => {
|
||||
// CC's `Read` on images returns a `tool_result` whose `content` is an
|
||||
// `image` block (base64). The generic mapper had no branch for it so
|
||||
// resultContent collapsed to '' and the UI's StatusIndicator stuck on the
|
||||
@@ -2242,9 +2242,9 @@ describe('ClaudeCodeAdapter', () => {
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
// LOBE-8998: external signal detection (Monitor task callbacks)
|
||||
// external signal detection (Monitor task callbacks)
|
||||
// ────────────────────────────────────────────────────
|
||||
describe('external signal detection (LOBE-8998)', () => {
|
||||
describe('external signal detection ()', () => {
|
||||
const init = (adapter: ClaudeCodeAdapter) => {
|
||||
adapter.adapt({
|
||||
model: 'claude-sonnet-4-6',
|
||||
|
||||
@@ -578,7 +578,7 @@ export class ClaudeCodeAdapter implements AgentEventAdapter {
|
||||
// ─── Private handlers ───
|
||||
|
||||
private handleSystem(raw: any): HeterogeneousAgentEvent[] {
|
||||
// CC's long-running task lifecycle (Monitor, etc., LOBE-8998).
|
||||
// CC's long-running task lifecycle (Monitor, etc., ).
|
||||
// `task_started` registers a task that may fire callback turns;
|
||||
// `task_notification` (terminal) drops it. While a task is alive,
|
||||
// any new turn without preceding user input is treated as a signal
|
||||
@@ -976,11 +976,11 @@ export class ClaudeCodeAdapter implements AgentEventAdapter {
|
||||
// blocks — no `text` / `content` field. Without this branch the
|
||||
// mapper returns '' for every reference, filter drops them all,
|
||||
// and the tool message lands in DB with empty content — leaving
|
||||
// the UI's StatusIndicator stuck on the spinner (LOBE-7369).
|
||||
// the UI's StatusIndicator stuck on the spinner ().
|
||||
if (c?.type === 'tool_reference' && c.tool_name) return c.tool_name;
|
||||
// `Read` on images yields `{type: 'image', source: {...}}` blocks
|
||||
// with no text. Drop a minimal placeholder so the tool message
|
||||
// has non-empty content (LOBE-7338); richer image echo is a
|
||||
// has non-empty content (); richer image echo is a
|
||||
// follow-up that needs structured ToolResultData.
|
||||
if (c?.type === 'image') {
|
||||
const mediaType = c.source?.media_type || 'image';
|
||||
@@ -1290,7 +1290,7 @@ export class ClaudeCodeAdapter implements AgentEventAdapter {
|
||||
|
||||
this.currentMessageId = messageId;
|
||||
this.stepIndex++;
|
||||
// Signal-callback detection (LOBE-8998): if this turn opened
|
||||
// Signal-callback detection (): if this turn opened
|
||||
// WITHOUT a preceding `user` event AND a long-running task is
|
||||
// still active, the LLM was re-invoked by the task pushing an
|
||||
// update — tag the resulting assistant turn accordingly. Otherwise
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Producer-side MCP server + per-op bridge for Claude Code's AskUserQuestion
|
||||
* via local HTTP MCP. See `LOBE-8725` for the full design.
|
||||
* via local HTTP MCP. See `` for the full design.
|
||||
*
|
||||
* Used by:
|
||||
* - Electron main (`HeterogeneousAgentCtr`) — local app
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Producer-side helpers for converting external agent CLI output into the
|
||||
* unified `AgentStreamEvent` wire shape. Imported by:
|
||||
* - Electron main (`HeterogeneousAgentCtr`) — desktop CC / Codex flow
|
||||
* - The future `lh hetero exec` CLI — sandbox + terminal flow (LOBE-8516)
|
||||
* - The future `lh hetero exec` CLI — sandbox + terminal flow ()
|
||||
*
|
||||
* Consumers (renderer executor, server `heteroIngest` handler) never need to
|
||||
* touch adapters — every event reaching them is already an `AgentStreamEvent`.
|
||||
|
||||
@@ -53,7 +53,7 @@ export interface StreamStartData {
|
||||
* `metadata.signal` so MessageCollector can collect signal-tagged
|
||||
* toolless assistants into a SignalCallbacksNode.
|
||||
*
|
||||
* Phase 2 (LOBE-8999) promotes the persisted shape to a dedicated
|
||||
* Phase 2 () promotes the persisted shape to a dedicated
|
||||
* `messages.signal` column; the event peer field name stays
|
||||
* `externalSignal` regardless.
|
||||
*/
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('file operations', () => {
|
||||
|
||||
it('should truncate single very long lines to per-line cap', async () => {
|
||||
const filePath = path.join(tmpDir, 'long-line.txt');
|
||||
// 27KB single line of base64-like text — the LOBE-8703 scenario.
|
||||
// 27KB single line of base64-like text — the scenario.
|
||||
await writeFile(filePath, 'A'.repeat(27_000));
|
||||
|
||||
const result = await readLocalFile({ path: filePath });
|
||||
|
||||
@@ -283,7 +283,7 @@ describe('anthropicHelpers', () => {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search',
|
||||
// LOBE-7761 Qwen shape — upstream sanitize should catch this, but
|
||||
// Qwen shape — upstream sanitize should catch this, but
|
||||
// if it doesn't we want noise in the logs rather than a silent drop.
|
||||
arguments: '{, "query": "anthropic"}',
|
||||
},
|
||||
@@ -342,7 +342,7 @@ describe('anthropicHelpers', () => {
|
||||
});
|
||||
|
||||
it('recovers tool_call input from element[0] when arguments parse to a multi-element array', async () => {
|
||||
// LOBE-8201 — model emitted long writeLocalFile args containing many
|
||||
// — model emitted long writeLocalFile args containing many
|
||||
// unescaped quotes, which JSON.parse re-segmented into a top-level array.
|
||||
// element[0] usually still carries the first legit key (e.g. `content`),
|
||||
// so prefer partial recovery over total loss.
|
||||
|
||||
@@ -289,7 +289,7 @@ describe('google contextBuilders', () => {
|
||||
});
|
||||
|
||||
it('recovers functionCall.args from element[0] when arguments parse to an array', async () => {
|
||||
// LOBE-8201 — same defense as Anthropic: prefer partial recovery from
|
||||
// — same defense as Anthropic: prefer partial recovery from
|
||||
// element[0] over total loss when malformed JSON parses to an array.
|
||||
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const message = {
|
||||
|
||||
@@ -252,7 +252,7 @@ export const buildGoogleMessages = async (messages: OpenAIChatMessage[]): Promis
|
||||
// This handles cross-provider scenarios (e.g., OpenAI → Gemini switch) where
|
||||
// historical tool_calls lack thoughtSignature, as well as multi-turn Gemini
|
||||
// conversations where earlier turns may have lost their signatures.
|
||||
// @see https://linear.app/lobehub/issue/LOBE-8662
|
||||
// @see https://linear.app/lobehub/issue/
|
||||
for (const content of filteredContents) {
|
||||
if (content.role === 'model' && content.parts) {
|
||||
for (const part of content.parts) {
|
||||
@@ -275,7 +275,7 @@ export const buildGoogleMessages = async (messages: OpenAIChatMessage[]): Promis
|
||||
* schema may place `enum` on non-STRING types (e.g. number, boolean)
|
||||
* or `required` on non-OBJECT types.
|
||||
*
|
||||
* @see https://linear.app/lobehub/issue/LOBE-8661
|
||||
* @see https://linear.app/lobehub/issue/
|
||||
*/
|
||||
export const sanitizeGeminiSchema = (schema: any): any => {
|
||||
if (!schema || typeof schema !== 'object') return schema;
|
||||
|
||||
@@ -151,7 +151,7 @@ export interface OpenAICompatibleFactoryOptions<T extends Record<string, any> =
|
||||
* provided model list before dispatching to upstream. If the estimated
|
||||
* prompt tokens strictly exceed the model's context window, the
|
||||
* request is aborted with a structured `ExceededContextWindow` error
|
||||
* — see LOBE-8974.
|
||||
* — see .
|
||||
*
|
||||
* This is for providers like NVIDIA / DeepSeek where the harness does
|
||||
* not cap `max_tokens` itself but we still want to fail fast on doomed
|
||||
@@ -485,7 +485,7 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
|
||||
|
||||
// Pre-flight: abort doomed requests before invoking handlePayload so
|
||||
// providers don't waste a round-trip to upstream just to get a 400.
|
||||
// See LOBE-8974.
|
||||
// See .
|
||||
if (chatCompletion?.contextPreFlight) {
|
||||
const { models: preFlightModels, ...preFlightOptions } = chatCompletion.contextPreFlight;
|
||||
assertContextWithinWindow(processedPayload, preFlightModels, preFlightOptions);
|
||||
@@ -1058,7 +1058,7 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
|
||||
|
||||
// Pre-flight context-window failures get a structured payload so the
|
||||
// UI can offer fork / switch-model affordances instead of surfacing a
|
||||
// raw provider 400. See LOBE-8974.
|
||||
// raw provider 400. See .
|
||||
if (error instanceof ContextExceededPreFlightError) {
|
||||
log('pre-flight context exceeded: %s', error.message);
|
||||
return AgentRuntimeError.chat({
|
||||
|
||||
@@ -163,7 +163,7 @@ describe('parseToolCalls', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// LOBE-8199: NVIDIA NIM (z-ai/glm5, qwen3.5-MoE) and some proxies open a
|
||||
// NVIDIA NIM (z-ai/glm5, qwen3.5-MoE) and some proxies open a
|
||||
// tool_call with function.name=null as a start marker; the real name
|
||||
// arrives in a subsequent delta.
|
||||
it('should coerce null function.name on the first delta and patch it in from a later delta', () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { MessageToolCallSchema } from '../types';
|
||||
// carried by a subsequent delta. Passing `null` through the strict
|
||||
// MessageToolCallSchema throws ZodError mid-stream and kills the entire
|
||||
// operation. Coerce to '' so parsing succeeds; the merge logic below patches
|
||||
// the name in once a later delta supplies it. See LOBE-8199.
|
||||
// the name in once a later delta supplies it. See .
|
||||
const normalizeChunkForParse = <T extends Omit<MessageToolCallChunk, 'index'>>(chunk: T): T => {
|
||||
if (chunk.function && chunk.function.name == null) {
|
||||
return { ...chunk, function: { ...chunk.function, name: '' } };
|
||||
|
||||
@@ -56,7 +56,7 @@ export const openAIParams = {
|
||||
chatCompletion: {
|
||||
// DeepSeek upstream rejects requests where input alone exceeds the
|
||||
// model context window with a 400 carrying `max_completion=0` in the
|
||||
// message. Fail fast before round-tripping. See LOBE-8974.
|
||||
// message. Fail fast before round-tripping. See .
|
||||
contextPreFlight: { models: deepseekChatModels },
|
||||
handlePayload: buildDeepSeekOpenAIPayload,
|
||||
},
|
||||
|
||||
@@ -511,7 +511,7 @@ describe('createGoogleImage', () => {
|
||||
});
|
||||
|
||||
// Regression: nano banana 4K selection used to be silently dropped because
|
||||
// imageSize was gated on aspectRatio !== 'auto'. See LOBE-9115.
|
||||
// imageSize was gated on aspectRatio !== 'auto'. See .
|
||||
it('should pass imageSize when resolution is set even if aspectRatio is auto', async () => {
|
||||
// Arrange
|
||||
const realBase64ImageData =
|
||||
|
||||
@@ -180,7 +180,7 @@ describe('Google generateObject', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// LOBE-8661: enum should only be copied for STRING type properties
|
||||
// enum should only be copied for STRING type properties
|
||||
it('should strip enum from non-STRING types', () => {
|
||||
const openAISchema = {
|
||||
name: 'test',
|
||||
@@ -207,7 +207,7 @@ describe('Google generateObject', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// LOBE-8661: enum with empty array should be stripped even for STRING type
|
||||
// enum with empty array should be stripped even for STRING type
|
||||
it('should strip empty enum arrays', () => {
|
||||
const openAISchema = {
|
||||
name: 'test',
|
||||
@@ -229,7 +229,7 @@ describe('Google generateObject', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// LOBE-8661: required should only be copied for OBJECT types
|
||||
// required should only be copied for OBJECT types
|
||||
it('should strip required from non-OBJECT types', () => {
|
||||
const openAISchema = {
|
||||
name: 'test',
|
||||
@@ -400,7 +400,7 @@ describe('Google generateObject', () => {
|
||||
expect(sanitizeGeminiSchema(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
// LOBE-8661: nullable string enums should be preserved
|
||||
// nullable string enums should be preserved
|
||||
it('should preserve enum on nullable STRING types (type: array with string)', () => {
|
||||
const schema = {
|
||||
properties: {
|
||||
@@ -425,7 +425,7 @@ describe('Google generateObject', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// LOBE-8661: nullable object required should be preserved
|
||||
// nullable object required should be preserved
|
||||
it('should preserve required on nullable OBJECT types (type: array with object)', () => {
|
||||
const schema = {
|
||||
properties: {
|
||||
@@ -452,7 +452,7 @@ describe('Google generateObject', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// LOBE-8661: should strip enum from nullable non-STRING types
|
||||
// should strip enum from nullable non-STRING types
|
||||
it('should strip enum from nullable non-STRING types (type: array without string)', () => {
|
||||
const schema = {
|
||||
properties: {
|
||||
@@ -476,7 +476,7 @@ describe('Google generateObject', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// LOBE-8661: recurse into definitions/$defs
|
||||
// recurse into definitions/$defs
|
||||
it('should sanitize schemas under definitions', () => {
|
||||
const schema = {
|
||||
definitions: {
|
||||
@@ -505,7 +505,7 @@ describe('Google generateObject', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// LOBE-8661: recurse into $defs
|
||||
// recurse into $defs
|
||||
it('should sanitize schemas under $defs', () => {
|
||||
const schema = {
|
||||
$defs: {
|
||||
@@ -1315,7 +1315,7 @@ describe('Google generateObject', () => {
|
||||
expect(result).toEqual([{ arguments: {}, name: 'simple_function' }]);
|
||||
});
|
||||
|
||||
// LOBE-8661: buildGoogleTool should sanitize schema to strip enum from non-STRING types
|
||||
// buildGoogleTool should sanitize schema to strip enum from non-STRING types
|
||||
it('should sanitize enum from non-STRING types in tool parameters', () => {
|
||||
const tool: any = {
|
||||
function: {
|
||||
@@ -1350,7 +1350,7 @@ describe('Google generateObject', () => {
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
// LOBE-8661: buildGoogleTool should sanitize nested tool parameters
|
||||
// buildGoogleTool should sanitize nested tool parameters
|
||||
it('should sanitize nested enum/required in tool parameters', () => {
|
||||
const tool: any = {
|
||||
function: {
|
||||
@@ -1396,7 +1396,7 @@ describe('Google generateObject', () => {
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
// LOBE-8661: buildGoogleTool should preserve nullable string enum
|
||||
// buildGoogleTool should preserve nullable string enum
|
||||
it('should preserve enum on nullable STRING type in tool parameters', () => {
|
||||
const tool: any = {
|
||||
function: {
|
||||
|
||||
@@ -82,7 +82,7 @@ const convertSchema = (schema: any): any => {
|
||||
|
||||
// Only include enum if type is STRING and enum is non-empty.
|
||||
// Gemini proto: enum is only allowed for STRING type.
|
||||
// @see https://linear.app/lobehub/issue/LOBE-8661
|
||||
// @see https://linear.app/lobehub/issue/
|
||||
if (schema.enum && schema.enum.length > 0 && isStringType(schema.type)) {
|
||||
converted.enum = schema.enum;
|
||||
}
|
||||
@@ -100,7 +100,7 @@ const convertSchema = (schema: any): any => {
|
||||
|
||||
// Only include required if type is OBJECT and required is non-empty.
|
||||
// Gemini proto: required is only allowed for OBJECT type.
|
||||
// @see https://linear.app/lobehub/issue/LOBE-8661
|
||||
// @see https://linear.app/lobehub/issue/
|
||||
if (schema.required && schema.required.length > 0 && isObjectType(schema.type)) {
|
||||
converted.required = schema.required;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('LobeMinimaxAI - handlePayload', () => {
|
||||
});
|
||||
|
||||
it('caps max_tokens when input + tools fill most of the context window', () => {
|
||||
// Mimic the LOBE-7017 scenario: many large tool definitions.
|
||||
// Mimic the scenario: many large tool definitions.
|
||||
// MiniMax-M2.7: contextWindow=204_800, maxOutput=131_072. Need >72k tokens
|
||||
// of input to push the dynamic cap below maxOutput.
|
||||
const heavyTool = {
|
||||
|
||||
@@ -23,7 +23,7 @@ export const params = {
|
||||
// exceed the model context window (returns 400 "requested 0 output
|
||||
// tokens and your prompt contains at least N+1 input tokens"). Fail
|
||||
// fast so the UI can surface a fork / switch-model affordance instead
|
||||
// of a raw provider error. See LOBE-8974.
|
||||
// of a raw provider error. See .
|
||||
contextPreFlight: { models: nvidiaChatModels },
|
||||
handlePayload: (payload) => {
|
||||
const { model, thinking, messages, ...rest } = payload;
|
||||
|
||||
@@ -209,7 +209,7 @@ describe('assertContextWithinWindow', () => {
|
||||
).toThrow(ContextExceededPreFlightError);
|
||||
});
|
||||
|
||||
it('attaches LOBE-8974 structured payload via toPayload()', () => {
|
||||
it('attaches structured payload via toPayload', () => {
|
||||
const longContent = 'a'.repeat(20_000);
|
||||
try {
|
||||
assertContextWithinWindow(
|
||||
@@ -233,7 +233,7 @@ describe('assertContextWithinWindow', () => {
|
||||
});
|
||||
|
||||
it('does NOT reject a near-limit prompt that still fits within the window', () => {
|
||||
// Regression test for LOBE-8974 PR review feedback: the helper was
|
||||
// Regression test for PR review feedback: the helper was
|
||||
// previously deducting a 1024 buffer + 1024 minOutputTokens and would
|
||||
// throw for a 198.5k-token prompt against a 200k-token window even
|
||||
// though the upstream would accept it. With the corrected threshold,
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface ResolveSafeMaxTokensOptions {
|
||||
* `minOutputTokens` for completion (or already exceed the model's context
|
||||
* window). Caught by `openaiCompatibleFactory` and surfaced as an
|
||||
* `ExceededContextWindow` chat error carrying structured diagnostic fields
|
||||
* — see LOBE-8974 for the rationale of failing fast instead of issuing a
|
||||
* — see for the rationale of failing fast instead of issuing a
|
||||
* doomed upstream request.
|
||||
*/
|
||||
export class ContextExceededPreFlightError extends Error {
|
||||
@@ -161,7 +161,7 @@ export interface AssertContextWithinWindowOptions {
|
||||
* completion — the upstream will pick its own `max_tokens` default once
|
||||
* the request is dispatched. Rejecting near-limit-but-fitting prompts
|
||||
* (e.g. 198.5k tokens against a 200k window) would block valid requests
|
||||
* that the upstream would happily serve. See LOBE-8974 review feedback.
|
||||
* that the upstream would happily serve. See review feedback.
|
||||
*/
|
||||
export const assertContextWithinWindow = (
|
||||
payload: Pick<ChatStreamPayload, 'messages' | 'model' | 'tools'>,
|
||||
|
||||
@@ -180,7 +180,7 @@ export const MessageMetadataSchema = ModelUsageSchema.merge(ModelPerformanceSche
|
||||
performance: ModelPerformanceSchema.optional(),
|
||||
reactions: z.array(EmojiReactionSchema).optional(),
|
||||
scope: z.string().optional(),
|
||||
// External-signal lineage for Monitor-style callback turns (LOBE-8998).
|
||||
// External-signal lineage for Monitor-style callback turns ().
|
||||
signal: MessageSignalSchema.optional(),
|
||||
subAgentId: z.string().optional(),
|
||||
toolExecutionTimeMs: z.number().optional(),
|
||||
@@ -323,7 +323,7 @@ export interface MessageMetadata {
|
||||
* External-signal lineage for messages produced as reactive replies
|
||||
* to an out-of-band trigger (Monitor stdout push, webhook callback,
|
||||
* scheduled tick, …) rather than a fresh user turn. Phase-1 storage —
|
||||
* Phase 2 (LOBE-8999) promotes this to a dedicated `messages.signal`
|
||||
* Phase 2 () promotes this to a dedicated `messages.signal`
|
||||
* jsonb column.
|
||||
*
|
||||
* Conversation-flow groups signal-tagged TOOLLESS assistants into a
|
||||
@@ -372,7 +372,7 @@ export interface MessageMetadata {
|
||||
* Persisted form of an external-signal trigger context — stamped on
|
||||
* messages produced as reactive replies to out-of-band events.
|
||||
*
|
||||
* Phase 1 lives under `MessageMetadata.signal`; Phase 2 (LOBE-8999)
|
||||
* Phase 1 lives under `MessageMetadata.signal`; Phase 2 ()
|
||||
* promotes to a dedicated `messages.signal` column with the same
|
||||
* shape (plus `rootSourceId` / `scopeKey` for agent-signal alignment).
|
||||
*/
|
||||
|
||||
@@ -253,7 +253,7 @@ export interface UIChatMessage {
|
||||
search?: GroundingSearch | null;
|
||||
sessionId?: string;
|
||||
/**
|
||||
* External-signal callback blocks (LOBE-8998). Set on virtual
|
||||
* External-signal callback blocks (). Set on virtual
|
||||
* assistantGroup messages built by FlatListBuilder when the chain
|
||||
* contains toolless assistants triggered by repeated tool_results
|
||||
* (Monitor stdout push pattern). Rendered as `<SignalCallbacks>`
|
||||
@@ -268,7 +268,7 @@ export interface UIChatMessage {
|
||||
*/
|
||||
targetId?: string | null;
|
||||
/**
|
||||
* Post-task summary blocks (LOBE-8998). Set on virtual assistantGroup
|
||||
* Post-task summary blocks (). Set on virtual assistantGroup
|
||||
* messages by FlatListBuilder when the chain contains toolless
|
||||
* assistants tagged with `signal.type === 'task-completion'` — the
|
||||
* final-summary turn the LLM emits after CC delivers
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('sanitizeToolCallArguments', () => {
|
||||
expect(sanitizeToolCallArguments(undefined)).toBe('{}');
|
||||
});
|
||||
|
||||
it('falls back to "{}" on LOBE-7761 shape "{, ..."', () => {
|
||||
it('falls back to "{}" on shape "{, .."', () => {
|
||||
// exact shape from the reported NVIDIA/Qwen trace
|
||||
const input = '{, "description": "Create data models", "language": "python"}';
|
||||
expect(sanitizeToolCallArguments(input)).toBe('{}');
|
||||
|
||||
@@ -7,7 +7,7 @@ import { safeParseJSON, safeParsePartialJSON } from './safeParseJSON';
|
||||
* Strict providers (e.g. NVIDIA NIM) validate the full message history on
|
||||
* every request. A single malformed `arguments` string — even one produced
|
||||
* many turns ago — causes a 400 on the entire request, terminating the op
|
||||
* and wasting all accumulated tokens. See LOBE-7761.
|
||||
* and wasting all accumulated tokens. See .
|
||||
*
|
||||
* Behavior:
|
||||
* - Valid JSON → returned as-is (preserves prompt-cache keys).
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('htmlToMarkdown', () => {
|
||||
expect(result.content.length).toBeLessThan(html.length);
|
||||
}, 20000);
|
||||
|
||||
it('should not crash on HTML with invalid CSS selectors (LOBE-6869)', () => {
|
||||
it('should not crash on HTML with invalid CSS selectors ()', () => {
|
||||
// Regression: happy-dom throws TypeError on pages with CSS selectors it cannot parse.
|
||||
// htmlToMarkdown must not propagate this — it should fall back to raw HTML conversion.
|
||||
const html = `
|
||||
@@ -67,7 +67,7 @@ describe('htmlToMarkdown', () => {
|
||||
expect(result.content).toContain('Valid content');
|
||||
});
|
||||
|
||||
it('should not crash on HTML with external stylesheet links (LOBE-6869)', () => {
|
||||
it('should not crash on HTML with external stylesheet links ()', () => {
|
||||
// Regression: happy-dom's HTMLLinkElement.#loadStyleSheet can crash on CSS parsing.
|
||||
// disableCSSFileLoading should prevent this path entirely.
|
||||
const html = `
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* - useSearchParams -> @/app/[variants]/(main)/hooks/useSearchParams
|
||||
* - useRouter -> @/app/[variants]/(main)/hooks/useRouter
|
||||
*
|
||||
* @see RFC 147: LOBE-2850 - Phase 3
|
||||
* @see RFC 147: - Phase 3
|
||||
*/
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
@@ -181,7 +181,7 @@ const GroupMessage = memo<GroupMessageProps>(
|
||||
the same "agent reply" block. The ChatItem body gap (16px) would
|
||||
otherwise stretch them apart and the natural narrative — initial
|
||||
reply → callbacks → summary — reads as three disconnected
|
||||
sections (LOBE-8998).
|
||||
sections ().
|
||||
*/}
|
||||
<Flexbox gap={4}>
|
||||
{children && children.length > 0 && (
|
||||
|
||||
@@ -162,7 +162,7 @@ const findBlockById = (
|
||||
}
|
||||
// Post-task summary blocks live in a separate field on virtual
|
||||
// assistantGroup messages so they render AFTER `<SignalCallbacks>`
|
||||
// (LOBE-8998). Same lookup contract as `children` — the renderer
|
||||
// (). Same lookup contract as `children` — the renderer
|
||||
// identifies blocks by id regardless of which slot they came from.
|
||||
if ((message as { taskCompletions?: AssistantContentBlock[] }).taskCompletions) {
|
||||
const block = (
|
||||
|
||||
@@ -485,7 +485,7 @@ describe('Generation Actions', () => {
|
||||
expect(mockCompleteOperation).toHaveBeenCalledWith('test-op-id');
|
||||
});
|
||||
|
||||
it('should delete message BEFORE regeneration to prevent message not found issue (LOBE-2533)', async () => {
|
||||
it('should delete message BEFORE regeneration to prevent message not found issue ()', async () => {
|
||||
// This test verifies the fix:
|
||||
// When "delete and regenerate" is called, if regeneration happens first,
|
||||
// it switches to a new branch, causing the original message to no longer
|
||||
|
||||
@@ -71,7 +71,7 @@ export default defineFixtures({
|
||||
content: 'Issue thread describing the /devtools route rollout.',
|
||||
engines: ['bing'],
|
||||
title: 'Builtin render devtools issue',
|
||||
url: 'https://linear.example.com/issue/LOBE-8114',
|
||||
url: 'https://linear.example.com/issue/',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('UserUpdater', () => {
|
||||
rerender(<UserUpdater />);
|
||||
|
||||
// Regression: interests / firstName / latestName must NOT be wiped by the
|
||||
// session sync. (LOBE-8597 — wiped interests caused the home daily-brief
|
||||
// session sync. (— wiped interests caused the home daily-brief
|
||||
// recommendation SWR key to reset and refetch with empty interestKeys.)
|
||||
expect(useUserStore.getState().user?.interests).toEqual(['内容创作', '编程']);
|
||||
expect(useUserStore.getState().user?.firstName).toBe('A');
|
||||
|
||||
@@ -29,7 +29,7 @@ const UserUpdater = memo(() => {
|
||||
// than replace it — fields like `interests`, `firstName`, `latestName` are
|
||||
// populated by `useInitUserState` (one-shot SWR) and would otherwise be
|
||||
// wiped on every focus, breaking downstream selectors (e.g. the daily-brief
|
||||
// recommendation SWR key resets to empty interests and refetches). LOBE-8597.
|
||||
// recommendation SWR key resets to empty interests and refetches). .
|
||||
//
|
||||
// Guard the merge by user id: if the session switches to a different
|
||||
// account (e.g. another tab signed in as a different user, focus refetch
|
||||
|
||||
@@ -73,7 +73,7 @@ const styles = createStaticStyles(({ css }) => ({
|
||||
|
||||
// Module-scoped so a click on any topic cancels a pending click on another.
|
||||
// Per-item refs can't do that, which lets rapid clicks across items all
|
||||
// fire — each racing to write activeTopicId (see LOBE-7785).
|
||||
// fire — each racing to write activeTopicId (see ).
|
||||
let pendingSingleClickTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cancelPendingSingleClick = () => {
|
||||
|
||||
@@ -59,7 +59,7 @@ const styles = createStaticStyles(({ css }) => ({
|
||||
|
||||
// Module-scoped so a click on any topic cancels a pending click on another.
|
||||
// Per-item refs can't do that, which lets rapid clicks across items all
|
||||
// fire — each racing to write activeTopicId (see LOBE-7785).
|
||||
// fire — each racing to write activeTopicId (see ).
|
||||
let pendingSingleClickTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cancelPendingSingleClick = () => {
|
||||
|
||||
@@ -17,9 +17,9 @@ describe('stripMarkdownLinks', () => {
|
||||
it('replaces multiple markdown links in one string', () => {
|
||||
expect(
|
||||
stripMarkdownLinks(
|
||||
'帮我整理 [2.1 改版](/agent/inbox/tpc_a) 和 [LOBE-8516](/task/T-1) 的下一步...',
|
||||
'帮我整理 [2.1 改版](/agent/inbox/tpc_a) 和 [发布计划](/task/T-1) 的下一步...',
|
||||
),
|
||||
).toBe('帮我整理 2.1 改版 和 LOBE-8516 的下一步...');
|
||||
).toBe('帮我整理 2.1 改版 和 发布计划 的下一步...');
|
||||
});
|
||||
|
||||
it('preserves the trailing ellipsis used as a typing indicator', () => {
|
||||
|
||||
@@ -57,7 +57,7 @@ interface AutoLinkPattern {
|
||||
}
|
||||
|
||||
// Bare references the model might emit without the markdown link form.
|
||||
// Used as a fallback so e.g. plain "LOBE-8516" inside the welcome still
|
||||
// Used as a fallback so e.g. plain "" inside the welcome still
|
||||
// becomes clickable.
|
||||
const AUTO_LINK_PATTERNS: AutoLinkPattern[] = [
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ const log = debug('lobe-server:agent:finalize-abandoned');
|
||||
/**
|
||||
* Reverse-trigger finalization for an operation whose Vercel function was
|
||||
* killed mid-flight. Called by the agent-gateway DO inactivity watchdog when
|
||||
* an op has gone silent past the threshold — see LOBE-8533.
|
||||
* an op has gone silent past the threshold — see .
|
||||
*
|
||||
* Body: `{ operationId: string, reason: string }`
|
||||
*
|
||||
|
||||
@@ -364,7 +364,7 @@ export const createRuntimeExecutors = (
|
||||
// Get parentId from payload (parentId or parentMessageId depending on payload type)
|
||||
const parentId = llmPayload.parentId || (llmPayload as any).parentMessageId;
|
||||
|
||||
// Parent existence preflight (LOBE-7158 / LOBE-7154):
|
||||
// Parent existence preflight ():
|
||||
// If the parent was deleted concurrently (e.g. user deleted topic mid-run),
|
||||
// assistant message creation below would hit a PG FK violation AFTER we've
|
||||
// already done the LLM call and spent tokens. Check first — fail fast,
|
||||
@@ -924,7 +924,7 @@ export const createRuntimeExecutors = (
|
||||
// self-reflection signal the model needs to fix its own output.
|
||||
// Sanitization happens later, only at the persist boundaries
|
||||
// (DB write and state.messages push) to protect strict providers
|
||||
// replaying history. See LOBE-7761.
|
||||
// replaying history. See .
|
||||
const payload = resolvedCalls.map((p) => ({
|
||||
...p,
|
||||
executor: resolved.executorMap?.[p.identifier],
|
||||
@@ -1062,7 +1062,7 @@ export const createRuntimeExecutors = (
|
||||
|
||||
// Sanitize tool_call `arguments` before persisting to DB so malformed
|
||||
// JSON (e.g. Qwen emitting `{, ...}`) can't poison future context
|
||||
// builds and 400 strict providers like NVIDIA NIM. See LOBE-7761.
|
||||
// builds and 400 strict providers like NVIDIA NIM. See .
|
||||
const persistedTools =
|
||||
toolsCalling.length > 0
|
||||
? toolsCalling.map((t) => ({
|
||||
@@ -1790,7 +1790,7 @@ export const createRuntimeExecutors = (
|
||||
// Finally persist to database. In resumption mode (skipCreateToolMessage),
|
||||
// the pending tool message already exists from request_human_approve, so
|
||||
// we update it in-place rather than inserting a new row — inserting would
|
||||
// either duplicate the tool_call_id or violate parent_id FK (LOBE-7154).
|
||||
// either duplicate the tool_call_id or violate parent_id FK ().
|
||||
let toolMessageId: string | undefined;
|
||||
try {
|
||||
if (payload.skipCreateToolMessage) {
|
||||
@@ -1954,7 +1954,7 @@ export const createRuntimeExecutors = (
|
||||
} catch (error) {
|
||||
// Persist-level failures (parent FK violation etc.) must propagate so
|
||||
// the step fails — otherwise the swallow-and-continue path keeps
|
||||
// running the agent on a broken conversation chain. See LOBE-7158.
|
||||
// running the agent on a broken conversation chain. See .
|
||||
if (isPersistFatal(error)) throw error;
|
||||
|
||||
if (ctx.hookDispatcher) {
|
||||
@@ -2294,7 +2294,7 @@ export const createRuntimeExecutors = (
|
||||
// Normalize BEFORE publishing — clients treat `error` stream
|
||||
// events as terminal and surface `event.data.error` directly, so
|
||||
// a raw SQL error here would leak driver text to the user before
|
||||
// the ConversationParentMissing throw is consumed. See LOBE-7158.
|
||||
// the ConversationParentMissing throw is consumed. See .
|
||||
const fatal = isParentMessageMissingError(error)
|
||||
? createConversationParentMissingError(parentMessageId, error)
|
||||
: error instanceof Error
|
||||
@@ -2900,7 +2900,7 @@ export const createRuntimeExecutors = (
|
||||
// newState.messages. When the approval resumes, the `call_tool`
|
||||
// executor (skip-create branch) appends the resolved tool message
|
||||
// to state.messages itself. Pushing a placeholder here produced
|
||||
// two entries for the same tool_call_id — see LOBE-7151 review P2.
|
||||
// two entries for the same tool_call_id — see review P2.
|
||||
|
||||
log(
|
||||
'[%s:%d] Created pending tool message %s for %s',
|
||||
@@ -3017,7 +3017,7 @@ export const createRuntimeExecutors = (
|
||||
error,
|
||||
);
|
||||
// Normalize BEFORE publishing so clients surface the typed business
|
||||
// error instead of the raw driver text (see LOBE-7158 review).
|
||||
// error instead of the raw driver text (see review).
|
||||
const fatal = isParentMessageMissingError(error)
|
||||
? createConversationParentMissingError(parentMessageId, error)
|
||||
: error instanceof Error
|
||||
|
||||
@@ -250,7 +250,7 @@ describe('RuntimeExecutors', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ConversationParentMissing if parent preflight misses (LOBE-7158)', async () => {
|
||||
it('should throw ConversationParentMissing if parent preflight misses ()', async () => {
|
||||
// parent existence preflight — if the parent row was deleted between
|
||||
// operation kickoff and call_llm, fail fast before spending LLM tokens
|
||||
// on a chain that would hit a FK violation anyway.
|
||||
@@ -1521,8 +1521,8 @@ describe('RuntimeExecutors', () => {
|
||||
expect(result.nextContext!.phase).toBe('tool_result');
|
||||
});
|
||||
|
||||
it('should re-throw when messageModel.create fails (LOBE-7158: no silent swallow)', async () => {
|
||||
// Before LOBE-7158 we silently swallowed this error and returned
|
||||
it('should re-throw when messageModel.create fails (no silent swallow)', async () => {
|
||||
// Before we silently swallowed this error and returned
|
||||
// `parentMessageId: undefined`, which let the operation continue into
|
||||
// the next step and re-hit the same failure without context. The fix
|
||||
// requires the executor to propagate so the whole step fails.
|
||||
@@ -1548,7 +1548,7 @@ describe('RuntimeExecutors', () => {
|
||||
await expect(executors.call_tool!(instruction, state)).rejects.toThrow('Database error');
|
||||
});
|
||||
|
||||
it('should throw ConversationParentMissing on a parent_id FK violation (LOBE-7158)', async () => {
|
||||
it('should throw ConversationParentMissing on a parent_id FK violation ()', async () => {
|
||||
// Simulate the drizzle + postgres-js wrapped error shape.
|
||||
const fkError: any = new Error(
|
||||
'Failed query: insert into "messages" ... violates foreign key constraint',
|
||||
@@ -2330,8 +2330,8 @@ describe('RuntimeExecutors', () => {
|
||||
expect(result.nextContext!.phase).toBe('tools_batch_result');
|
||||
});
|
||||
|
||||
it('should propagate persist failures instead of silently falling back (LOBE-7158)', async () => {
|
||||
// Before LOBE-7158 we fell back to the original parentMessageId here,
|
||||
it('should propagate persist failures instead of silently falling back ()', async () => {
|
||||
// Before we fell back to the original parentMessageId here,
|
||||
// which was itself the deleted parent that caused the failure — so the
|
||||
// next step would hit the same FK violation with no context. The fix
|
||||
// requires the batch to short-circuit on persist failure.
|
||||
@@ -2361,7 +2361,7 @@ describe('RuntimeExecutors', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ConversationParentMissing on a parent_id FK violation (LOBE-7158)', async () => {
|
||||
it('should throw ConversationParentMissing on a parent_id FK violation ()', async () => {
|
||||
const fkError: any = new Error(
|
||||
'Failed query: insert into "messages" ... violates foreign key constraint',
|
||||
);
|
||||
@@ -2447,8 +2447,8 @@ describe('RuntimeExecutors', () => {
|
||||
expect(result.nextContext!.phase).toBe('tools_batch_result');
|
||||
});
|
||||
|
||||
it('should fail the batch if tool message creation fails for any tool (LOBE-7158)', async () => {
|
||||
// Before LOBE-7158 we swallowed per-tool persist failures and kept
|
||||
it('should fail the batch if tool message creation fails for any tool ()', async () => {
|
||||
// Before we swallowed per-tool persist failures and kept
|
||||
// going. The fix requires the batch to abort — a FK violation on one
|
||||
// tool means every concurrent tool has the same doomed parent.
|
||||
mockMessageModel.create
|
||||
@@ -2614,7 +2614,7 @@ describe('RuntimeExecutors', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// LOBE-5143: After DB refresh, state.messages stores raw UIChatMessage[]
|
||||
// After DB refresh, state.messages stores raw UIChatMessage[]
|
||||
// and call_llm re-injects context via serverMessagesEngine on each invocation
|
||||
it('should store raw UIChatMessage[] from DB after refresh (context re-injected by call_llm)', async () => {
|
||||
// DB only stores raw user/assistant/tool messages, NOT MessagesEngine injections
|
||||
@@ -3229,8 +3229,8 @@ describe('RuntimeExecutors', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should propagate persist failures instead of silently swallowing (LOBE-7158)', async () => {
|
||||
// The pre-LOBE-7158 behavior logged the error and kept walking the
|
||||
it('should propagate persist failures instead of silently swallowing ()', async () => {
|
||||
// The pre-behavior logged the error and kept walking the
|
||||
// aborted-tool list. That left a half-persisted state and hid the real
|
||||
// cause from ops. Now we fail fast.
|
||||
mockMessageModel.create
|
||||
|
||||
@@ -17,7 +17,7 @@ import { formatPgError, pgErrorType, unwrapPgError } from './pgError';
|
||||
* 3. Anything else — falls back to `error.message` + `error.name`, or
|
||||
* `"Unknown error"` when the value isn't even an Error.
|
||||
*
|
||||
* See LOBE-7158 / LOBE-7334 for the motivation: Drizzle wraps driver errors
|
||||
* See for the motivation: Drizzle wraps driver errors
|
||||
* as `"Failed query: insert into ..."` and buries the real diagnostic fields
|
||||
* under `.cause`, which left the agent-gateway dashboard unable to bucket
|
||||
* DB failures by SQLSTATE.
|
||||
|
||||
@@ -26,7 +26,7 @@ export const PERSIST_FATAL_MARKER = 'persistFatal';
|
||||
* Detect whether an error returned by `messageModel.create` is a `parent_id`
|
||||
* FK violation — meaning the parent message no longer exists. Most commonly
|
||||
* caused by the parent being deleted concurrently with agent execution
|
||||
* (see LOBE-7154 / LOBE-7158).
|
||||
* (see ).
|
||||
*
|
||||
* `drizzle` + `postgres-js` wrap the raw PG error as `.cause`, so the check
|
||||
* looks at both the top level and the cause.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* (e.g. transaction runners), so walking a few layers is necessary. Without
|
||||
* unwrapping, the runtime only sees the generic `"Failed query: insert into ..."`
|
||||
* wrapper message, which strips every diagnostic field the Agent Harness
|
||||
* dashboard needs to classify the failure (see LOBE-7158 / LOBE-7334).
|
||||
* dashboard needs to classify the failure (see ).
|
||||
*
|
||||
* @see https://www.postgresql.org/docs/current/errcodes-appendix.html
|
||||
*/
|
||||
|
||||
@@ -141,7 +141,7 @@ describe('createServerToolsEngine', () => {
|
||||
expect(availablePlugins).toContain('additional-tool');
|
||||
});
|
||||
|
||||
it('drops device manifests from every source when excludeIdentifiers is set (LOBE-8768)', () => {
|
||||
it('drops device manifests from every source when excludeIdentifiers is set ()', () => {
|
||||
// Simulate a plugin + an additional manifest that claim the device
|
||||
// identifiers. The pre-merge `buildAllowedBuiltinTools` filter only
|
||||
// touches builtins; the post-merge `excludeIdentifiers` wall is what
|
||||
@@ -568,7 +568,7 @@ describe('createServerAgentToolsEngine', () => {
|
||||
});
|
||||
|
||||
it('should enable RemoteDevice in bot conversations when caller is trusted (canUseDevice=true)', () => {
|
||||
// The `!isBotConversation` clause was dropped in LOBE-8715 — the
|
||||
// The `!isBotConversation` clause was dropped in — the
|
||||
// confused-deputy concern that motivated it is now handled at a
|
||||
// stricter layer (`canUseDevice` from `resolveDeviceAccessPolicy`).
|
||||
// For owner / first-party turns the proxy is legitimately useful in
|
||||
@@ -780,7 +780,7 @@ describe('createServerAgentToolsEngine', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('canUseDevice gate (LOBE-8715 device access policy)', () => {
|
||||
describe('canUseDevice gate (device access policy)', () => {
|
||||
it('drops LocalSystem when canUseDevice is false even on a desktop caller', () => {
|
||||
// External bot sender impersonating a desktop session must not get
|
||||
// local-system back through Phase 6.4 dispatch.
|
||||
|
||||
@@ -77,14 +77,14 @@ export const createServerToolsEngine = (
|
||||
// Get builtin tool manifests from the (possibly pre-filtered) list. The
|
||||
// filter is one half of the hard wall keeping device tools out of an
|
||||
// external bot sender's manifestSchemas — see `buildAllowedBuiltinTools`
|
||||
// and LOBE-8768. The enableChecker rules below are defense-in-depth
|
||||
// and . The enableChecker rules below are defense-in-depth
|
||||
// because `allowExplicitActivation` lets activator-driven activation
|
||||
// bypass them.
|
||||
const builtinManifests = builtinToolsOverride.map((tool) => tool.manifest as LobeToolManifest);
|
||||
|
||||
// Combine all manifests, then drop anything whose identifier the caller
|
||||
// has explicitly forbidden for this turn. The post-merge filter closes
|
||||
// the second half of the LOBE-8768 wall: an installed plugin or a
|
||||
// the second half of the wall: an installed plugin or a
|
||||
// Skill/Klavis manifest claiming `lobe-remote-device` would otherwise
|
||||
// slip through `buildAllowedBuiltinTools` (which only touches the
|
||||
// builtin source).
|
||||
@@ -242,7 +242,7 @@ export const createServerAgentToolsEngine = (
|
||||
// Physically drop device-tool manifests for turns whose access policy
|
||||
// denies them. Without this filter, `lobe-activator`'s explicit
|
||||
// activation could resolve the manifest and bypass the rule-layer
|
||||
// gates below (LOBE-8768).
|
||||
// gates below ().
|
||||
builtinTools: buildAllowedBuiltinTools({ canUseDevice, disableLocalSystem }),
|
||||
// Add default tools based on configuration
|
||||
defaultToolIds: isChatMode ? chatModeAllowedToolIds : defaultToolIds,
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface ServerAgentToolsEngineConfig {
|
||||
* `manifestSchemas`. Defaults to the full `builtinTools` array from
|
||||
* `@lobechat/builtin-tools`. Callers gating device tools per-turn pass
|
||||
* `buildAllowedBuiltinTools(...)` here so an external bot sender cannot
|
||||
* resolve `lobe-remote-device` via the activator (LOBE-8768).
|
||||
* resolve `lobe-remote-device` via the activator ().
|
||||
*/
|
||||
builtinTools?: readonly LobeBuiltinTool[];
|
||||
/** Default tool IDs that will always be added */
|
||||
@@ -39,7 +39,7 @@ export interface ServerAgentToolsEngineConfig {
|
||||
* builtin, and additional manifests. Filtering builtins alone is not
|
||||
* enough: an installed plugin or a Skill/Klavis manifest can declare
|
||||
* `identifier: 'lobe-remote-device'` and slip past `buildAllowedBuiltinTools`.
|
||||
* This is the final post-merge wall referenced in LOBE-8768.
|
||||
* This is the final post-merge wall referenced in .
|
||||
*/
|
||||
excludeIdentifiers?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
+2
-2
@@ -372,12 +372,12 @@ describe('Multi-Round Tool Execution', () => {
|
||||
|
||||
expect(finalState.status).toBe('done');
|
||||
|
||||
// After LOBE-5143: state.messages stores parse()-processed UIChatMessage[]
|
||||
// After state.messages stores parse-processed UIChatMessage[]
|
||||
// Tool messages are wrapped in virtual 'assistantGroup' nodes by conversation-flow parse()
|
||||
// The chain detector combines consecutive assistant+tool rounds into a single assistantGroup
|
||||
expect(finalState.messages.length).toBeGreaterThan(0);
|
||||
|
||||
// After LOBE-5143: state.messages stores parse()-processed UIChatMessage[]
|
||||
// After state.messages stores parse-processed UIChatMessage[]
|
||||
// Tool messages are wrapped in virtual 'assistantGroup' nodes by conversation-flow parse()
|
||||
// The chain detector combines consecutive assistant+tool rounds into a single assistantGroup
|
||||
expect(finalState.messages.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -385,7 +385,7 @@ const AgentStreamEventSchema = z.object({
|
||||
/**
|
||||
* Schema for `aiAgent.heteroIngest` — accepts a batch of producer-side
|
||||
* `AgentStreamEvent`s from `lh hetero exec`. `topicId` is required (operationId
|
||||
* → topic reverse-lookup is unreliable per LOBE-8516 design decision).
|
||||
* → topic reverse-lookup is unreliable per design decision).
|
||||
*/
|
||||
const HeteroIngestSchema = z.object({
|
||||
agentType: z.enum(['claude-code', 'codex']),
|
||||
|
||||
@@ -541,7 +541,7 @@ export const messengerRouter = router({
|
||||
/**
|
||||
* Best-effort confirmation back to the IM platform after a successful link.
|
||||
* Slack needs `tenantId` to resolve the right per-workspace bot token; Telegram
|
||||
* is a global bot and ignores it. PR2.4 (LOBE-8453) rewires the Slack binder
|
||||
* is a global bot and ignores it. PR2.4 () rewires the Slack binder
|
||||
* to receive `InstallationCredentials` via the router's installation store —
|
||||
* until then this entry point falls back to no-op for Slack (binder.createClient
|
||||
* returns null in PR1's intermediate state).
|
||||
|
||||
@@ -875,7 +875,7 @@ export class AgentRuntimeService {
|
||||
|
||||
// Finalize tracing snapshot. The error catch below uses the same
|
||||
// recorder so propagated failures still write the canonical S3
|
||||
// snapshot instead of orphaning the partial (LOBE-8533).
|
||||
// snapshot instead of orphaning the partial ().
|
||||
await this.traceRecorder.finalize(operationId, {
|
||||
appendEventsToLastStep: completionSignalEvents,
|
||||
completionReason: reason,
|
||||
@@ -968,7 +968,7 @@ export class AgentRuntimeService {
|
||||
// RuntimeExecutors) leave the partial as an orphan at
|
||||
// `_partial/<op>.json.zst` and the canonical
|
||||
// `agent-traces/<agentId>/<topicId>/<op>.json.zst` returns 404 — see
|
||||
// LOBE-8533.
|
||||
// .
|
||||
//
|
||||
// `failedStep` synthesizes a step record for the failure because the
|
||||
// real step never reached `appendStepToPartial` — it threw before the
|
||||
|
||||
@@ -163,7 +163,7 @@ export class HumanInterventionHandler {
|
||||
* tools to be resolved or (b) resume LLM once this is the last one.
|
||||
* Returning a `phase: 'user_input'` nextContext while pendingToolsCalling
|
||||
* is non-empty would cause executeStep to run runtime.step immediately,
|
||||
* resuming the LLM with an unresolved batch — see LOBE-7151 review P1.
|
||||
* resuming the LLM with an unresolved batch — see review P1.
|
||||
*/
|
||||
private rejectAndContinue(
|
||||
state: any,
|
||||
|
||||
@@ -40,7 +40,7 @@ export interface FinalizeParams {
|
||||
* Synthetic step record for the error path. The real failing step never
|
||||
* reached `appendStep` because the executor threw before the partial push,
|
||||
* so the catch caller passes this to keep step counts aligned with the
|
||||
* assistant message that triggered the call. See LOBE-8533.
|
||||
* assistant message that triggered the call. See .
|
||||
*/
|
||||
failedStep?: { startedAt: number; stepIndex: number };
|
||||
state: any;
|
||||
|
||||
@@ -198,7 +198,7 @@ describe('HumanInterventionHandler.process', () => {
|
||||
it('stays paused (nextContext=undefined) when other tools are still pending', async () => {
|
||||
// makeState() has 2 pending; pluginQuery resolves tool-call-1 → 1 left.
|
||||
// Returning a `phase: 'user_input'` context here would resume the LLM
|
||||
// before the remaining pending tools are decided (LOBE-7151 review P1).
|
||||
// before the remaining pending tools are decided (review P1).
|
||||
const state = makeState();
|
||||
mockDBPluginQuery.mockResolvedValueOnce({ toolCallId: 'tool-call-1' });
|
||||
|
||||
|
||||
@@ -597,7 +597,7 @@ describe('AgentRuntimeService.executeStep - Redis failure in error handler', ()
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentRuntimeService.executeStep - error-path snapshot finalize (LOBE-8533)', () => {
|
||||
describe('AgentRuntimeService.executeStep - error-path snapshot finalize ()', () => {
|
||||
it('finalizes a snapshot with completionReason=error and a synthetic failed step when the executor throws', async () => {
|
||||
const snapshotStore = {
|
||||
get: vi.fn(),
|
||||
|
||||
@@ -263,7 +263,7 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
|
||||
});
|
||||
|
||||
describe('Web UI scenario (no botContext/discordContext)', () => {
|
||||
// LOBE-9378: regular chat used to leave activeDeviceId undefined when no
|
||||
// regular chat used to leave activeDeviceId undefined when no
|
||||
// device was bound, which caused the local-system system prompt's
|
||||
// {{workingDirectory}} / {{hostname}} placeholders to reach the LLM as
|
||||
// literals. The model would then waste the first N steps groping for cwd.
|
||||
@@ -428,7 +428,7 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
|
||||
// the runtime bound device. Setup: topic.metadata says device-002, but the
|
||||
// only online device is device-001. If the topic metadata were reused as
|
||||
// boundDeviceId, activeDeviceId would be undefined (device-002 is offline).
|
||||
// After LOBE-9378 auto-activate, we instead pick the most-recent online
|
||||
// After auto-activate, we instead pick the most-recent online
|
||||
// device (device-001) — proving the topic's stale metadata wasn't honored.
|
||||
it('should not reuse topic boundDeviceId when no explicit deviceId is provided', async () => {
|
||||
mockDeviceProxy.isConfigured = true;
|
||||
@@ -531,7 +531,7 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
|
||||
// different mock shape. Topic metadata stores device-002, but only
|
||||
// device-001 is online; if topic metadata leaked into boundDeviceId,
|
||||
// activeDeviceId would be undefined (since device-002 is offline). The
|
||||
// post-LOBE-9378 auto-activate picks device-001 instead, confirming the
|
||||
// post-auto-activate picks device-001 instead, confirming the
|
||||
// stale topic.metadata.boundDeviceId path is dead.
|
||||
it('should not reuse topic metadata bound device when no deviceId is supplied', async () => {
|
||||
mockDeviceProxy.isConfigured = true;
|
||||
|
||||
@@ -148,7 +148,7 @@ const createBaseAgentConfig = (overrides: Record<string, any> = {}) => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('AiAgentService.execAgent - device tool pipeline (LOBE-5636)', () => {
|
||||
describe('AiAgentService.execAgent - device tool pipeline ()', () => {
|
||||
let service: AiAgentService;
|
||||
const mockDb = {} as any;
|
||||
const userId = 'test-user-id';
|
||||
|
||||
@@ -38,7 +38,7 @@ export type DeviceAccessReason =
|
||||
*
|
||||
* 1. The platform's chat-adapter encodes every inbound thread as 1:1
|
||||
* (no group / channel handling), so the "external user @s the bot in
|
||||
* a group" attack surface from LOBE-8715 doesn't exist on this
|
||||
* a group" attack surface from doesn't exist on this
|
||||
* platform today.
|
||||
* 2. The platform's settings schema has no `userId` field, so an owner
|
||||
* ID can't be configured even if we wanted to gate on it.
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('deviceToolRegistry', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('strips BOTH device tools when canUseDevice=false (closes LOBE-8768 B1)', () => {
|
||||
it('strips BOTH device tools when canUseDevice=false (closes B1)', () => {
|
||||
const result = buildAllowedBuiltinTools({
|
||||
canUseDevice: false,
|
||||
disableLocalSystem: false,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* physical filter on `builtinTools` before they reach the
|
||||
* `ToolsEngine.manifestSchemas` or the activator-discovery
|
||||
* `toolManifestMap`. Routing every builtin discovery through this
|
||||
* helper closes the activator bypass documented in LOBE-8768 (an
|
||||
* helper closes the activator bypass documented in (an
|
||||
* external sender could otherwise `activateTools(["lobe-remote-device"])`
|
||||
* because the manifest was still resolvable in the engine even when
|
||||
* the rule-layer gate denied it).
|
||||
@@ -59,7 +59,7 @@ export interface AllowedBuiltinToolsParams {
|
||||
*
|
||||
* Defense-in-depth note: the rule-layer gates in `AgentToolsEngine` are
|
||||
* kept as a secondary line of defense, but they are bypassed by
|
||||
* `allowExplicitActivation` (LOBE-8768 B2), so the **physical** filter
|
||||
* `allowExplicitActivation` (B2), so the **physical** filter
|
||||
* here is the only reliable enforcement point.
|
||||
*/
|
||||
export const buildAllowedBuiltinTools = (params: AllowedBuiltinToolsParams) => {
|
||||
|
||||
@@ -1176,7 +1176,7 @@ export class AiAgentService {
|
||||
// bypassing the engine's enabledToolIds exclusion. Skipping the
|
||||
// assignment here closes that bypass at the source.
|
||||
//
|
||||
// Resolution order (LOBE-9378):
|
||||
// Resolution order ():
|
||||
// 1. boundDeviceId (topic-bound > agent-bound): use if online; if offline,
|
||||
// respect the explicit choice and stay unrouted — don't silently fall
|
||||
// back to a different device, that would surprise the user.
|
||||
@@ -1185,7 +1185,7 @@ export class AiAgentService {
|
||||
// recency / first-online would be a guess that could route tool calls
|
||||
// to the wrong machine. This applies uniformly to regular chat and
|
||||
// IM/Bot — the previous "regular-chat does nothing" path was the bug
|
||||
// behind LOBE-9378 (the local-system system prompt's
|
||||
// behind (the local-system system prompt's
|
||||
// `{{workingDirectory}}` reached the LLM as a literal, wasting the
|
||||
// first N steps groping for cwd).
|
||||
activeDeviceId = !canUseDevice
|
||||
@@ -1253,7 +1253,7 @@ export class AiAgentService {
|
||||
// installed plugin, a LobeHub Skill, or a Klavis manifest declaring
|
||||
// `identifier: 'lobe-remote-device'` would otherwise reach the
|
||||
// activator-discovery map and let an external bot sender enable it
|
||||
// (LOBE-8768). Centralising the check at the ingest layer means
|
||||
// (). Centralising the check at the ingest layer means
|
||||
// every future manifest source automatically inherits the wall.
|
||||
const isManifestIngestAllowed = (identifier: string): boolean =>
|
||||
canUseDevice || !isDeviceToolIdentifier(identifier);
|
||||
@@ -1419,7 +1419,7 @@ export class AiAgentService {
|
||||
|
||||
// 9.4. Fetch device system info for placeholder variable replacement.
|
||||
//
|
||||
// Decoupled from activeDeviceId routing (LOBE-9378): pulled into a helper
|
||||
// Decoupled from activeDeviceId routing (): pulled into a helper
|
||||
// so the device whose info populates the template (`{{hostname}}`,
|
||||
// `{{workingDirectory}}`, etc.) is a separate decision from the device
|
||||
// that tool calls route to. Today they're aligned — but future policy
|
||||
|
||||
@@ -542,7 +542,7 @@ export class BotMessageRouter {
|
||||
const groupSettings: GroupSettings = extractGroupSettings(info.settings);
|
||||
const userAllowlist: UserAllowlist = extractUserAllowlist(info.settings);
|
||||
/**
|
||||
* Operator-configured keywords (LOBE-8891). When non-empty, a non-@mention
|
||||
* Operator-configured keywords (). When non-empty, a non-@mention
|
||||
* non-command message in a subscribed group thread still wakes the bot if
|
||||
* its text contains any keyword — case-insensitive, word-boundary aware
|
||||
* (see `messageMatchesWatchKeyword`). Empty list keeps the legacy
|
||||
@@ -887,7 +887,7 @@ export class BotMessageRouter {
|
||||
return false;
|
||||
};
|
||||
|
||||
// LOBE-8981: single-user thread relaxation. A subscribed channel thread
|
||||
// single-user thread relaxation. A subscribed channel thread
|
||||
// with only one human follower is effectively a private 1:1 with the
|
||||
// bot, so we drop the @mention requirement for follow-ups. Once a
|
||||
// second human posts we revert to mention-only mode and announce the
|
||||
@@ -1044,7 +1044,7 @@ export class BotMessageRouter {
|
||||
// type `/new` directly without mentioning the bot), but they are NOT exempt
|
||||
// from the access gates below.
|
||||
//
|
||||
// LOBE-8981: a subscribed channel thread with only one human follower
|
||||
// a subscribed channel thread with only one human follower
|
||||
// is functionally a private 1:1 with the bot, so the @mention
|
||||
// requirement is dropped while `count <= 1`. Tracked + counted here
|
||||
// regardless of which exemption ultimately fires so the
|
||||
@@ -1057,7 +1057,7 @@ export class BotMessageRouter {
|
||||
context?.skipped?.some((m) => m.isMention === true) === true ||
|
||||
isSingleHumanThread;
|
||||
const isCommand = looksLikeCommand(message.text);
|
||||
// LOBE-8891: operator-configured keyword match also wakes the bot in a
|
||||
// operator-configured keyword match also wakes the bot in a
|
||||
// subscribed group thread. Skipped (debounced) siblings are inspected
|
||||
// too so a keyword queued behind a non-trigger still fires — same
|
||||
// pattern as the mention check above.
|
||||
@@ -1075,7 +1075,7 @@ export class BotMessageRouter {
|
||||
message.author.userName,
|
||||
thread.id,
|
||||
);
|
||||
// LOBE-8981: first skip in this thread → tell participants the bot
|
||||
// first skip in this thread → tell participants the bot
|
||||
// is now mention-only so newcomers don't think it broke. Dedupe by
|
||||
// thread id so we never announce more than once.
|
||||
if (!thread.isDM && humanCount >= 2) {
|
||||
@@ -1135,7 +1135,7 @@ export class BotMessageRouter {
|
||||
}
|
||||
|
||||
let merged = BotMessageRouter.mergeSkippedMessages(message, context);
|
||||
// LOBE-8891: when a keyword (and not a mention / DM / command) is what
|
||||
// when a keyword (and not a mention / DM / command) is what
|
||||
// wakes the bot, prepend the matched entries' operator-authored
|
||||
// instructions to the user message so the agent gets a directive
|
||||
// rather than only the raw chatter. Mentions, DMs, and commands are
|
||||
@@ -1236,7 +1236,7 @@ export class BotMessageRouter {
|
||||
// regex listener can serve both:
|
||||
//
|
||||
// • DM path: every message in a DM thread (when DM policy allows it).
|
||||
// • Channel keyword path (LOBE-8891): non-DM messages whose text — or
|
||||
// • Channel keyword path (): non-DM messages whose text — or
|
||||
// a debounced sibling's text — contains a configured watch keyword.
|
||||
// This is the only way to wake the bot in a parent channel on
|
||||
// platforms like Discord, where `shouldSubscribe` returns false for
|
||||
@@ -1320,7 +1320,7 @@ export class BotMessageRouter {
|
||||
}
|
||||
|
||||
let merged = BotMessageRouter.mergeSkippedMessages(message, context);
|
||||
// LOBE-8891: same instruction-injection rule as `onSubscribedMessage`
|
||||
// same instruction-injection rule as `onSubscribedMessage`
|
||||
// — prepend the matched entries' operator-authored instructions when
|
||||
// the keyword (not a mention / DM / command) is what wakes the bot.
|
||||
// DMs are explicit user intent and never get the prefix.
|
||||
|
||||
@@ -296,7 +296,7 @@ describe('BotCallbackService', () => {
|
||||
await service.handleCallback(body);
|
||||
|
||||
// Crucially: never hits agent_bot_providers — that lookup throws for
|
||||
// messenger-originated runs and was the cause of LOBE-8654.
|
||||
// messenger-originated runs and was the cause of .
|
||||
expect(mockFindByPlatformAndAppId).not.toHaveBeenCalled();
|
||||
expect(mockMessengerGetInstallationStore).toHaveBeenCalledWith('telegram');
|
||||
expect(mockMessengerStoreResolveByKey).toHaveBeenCalledWith('telegram:singleton');
|
||||
|
||||
@@ -69,7 +69,7 @@ const mockOnNewMention = vi.hoisted(() => vi.fn());
|
||||
const mockOnSubscribedMessage = vi.hoisted(() => vi.fn());
|
||||
const mockOnNewMessage = vi.hoisted(() => vi.fn());
|
||||
const mockOnSlashCommand = vi.hoisted(() => vi.fn());
|
||||
// Default state mocks for the LOBE-8981 participant tracking. Tests that
|
||||
// Default state mocks for the participant tracking. Tests that
|
||||
// care about the multi-human transition reassign `mockGetList` to seed the
|
||||
// pre-existing participant list.
|
||||
const mockGetList = vi.hoisted(() => vi.fn().mockResolvedValue([]));
|
||||
@@ -103,7 +103,7 @@ vi.mock('@/server/services/aiAgent', () => ({
|
||||
const mockHandleMention = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
const mockHandleSubscribedMessage = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
// Default to "platform does not opt into thread isolation" so existing tests
|
||||
// keep their pre-LOBE-8891 behaviour. Individual tests can replace this via
|
||||
// keep their pre-behaviour. Individual tests can replace this via
|
||||
// `.mockResolvedValueOnce(...)` to simulate Discord's auto-thread upgrade.
|
||||
const mockOpenThreadForChannelWake = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
|
||||
@@ -292,7 +292,7 @@ vi.mock('../platforms', () => ({
|
||||
if (!operatorId || explicit.includes(operatorId)) return { ids: explicit };
|
||||
return { ids: [...explicit, operatorId] };
|
||||
},
|
||||
// LOBE-8891: mirror the production helpers just well enough that
|
||||
// mirror the production helpers just well enough that
|
||||
// BotMessageRouter.registerHandlers can read a clean list of entries.
|
||||
// Tests can populate `settings.watchKeywords` with the canonical
|
||||
// `[{ keyword, instruction? }]` shape to exercise both the keyword-match
|
||||
@@ -436,7 +436,7 @@ describe('BotMessageRouter', () => {
|
||||
mockHandleMention.mockResolvedValue(undefined);
|
||||
mockHandleSubscribedMessage.mockResolvedValue(undefined);
|
||||
mockOpenThreadForChannelWake.mockResolvedValue(undefined);
|
||||
// LOBE-8981 participant tracking — restore defaults wiped by
|
||||
// participant tracking — restore defaults wiped by
|
||||
// clearAllMocks. Empty list = fresh single-human thread; individual
|
||||
// describes / tests override as needed.
|
||||
mockGetList.mockResolvedValue([]);
|
||||
@@ -597,7 +597,7 @@ describe('BotMessageRouter', () => {
|
||||
});
|
||||
|
||||
describe('onSubscribedMessage policy', () => {
|
||||
// LOBE-8981 introduced single-human thread relaxation: a non-mention
|
||||
// introduced single-human thread relaxation: a non-mention
|
||||
// post in a thread with ≤1 known humans now reaches the agent. Most of
|
||||
// the existing policy tests are about the multi-human gate (keyword
|
||||
// wake, command pass-through, allowlist rejection), so seed two
|
||||
@@ -652,7 +652,7 @@ describe('BotMessageRouter', () => {
|
||||
}
|
||||
|
||||
it('should skip non-mention messages in a multi-human group thread', async () => {
|
||||
// LOBE-8981: post-fix the gate keys off thread.isDM || mention ||
|
||||
// post-fix the gate keys off thread.isDM || mention ||
|
||||
// singleHumanThread. Default beforeEach seeds two known participants,
|
||||
// a third sender keeps the thread in multi-human territory.
|
||||
const handler = await loadSubscribedHandler();
|
||||
@@ -668,7 +668,7 @@ describe('BotMessageRouter', () => {
|
||||
expect(mockHandleSubscribedMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should respond to non-mention messages while the channel thread is still single-human (LOBE-8981)', async () => {
|
||||
it('should respond to non-mention messages while the channel thread is still single-human ()', async () => {
|
||||
// Override the default multi-human seed: no prior participants →
|
||||
// tracker records alice as participant #1 → gate lets her through
|
||||
// without an explicit @mention.
|
||||
@@ -682,7 +682,7 @@ describe('BotMessageRouter', () => {
|
||||
expect(mockHandleSubscribedMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should announce mention-only mode once when a second human joins (LOBE-8981)', async () => {
|
||||
it('should announce mention-only mode once when a second human joins ()', async () => {
|
||||
// Alice is already tracked; bob's first non-mention post is the
|
||||
// multi-human transition.
|
||||
mockGetList.mockResolvedValue(['alice-id']);
|
||||
@@ -739,7 +739,7 @@ describe('BotMessageRouter', () => {
|
||||
expect(mockHandleSubscribedMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should wake on a watch-keyword match in a subscribed group thread (LOBE-8891)', async () => {
|
||||
it('should wake on a watch-keyword match in a subscribed group thread ()', async () => {
|
||||
const handler = await loadSubscribedHandler({
|
||||
dmPolicy: 'open',
|
||||
watchKeywords: [{ keyword: 'bug' }, { keyword: 'outage' }],
|
||||
@@ -785,7 +785,7 @@ describe('BotMessageRouter', () => {
|
||||
});
|
||||
|
||||
it('should not change behaviour when watchKeywords is empty/missing', async () => {
|
||||
// Sanity: existing call sites must keep their pre-LOBE-8891 semantics.
|
||||
// Sanity: existing call sites must keep their pre-semantics.
|
||||
const handler = await loadSubscribedHandler({ dmPolicy: 'open' });
|
||||
const thread = makeThread({ isDM: false });
|
||||
const message = makeMessage({ isMention: false, text: 'there is a bug somewhere' });
|
||||
@@ -1297,7 +1297,7 @@ describe('BotMessageRouter', () => {
|
||||
expect(thread.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---- LOBE-8891: channel-side keyword wake via catch-all ----
|
||||
// ---- channel-side keyword wake via catch-all ----
|
||||
//
|
||||
// Discord (and any platform that opts out of subscribing top-level
|
||||
// channels via `shouldSubscribe`) never fires `onSubscribedMessage` for
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('buildBotContext', () => {
|
||||
expect(ctx.senderExternalUserId).toBe('sender-2');
|
||||
});
|
||||
|
||||
it('fails closed when operatorUserId is undefined (LOBE-8768 contract)', () => {
|
||||
it('fails closed when operatorUserId is undefined (contract)', () => {
|
||||
const ctx = buildBotContext({
|
||||
...baseParams,
|
||||
authorUserId: 'sender-1',
|
||||
|
||||
@@ -364,7 +364,7 @@ export function normalizeAllowFromEntries(raw: unknown): Array<{ id: string; nam
|
||||
return [];
|
||||
}
|
||||
|
||||
// ---------- Watch keywords (LOBE-8891) ----------
|
||||
// ---------- Watch keywords () ----------
|
||||
|
||||
/**
|
||||
* Entry shape persisted under `settings.watchKeywords`. `keyword` is what the
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user