From b03ce8d778c9acf5f22dd292944841e221ce40bf Mon Sep 17 00:00:00 2001 From: Dash <125997726+dashhuang@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:58:36 +1300 Subject: [PATCH 1/2] =?UTF-8?q?fix(companion):=20=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=9B=9E=E6=8A=A5=E8=A7=A6=E5=8F=91=E7=9A=84?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E6=96=87=E5=AD=97=E5=B9=B6=E6=98=8E=E7=A1=AE?= =?UTF-8?q?=E8=BD=AE=E6=AC=A1=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dash <125997726+dashhuang@users.noreply.github.com> --- .../agent-island/__tests__/service.test.ts | 2 +- .../__tests__/agent-input-coordinator.test.ts | 29 ++++++++++++++- .../__tests__/botTaskCoordination.test.ts | 8 +---- .../__tests__/botWorkbenchTranscripts.test.ts | 15 ++++---- .../__tests__/sessionEventPipeline.test.ts | 36 ++++++++++++------- .../main/maker-ipc/botWorkbenchTranscripts.ts | 14 +++++--- .../src/main/maker-ipc/coordinationOutput.ts | 12 ------- .../main/maker-ipc/sessionEventPipeline.ts | 6 +--- .../main/maker-ipc/sessionEventPreparation.ts | 6 ++-- .../desktop/src/shared/botTaskCoordination.ts | 8 +++-- docs/product-rules/agent-user-parity.md | 3 ++ docs/product-rules/cindy-bots-runtime.md | 7 ++-- docs/product-rules/message-source.md | 15 +++++--- .../src/xdt-helper/get_task_digest.ts | 5 +-- .../src/xdt-helper/send_to_session.ts | 2 +- 15 files changed, 102 insertions(+), 66 deletions(-) delete mode 100644 apps/desktop/src/main/maker-ipc/coordinationOutput.ts diff --git a/apps/desktop/src/main/agent-island/__tests__/service.test.ts b/apps/desktop/src/main/agent-island/__tests__/service.test.ts index b31dfb4401f..94cfde33253 100644 --- a/apps/desktop/src/main/agent-island/__tests__/service.test.ts +++ b/apps/desktop/src/main/agent-island/__tests__/service.test.ts @@ -7013,7 +7013,7 @@ it('does not sound or mark attention for internal coordination completion, but s const meta = { sessionId: 'coordination', agentKind: 'codex' as const }; service.handleAgentEvent(meta, { type: 'status', source: 'codex', data: { isRunning: true } }); playSound.mockClear(); - service.handleAgentEvent(meta, { ...doneEvent(), agentMeta: { botTaskCoordination: true, botPrivateReply: true } }); + service.handleAgentEvent(meta, { ...doneEvent(), agentMeta: { botTaskCoordination: true, botPrivateReply: false } }); expect(playSound).not.toHaveBeenCalled(); expect(service.getSessionActivitySnapshot(meta.sessionId)?.attention).not.toBe(true); service.handleAgentEvent(meta, terminalErrorEvent('User action required')); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/agent-input-coordinator.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/agent-input-coordinator.test.ts index 7cf8fa84db4..459939145bb 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/agent-input-coordinator.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/agent-input-coordinator.test.ts @@ -2404,7 +2404,7 @@ describe('AgentInputCoordinator send transaction', () => { }); it.each(['policy', 'preparation', 'provider'] as const)( - 'keeps coordination quiet when human steering fails at %s', async (failure) => { + 'keeps coordination ownership when human steering fails at %s', async (failure) => { const h = createHarness(), sid = `coordination-steer-${failure}`; h.sendToAgent.mockImplementationOnce(async () => { h.setRunning(true); return sendSuccess(); }); h.coordinator.enqueue(sid, makeItem('internal', '[UI_ACTION_TRIGGER]File agreement', { @@ -2436,6 +2436,33 @@ describe('AgentInputCoordinator send transaction', () => { }, ); + // The parent's reply ending releases its input lane even if the delegated + // child has not reported completion. No child terminal is needed to talk again. + it('dispatches owner input after a coordination turn ends and accepts another normal turn', async () => { + const h = createHarness(), sid = 'coordination-next-message'; + h.coordinator.enqueue(sid, makeItem('report', '[UI_ACTION_TRIGGER]Build still running', { + botTaskCoordination: { delegationId: 'd', senderSessionId: 'child', runSequence: 1 }, + })); + await flush(); + h.coordinator.enqueue(sid, makeItem('owner', 'Check the other machine too')); + await flush(); + expect(h.sendToAgent).toHaveBeenCalledOnce(); + expect(h.coordinator.getProjection(sid).pendingQueue.map(item => item.clientId)).toEqual(['owner']); + h.setRunning(false); + h.coordinator.onTurnEvent(sid, 'done'); + await flush(); + expect(h.sendToAgent).toHaveBeenCalledTimes(2); + expect(h.sendToAgent.mock.calls[1]?.[1]).toEqual({ type: 'user', content: 'Check the other machine too' }); + expect(h.coordinator.isActiveTaskCoordination(sid)).toBe(false); + h.setRunning(false); + h.coordinator.onTurnEvent(sid, 'done'); + await flush(); + h.coordinator.enqueue(sid, makeItem('next', 'What is the next step?')); + await flush(); + expect(h.sendToAgent).toHaveBeenCalledTimes(3); + expect(h.coordinator.getProjection(sid).pendingQueue).toEqual([]); + }); + it.each(['projection', 'control', 'direct'] as const)( 'keeps coordination queued instead of injecting it through %s steering', async (entry) => { const h = createHarness(), sid = `coordination-queue-steer-${entry}`; diff --git a/apps/desktop/src/main/maker-ipc/__tests__/botTaskCoordination.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/botTaskCoordination.test.ts index c12b39afabf..b7053787829 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/botTaskCoordination.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/botTaskCoordination.test.ts @@ -8,8 +8,7 @@ import { assertBotTaskCoordination, classifySessionMessagePurpose, coordinationI import { buildMakerUserMessage, sanitizeQueuedMessageForPersistence, type AgentInputQueuedMessage } from '../../../shared/agentInputQueue'; import { redactMessageOriginForSharedGuest, redactQueueItemForSharedGuest } from '../../device-link/sharedTaskMessageOrigin'; import { rebuildSessionQueueItem } from '../sessionControlService'; -import { projectCoordinationOutput } from '../coordinationOutput'; -import { AUTO_REVIEW_DELEGATED_CONTINUATION, type AgentEvent } from '@cindy/maker-core'; +import { AUTO_REVIEW_DELEGATED_CONTINUATION } from '@cindy/maker-core'; import { stripMainOnlySendOpts } from '../mobileClientPromptNote'; import { captureAgentRequest, formatForwardedRequest } from '../forwardedRequest'; @@ -90,11 +89,6 @@ it('retains hidden persistence and routing when the sending task edits its queue expect(edited.botTaskCoordination).toEqual(receipt); expect(buildMakerUserMessage(edited)).toMatchObject({ content: expect.stringContaining('Updated agreement') }); }); -it.each(['interaction_request', 'interaction_dismissed', 'error', 'tool_use', 'tool_result', 'account_usage'])('does not suppress an attention/action event: %s', type => { - const event: AgentEvent = { type, source: 'codex', data: { requestId: 'interaction', kind: 'ask_user_question', questions: [] } }; - expect(projectCoordinationOutput(event, true)).toBe(event); -}); - it('uses the registered host entry for both quiet and explicitly visible delegated sends', async () => { const source = readFileSync(resolve(__dirname, '..', 'register.ts'), 'utf8').replace(/\r\n/g, '\n'); const start = source.indexOf(' sendToSession: async (params) => {'); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/botWorkbenchTranscripts.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/botWorkbenchTranscripts.test.ts index dfb46689623..736aa68ce14 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/botWorkbenchTranscripts.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/botWorkbenchTranscripts.test.ts @@ -1,9 +1,9 @@ /** * 测试价值 - * - 生产入口与可观察结果:boundTranscript / lastExchange 的输出(伙伴读到的摘录与任务摘要的两句)。 - * - 规则依据:工作台摘录有界且去掉指令块;get_task_digest 取「主人最后的要求 / 助手最后一句」。 - * - 现有覆盖缺口:放弃「项目」后本机转录扫描已删除(其测试随之删除);lastExchange 为新增。 - * - 最小失败输入:末尾是空助手消息 / 指令块;多行助手回复。 + * - 生产入口与可观察结果:boundTranscript / lastExchange 的摘录与任务摘要。 + * - 规则依据:内部协调不是用户要求;最新助手回复保留重点,由工具层按预算截短。 + * - 现有覆盖缺口:内部回报覆盖了用户要求;多行回复只保留最后一行而丢失结论。 + * - 最小失败输入:用户要求之后出现隐藏协调输入;助手先说结论再说下一步。 * - tier:unit(纯函数,数据库模块 mock 掉,不加载)。 */ import { describe, expect, it, vi } from 'vitest'; @@ -30,14 +30,17 @@ describe('boundTranscript', () => { }); describe('lastExchange', () => { - it('takes the latest owner ask and the last line of the latest non-empty assistant reply', () => { + it('keeps the latest user ask and the full latest non-empty assistant reply', () => { expect(lastExchange([ { role: 'user', text: '先修登录' }, { role: 'assistant', text: '好的' }, { role: 'user', text: 'internal再跑一遍测试' }, { role: 'assistant', text: '跑完了。\n还有 2 个失败' }, + { role: 'user', text: '[UI_ACTION_TRIGGER]后台回报:正在修复失败项' }, { role: 'assistant', text: 'only a hint' }, - ])).toEqual({ lastUserAsk: '再跑一遍测试', lastAssistantLine: '还有 2 个失败' }); + ])).toEqual({ lastUserAsk: '再跑一遍测试', lastAssistantLine: '跑完了。\n还有 2 个失败' }); expect(lastExchange([])).toEqual({ lastUserAsk: null, lastAssistantLine: null }); + expect(lastExchange([{ role: 'user', text: '[UI_ACTION_TRIGGER]仅内部协调' }])) + .toEqual({ lastUserAsk: null, lastAssistantLine: null }); }); }); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/sessionEventPipeline.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/sessionEventPipeline.test.ts index c7c4a24eef9..fe16981efe0 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/sessionEventPipeline.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/sessionEventPipeline.test.ts @@ -425,31 +425,43 @@ function ordered(...names: string[]) { } describe('production Session event pipeline', () => { - it('delivers coordination actions without public prose, then preserves ordinary and completion replies', async () => { + // Regression: a delegated report used to leave only tool rows in the chat. + // The real pipeline must persist and broadcast its reply, then settle normally. + it('delivers coordination progress and final text through the ordinary message pipeline', async () => { const h = harness(); + h.deps.redactEventForRenderer.mockImplementation(value => value); h.deps.agentInputCoordinatorHolder.isActiveTaskCoordination.mockReturnValue(true); h.deps.agentInputCoordinatorHolder.getActiveInputClientId.mockReturnValue('coordination'); try { - h.emit(event('text', { text: 'Internal file ownership agreement', isFinal: true })); - h.emit(event('text', { text: 'Internal standalone' }, { standaloneText: true })); - h.emit(event('thinking', { text: 'Internal reasoning' })); - expect(effects.fn('onAssistantTextEvent')).not.toHaveBeenCalled(); - expect(effects.fn('onStandaloneTextEvent')).not.toHaveBeenCalled(); - expect(h.deps.broadcastToAllWindows).not.toHaveBeenCalled(); + h.emit(event('text', { text: 'The new machines are running; checking the test results.', isFinal: false }, + { agentMeta: { assistantPhase: 'commentary' } })); + expect(effects.fn('onAssistantTextEvent')).toHaveBeenCalledWith('task', + expect.objectContaining({ text: 'The new machines are running; checking the test results.' }), + expect.objectContaining({ botPrivateReply: false, assistantPhase: 'commentary' }), undefined); + expect(h.deps.broadcastToAllWindows).toHaveBeenCalledWith('maker:event', expect.objectContaining({ + event: expect.objectContaining({ type: 'text', data: expect.objectContaining({ + text: 'The new machines are running; checking the test results.', + }) }), + })); + h.emit(event('text', { text: 'Progress notice' }, { standaloneText: true })); + expect(effects.fn('onStandaloneTextEvent')).toHaveBeenCalledWith('task', 'Progress notice', { botPrivateReply: false }); + h.emit(event('thinking', { text: 'Check the test results' })); + expect(effects.fn('onThinkingEvent')).toHaveBeenCalledOnce(); h.emit(event('tool_use', { id: 'tool', name: 'read', input: {} })); expect(effects.fn('onToolUseEvent')).toHaveBeenCalledOnce(); h.deps.agentInputCoordinatorHolder.onTurnEvent.mockImplementation(() => { h.deps.agentInputCoordinatorHolder.isActiveTaskCoordination.mockReturnValue(false); }); - h.emit(event('done', { result: 'Internal acknowledgement', finalText: 'Internal acknowledgement' })); + h.emit(event('done', { result: 'The tests passed.', finalText: 'The tests passed.' })); expect(h.deps.broadcastToAllWindows).toHaveBeenCalledWith('maker:event', expect.objectContaining({ - event: expect.objectContaining({ type: 'done', data: expect.objectContaining({ result: '', finalText: '' }), - agentMeta: expect.objectContaining({ botPrivateReply: true, botTaskCoordination: true }) }), + event: expect.objectContaining({ type: 'done', data: expect.objectContaining({ result: 'The tests passed.', finalText: 'The tests passed.' }), + agentMeta: expect.objectContaining({ botPrivateReply: false, botTaskCoordination: true }) }), })); - expect(JSON.stringify(h.deps.broadcastToAllWindows.mock.calls)).not.toContain('Internal acknowledgement'); + expect(h.deps.agentInputCoordinatorHolder.onTurnEvent).toHaveBeenCalled(); + const persisted = effects.fn('onAssistantTextEvent').mock.calls.length; h.deps.agentInputCoordinatorHolder.getActiveInputClientId.mockReturnValue('bot-delegation-completion:result'); h.emit(event('text', { text: 'User requested final result', isFinal: true })); - expect(effects.fn('onAssistantTextEvent')).toHaveBeenCalledOnce(); + expect(effects.fn('onAssistantTextEvent')).toHaveBeenCalledTimes(persisted + 1); } finally { await h.dispose(); } }); diff --git a/apps/desktop/src/main/maker-ipc/botWorkbenchTranscripts.ts b/apps/desktop/src/main/maker-ipc/botWorkbenchTranscripts.ts index 1a5e18d0fb7..23569720156 100644 --- a/apps/desktop/src/main/maker-ipc/botWorkbenchTranscripts.ts +++ b/apps/desktop/src/main/maker-ipc/botWorkbenchTranscripts.ts @@ -1,11 +1,12 @@ /** - * 伙伴工作台与任务摘要的只读会话读取:有界摘录与「最后一问 / 最后一句」。 + * 伙伴工作台与任务摘要的只读会话读取:有界摘录与最近用户消息 / 助手回复。 * * 2026-10-17 放弃「项目」后,不再扫描本机 Claude Code / Codex / Pi 转录目录,也不再为项目 * 现算摘要;只读 Cindy 自己库里的任务,两条有界查询,输出统一去掉 `` 之类的 * 指令块。不写库、不导入。 */ import { and, desc, eq, gt, inArray, isNull } from 'drizzle-orm'; +import { isSyntheticTriggerText } from '@cindy/maker-shared/synthetic-trigger'; import { getDbClient } from '../localDb/client/current.js'; import { messages, sessions } from '../localDb/schema.js'; @@ -18,7 +19,7 @@ import { export const WORKBENCH_TRANSCRIPT_MAX_CHARS = 4_000; const PER_MESSAGE_MAX_CHARS = 1_200; const SESSION_MESSAGE_ROWS = 60; -/** 找「最后一问 / 最后一句」时最多往回看的消息条数。 */ +/** 找最近用户消息 / 助手回复时最多往回看的消息条数。 */ const LAST_EXCHANGE_ROWS = 20; function cleanText(raw: string): string { @@ -112,13 +113,16 @@ export function lastExchange(items: readonly Pick= 0 && (!lastUserAsk || !lastAssistantLine); index -= 1) { const text = cleanText(items[index].text); if (!text) continue; - if (items[index].role === 'user') lastUserAsk ??= text; - else lastAssistantLine ??= text.split('\n').filter(Boolean).pop() ?? text; + if (items[index].role === 'user') { + // Shared with remote history: coordination and recovery inputs carry the + // same durable marker and must not replace the user's actual request. + if (!isSyntheticTriggerText(text)) lastUserAsk ??= text; + } else lastAssistantLine ??= text; } return { lastUserAsk, lastAssistantLine }; } -/** 本机任务的「最后一问 / 最后一句」:一条有界查询。 */ +/** 本机任务的最近用户消息 / 助手回复:一条有界查询。 */ export async function readSessionLastExchange(sessionId: string) { const { db, where } = await sessionMessageWhere(sessionId); const rows = await db diff --git a/apps/desktop/src/main/maker-ipc/coordinationOutput.ts b/apps/desktop/src/main/maker-ipc/coordinationOutput.ts deleted file mode 100644 index a7ff4613df2..00000000000 --- a/apps/desktop/src/main/maker-ipc/coordinationOutput.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { AgentEvent } from '@cindy/maker-core'; - -/** Preserve tool actions, errors, interactions and usage; only internal prose is omitted. */ -export function projectCoordinationOutput(event: AgentEvent, coordination: boolean): AgentEvent | null { - if (!coordination || event.runtimeRecovery) return event; - if (event.type === 'text' || event.type === 'thinking') return null; - if (event.type === 'done') { - const data = event.data && typeof event.data === 'object' ? event.data : {}; - return { ...event, data: { ...data, result: '', finalText: '' } }; - } - return event; -} diff --git a/apps/desktop/src/main/maker-ipc/sessionEventPipeline.ts b/apps/desktop/src/main/maker-ipc/sessionEventPipeline.ts index 55850d47f53..cc2de0e14dc 100644 --- a/apps/desktop/src/main/maker-ipc/sessionEventPipeline.ts +++ b/apps/desktop/src/main/maker-ipc/sessionEventPipeline.ts @@ -1,4 +1,3 @@ -import { projectCoordinationOutput } from './coordinationOutput.js'; import { observeBotLearningTurn } from './botLearningFeedback.js'; import { projectQuietScheduledOutput } from '../scheduler-host/silent-output.js'; import type { AgentEvent, Session } from '@cindy/maker-core'; @@ -45,10 +44,7 @@ export function handleSessionEvent( ): void { const prepared = prepareSessionEvent(deps, session, event); if (!prepared) return; - const project = (input: AgentEvent) => { - const scheduled = projectQuietScheduledOutput(input); - return scheduled && projectCoordinationOutput(scheduled, prepared.taskCoordination); - }; + const project = projectQuietScheduledOutput; const projected = project(prepared.event); if (!projected) return; if (projected !== prepared.event) { diff --git a/apps/desktop/src/main/maker-ipc/sessionEventPreparation.ts b/apps/desktop/src/main/maker-ipc/sessionEventPreparation.ts index 80bdc56f328..2a77e4a2441 100644 --- a/apps/desktop/src/main/maker-ipc/sessionEventPreparation.ts +++ b/apps/desktop/src/main/maker-ipc/sessionEventPreparation.ts @@ -185,7 +185,6 @@ export function prepareSessionEvent( const taskCoordination = event.turnScope !== 'background' && (deps.agentInputCoordinatorHolder?.isActiveTaskCoordination?.(session.id, event.sessionTurnGeneration) ?? false); if (event.type === 'text' && event.standaloneText === true) { - if (taskCoordination && !event.runtimeRecovery) return; if (isQuietScheduledOutput(event) && !event.runtimeRecovery) return; // Deliver through the existing persisted-row channel only. Sending a text // event as well would let older renderers adopt the notice as their active @@ -226,7 +225,9 @@ export function prepareSessionEvent( ...event, agentMeta: { ...event.agentMeta, - botPrivateReply: isBotPrivateInput(activeInputId) || (taskCoordination && !event.runtimeRecovery), + // Coordination hides the incoming report, not the teammate's reply. + botPrivateReply: isBotPrivateInput(activeInputId), + // Retain provenance for the existing quiet completion notification policy. botTaskCoordination: taskCoordination && !event.runtimeRecovery, // The user input this turn answers; companion chats can show it above the reply // (cindy-bots-runtime.md「回复对应的消息」). Hidden trigger inputs are skipped on display. @@ -603,7 +604,6 @@ export function prepareSessionEvent( attributedEvent, botTaskResultInputIds, groupLaneInputClientId, - taskCoordination, broadcastEvent, eventAgentMeta, pendingContextSnapshot, diff --git a/apps/desktop/src/shared/botTaskCoordination.ts b/apps/desktop/src/shared/botTaskCoordination.ts index 2e6cb76c4ac..8fbe1fae0da 100644 --- a/apps/desktop/src/shared/botTaskCoordination.ts +++ b/apps/desktop/src/shared/botTaskCoordination.ts @@ -10,7 +10,9 @@ export type SessionMessagePurpose = 'coordination' | 'user-visible'; /** Per-input model instruction; never included in visible queue/history text. */ export function coordinationModelPrefix(receipt: BotTaskCoordination): string { return `[Internal task coordination from session ${receipt.senderSessionId}; delegation ${receipt.delegationId}; run ${receipt.runSequence}]\n` - + 'Process this coordination and use tools as needed. Prose in this turn is internal. ' - + 'Use question/permission tools for necessary user decisions. For a user-facing result or a failure requiring user intervention, ' - + 'use send_to_session with message_purpose="user-visible". Normal task completion is delivered separately.\n\n'; + + 'This incoming report is internal context, not a new request from the owner. Process it and use tools as needed. ' + + 'Your replies appear in the owner conversation: directly explain meaningful progress, results or blockers; ' + + 'if there is nothing useful to report, you may stay silent. Use question/permission tools for necessary user decisions. ' + + 'Ending your reply ends only this turn; delegated work may continue. State what remains in progress and report completion only when verified. ' + + 'Normal task completion is delivered separately.\n\n'; } diff --git a/docs/product-rules/agent-user-parity.md b/docs/product-rules/agent-user-parity.md index a7366151065..99af1d68bd7 100644 --- a/docs/product-rules/agent-user-parity.md +++ b/docs/product-rules/agent-user-parity.md @@ -91,6 +91,9 @@ Worker 工具省略 working_dir 时保持继承 Lead 任务记录目录,不跟 - 任务摘要(`get_task_digest`):`packages/lizi-mcps/src/xdt-helper/get_task_digest.ts`(输出合同 ≤2KB)、 `apps/desktop/src/main/mcp-integrations/taskDigest.ts` 与 `taskDigestHost.ts`(跨电脑只读,复用 `local-db:sessions:get`、`maker:list-active`、历史消息与 PR 状态通道)。 + 本机与跨电脑均排除带隐藏触发标记的协调/续跑输入,保留最新助手回复全文供工具按预算截短; + 不用内部回报替代用户要求,也不只取助手回复最后一行。`finished` 只表示所查任务本轮结束, + 不证明委派工作或用户目标完成;不参与输入放行判定,下一条消息仍按实时运行状态和队列派发。 - 伙伴跟进任务:`apps/desktop/src/main/maker-ipc/botWorkbenchAccess.ts`(授权:自动跟进所有任务的伙伴 可读取既有范围内的任务,含在线电脑;继续或接手还需行动授权,其它伙伴只读自己开的或已接手的任务)。 - 协议与兼容见 [`../dev-rules/protocol-compatibility.md`](../dev-rules/protocol-compatibility.md) diff --git a/docs/product-rules/cindy-bots-runtime.md b/docs/product-rules/cindy-bots-runtime.md index 67096e9ecaa..a783ede6185 100644 --- a/docs/product-rules/cindy-bots-runtime.md +++ b/docs/product-rules/cindy-bots-runtime.md @@ -713,9 +713,10 @@ Session 任务遵守同一套机制与呈现契约: 超时终态也必须检查旧引擎是否已停,仍活跃时不得通过续接启动第二份执行。 委派子任务向发起伙伴发送文件分工等内部协调时,使用 `send_to_session` 的协调用途, -其默认识别、来源核验、静默范围和历史兼容遵守 [message-source.md](message-source.md#委派任务的内部协调)。 -这条路径仍唤醒伙伴消费输入,不创建公开进度答复;正常完成结果、必要交互与错误继续保留。 -普通任务来信和明确 `user-visible` 的发送不受此静默规则影响。 +其默认识别、来源核验、通知静默和历史兼容遵守 [message-source.md](message-source.md#委派任务的内部协调)。 +原始输入隐藏,但伙伴处理后的进度与结果按普通助手消息显示、保存,最终答复后折叠过程。 +本轮结束不等待后台任务完成,也不阻止用户继续发消息;运行中排队/插话、结束后开始新轮的规则不变。 +正常完成结果、必要交互与错误继续保留;普通任务来信和明确 `user-visible` 的输入显示规则不变。 `send_to_agent` 是另一条更轻的伙伴间通讯通道,不得伪装成任务: diff --git a/docs/product-rules/message-source.md b/docs/product-rules/message-source.md index 3909fe7c8a3..11e6e788f70 100644 --- a/docs/product-rules/message-source.md +++ b/docs/product-rules/message-source.md @@ -108,13 +108,18 @@ Agent 用 `send_to_session` 把消息发给另一台电脑上的既有任务时 校验失败沿正常错误/重试路径处理,不能丢弃后假报送达。用途回执不接受 renderer/device-link 自报, 也不为工具执行提供新授权;共享访客投影移除回执里的私有标识。 -宿主只对该协调输入所启动的文字答复与思考做静默投影,成功结束不产生通知/未读; -工具审计、交互、权限请求、错误和宿主恢复说明保留。需要用户决定时使用交互工具; -给用户的正文结果通过明确 `user-visible` 的发送提供。用户插话接管后的答复恢复可见。 +**2026-10-25:内部输入与对用户的回复分开。** 原始协调输入继续隐藏,伙伴处理后的进度与结果 +直接走普通助手消息的落库、广播和历史读取;不再丢弃整轮文字、思考或清空结束事件的答复。 +有重要进展就直接说明,没有值得汇报的信息可以不发言;需要用户决定时使用交互工具。 +不要求伙伴通过 `send_to_session` 给自己发消息来汇报,也不扩大任务写工具的权限。 +协调轮次成功结束的通知静默规则保持原样;通知静默不等于隐藏聊天记录。 +进度运行中可见,最终答复后沿用普通任务折叠规则;工具审计、交互、权限请求、错误和恢复说明保留。 委派正常完成仍走原本独立的结果记录和隐藏 completion 唤醒,不改成内部协调。 -无需数据库迁移。旧消息不重新分类;新版宿主产生的隐藏输入和静默输出可由现有桌面/手机读取。 -旧宿主不会因新客户端自行获得协调用途识别能力;降级不承诺继续执行新队列的静默语义。 +本轮结束仅释放当前任务的输入通道,不代表委派工作或用户目标已完成;后台任务继续运行时, +用户仍可给主任务发送下一条消息。主任务运行中沿用原有排队/插话,结束后正常开始下一轮。 +无需数据库迁移或新协议。旧消息不重新分类,也不补造此前已被过滤的正文;新消息使用已有 +桌面/手机消息通道。旧宿主仍会过滤协调回复,需更新宿主才能恢复该路径的进度显示。 ## 对话使用的模型 diff --git a/packages/lizi-mcps/src/xdt-helper/get_task_digest.ts b/packages/lizi-mcps/src/xdt-helper/get_task_digest.ts index 80f1ce05ac7..7c50b957487 100644 --- a/packages/lizi-mcps/src/xdt-helper/get_task_digest.ts +++ b/packages/lizi-mcps/src/xdt-helper/get_task_digest.ts @@ -108,12 +108,13 @@ export function boundTaskDigest(raw: TaskDigestWire): TaskDigestWire { } const DESCRIPTION = [ - '读一件任务的短摘要(≤2KB):状态 phase、已持续分钟数、主人最后的要求、助手最后一句、待回答的问题、错误、当前模型路线、', + '读一件任务的短摘要(≤2KB):状态 phase、已持续分钟数、最近用户消息(排除隐藏协调/续跑输入)、最新助手回复片段、待回答的问题、错误、当前模型路线、', '建议换到的下一条路线(只在出错或撞用量上限、换模型确实有用且装得下当前上下文时,给你候选链里还没试过的那条;否则 null,此时提醒主人)、PR 状态、排队消息数、谁开的 / 谁在跟。', 'idle_minutes 距最后一次动静的分钟数;tool_in_flight 为 true 表示有工具调用(如长时间构建、测试)正在执行,宿主不知道时不出现。', '被程序叫醒跟进任务时先读这一份再决定;要看原文再用 get_chat_history。', '作用范围:本机任务直接传 id;另一台电脑上的任务传 deviceId::sessionId(只读,对方须在线)。', - 'phase:running 运行中 / waiting-permission 等权限 / waiting-answer 等回答 / errored 出错 / usage-limit 撞用量上限 / finished 刚做完 / idle 空闲。', + 'phase:running 运行中 / waiting-permission 等权限 / waiting-answer 等回答 / errored 出错 / usage-limit 撞用量上限 / finished 本轮已结束 / idle 空闲。', + 'finished 只描述所查任务的本轮,不代表它委派的后台任务或用户目标已完成。下一条消息的派发仍由该任务的实时运行状态和输入队列决定。', 'suggested_route 只供判断,不是换模型授权。获得目标和变更的明确授权后才用 set_session_runtime(expected_generation 填 runtime_generation;只支持本机任务)。', ].join('\n'); diff --git a/packages/lizi-mcps/src/xdt-helper/send_to_session.ts b/packages/lizi-mcps/src/xdt-helper/send_to_session.ts index 310946f1fac..35e6e6e82c6 100644 --- a/packages/lizi-mcps/src/xdt-helper/send_to_session.ts +++ b/packages/lizi-mcps/src/xdt-helper/send_to_session.ts @@ -191,7 +191,7 @@ export function registerSendToSessionTool( "要发送的消息。伙伴代用户下达任务要求时,宿主另附可核实的用户原话;只补充必要上下文,保留原意和范围,不复述授权、不要求对方回传。普通任务与自动化消息按正文发送。", ), message_purpose: z.enum(["coordination", "user-visible"]).optional().describe( - "委派子任务发给发起伙伴默认 coordination:送达模型但内部文字不进正文或通知。user-visible 用于用户要求的结果、需要用户介入的消息。其他普通任务默认保持可见;coordination 仅限宿主核实的委派关系。正常完成自动回传,无需重发。", + "委派子任务发给发起伙伴默认 coordination:原始来信隐藏,伙伴处理后的进度和结果可直接正常回复。user-visible 让来信本身可见。其他普通任务默认保持可见;coordination 仅限宿主核实的委派关系,不改变操作权限。正常完成自动回传,无需重发。", ), title: z .string() From 4a852f3591396986271a5979b6c24b4bdd46e7f4 Mon Sep 17 00:00:00 2001 From: Dash <125997726+dashhuang@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:13:52 +1300 Subject: [PATCH 2/2] =?UTF-8?q?fix(companion):=20=E4=B8=BA=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=91=98=E8=A6=81=E4=BF=9D=E7=95=99=E9=95=BF=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E9=A6=96=E5=B0=BE=E7=BB=93=E8=AE=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dash <125997726+dashhuang@users.noreply.github.com> --- docs/product-rules/agent-user-parity.md | 1 + .../src/__tests__/getTaskDigestTool.test.ts | 25 +++++++++++++++++-- .../src/xdt-helper/get_task_digest.ts | 13 ++++++---- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/docs/product-rules/agent-user-parity.md b/docs/product-rules/agent-user-parity.md index 99af1d68bd7..cfd55cfc6c9 100644 --- a/docs/product-rules/agent-user-parity.md +++ b/docs/product-rules/agent-user-parity.md @@ -92,6 +92,7 @@ Worker 工具省略 working_dir 时保持继承 Lead 任务记录目录,不跟 `apps/desktop/src/main/mcp-integrations/taskDigest.ts` 与 `taskDigestHost.ts`(跨电脑只读,复用 `local-db:sessions:get`、`maker:list-active`、历史消息与 PR 状态通道)。 本机与跨电脑均排除带隐藏触发标记的协调/续跑输入,保留最新助手回复全文供工具按预算截短; + 长回复保留首尾片段,总预算进一步收紧时仍保留首尾,避免丢掉末尾结果或阻塞说明。 不用内部回报替代用户要求,也不只取助手回复最后一行。`finished` 只表示所查任务本轮结束, 不证明委派工作或用户目标完成;不参与输入放行判定,下一条消息仍按实时运行状态和队列派发。 - 伙伴跟进任务:`apps/desktop/src/main/maker-ipc/botWorkbenchAccess.ts`(授权:自动跟进所有任务的伙伴 diff --git a/packages/lizi-mcps/src/__tests__/getTaskDigestTool.test.ts b/packages/lizi-mcps/src/__tests__/getTaskDigestTool.test.ts index 3c237c5ccd8..73d5fee30c2 100644 --- a/packages/lizi-mcps/src/__tests__/getTaskDigestTool.test.ts +++ b/packages/lizi-mcps/src/__tests__/getTaskDigestTool.test.ts @@ -2,8 +2,8 @@ * 测试价值 * - 生产入口与可观察结果:get_task_digest 经 registry.call 返回给模型的 JSON(字段、大小、错误码)。 * - 规则依据:被程序叫醒的伙伴先读 ≤2KB 摘要再决定;每段文字 ≤300 字;只读调用方可读范围内的任务。 - * - 现有覆盖缺口:新工具。 - * - 最小失败输入:每段文字都是长中文(UTF-8 三字节)的摘要;受限历史范围里请求范围外的任务。 + * - 现有覆盖缺口:长助手回复只截开头,丢掉末尾结果;总预算收紧时也须保留结论。 + * - 最小失败输入:超过 300 字、末尾才写失败结论的回复,分别带有/不带其他长字段;受限范围外任务。 * - tier:unit(纯工具层,宿主回调 mock)。 */ import { describe, expect, it, vi } from 'vitest'; @@ -73,6 +73,27 @@ describe('get_task_digest', () => { expect(boundTaskDigest(short)).toEqual(short); }); + it.each([false, true])('retains the opening and final blocker of a long reply (budget pressure=%s)', async (pressure) => { + const opening = '构建进展:'; + const conclusion = '最终结果:部署失败,需要回滚。'; + const raw = { + ...digest, + title: '部署结果', + last_user_ask: pressure ? longText : '检查部署结果', + last_assistant_line: `${opening}${'正在检查日志与测试结果。'.repeat(80)}\n${conclusion}`, + pending_question: pressure ? longText : null, + error: pressure ? longText : null, + pr_status: pressure ? longText : null, + }; + const reg = setup({ taskDigest: async () => ({ ok: true as const, digest: raw }) }); + const { text, json } = parse(await reg.call('get_task_digest', { session_id: raw.session_id })); + expect(new TextEncoder().encode(text).length).toBeLessThanOrEqual(TASK_DIGEST_MAX_BYTES); + const reply = String(json.last_assistant_line); + expect(reply.startsWith(opening)).toBe(true); + expect(reply.endsWith(conclusion)).toBe(true); + expect(Array.from(reply).length).toBeLessThanOrEqual(pressure ? 299 : 300); + }); + it('refuses a task outside a scoped caller\'s readable history and passes host errors through', async () => { const taskDigest = vi.fn(async () => ({ ok: false as const, errorCode: 'NOT_FOUND' as const, message: 'gone' })); const scoped = setup({ diff --git a/packages/lizi-mcps/src/xdt-helper/get_task_digest.ts b/packages/lizi-mcps/src/xdt-helper/get_task_digest.ts index 7c50b957487..bfb09c75e08 100644 --- a/packages/lizi-mcps/src/xdt-helper/get_task_digest.ts +++ b/packages/lizi-mcps/src/xdt-helper/get_task_digest.ts @@ -68,12 +68,15 @@ export const TASK_DIGEST_TEXT_MAX = 300; const TITLE_MAX = 120; const SHRINKABLE = ['last_assistant_line', 'last_user_ask', 'pending_question', 'error', 'pr_status'] as const; -function clip(value: string | null | undefined, max: number): string | null { +function clip(value: string | null | undefined, max: number, keepEnd = false): string | null { if (typeof value !== 'string') return null; const text = value.replace(/\s+/g, ' ').trim(); if (!text) return null; const chars = Array.from(text); - return chars.length <= max ? text : `${chars.slice(0, max - 1).join('')}…`; + if (chars.length <= max) return text; + if (!keepEnd) return `${chars.slice(0, max - 1).join('')}…`; + const head = Math.floor((max - 1) / 2); + return `${chars.slice(0, head).join('')}…${chars.slice(chars.length - (max - 1 - head)).join('')}`; } const encoder = new TextEncoder(); @@ -86,13 +89,13 @@ export function boundTaskDigest(raw: TaskDigestWire): TaskDigestWire { ...raw, title: clip(raw.title, TITLE_MAX) ?? '', last_user_ask: clip(raw.last_user_ask, TASK_DIGEST_TEXT_MAX), - last_assistant_line: clip(raw.last_assistant_line, TASK_DIGEST_TEXT_MAX), + last_assistant_line: clip(raw.last_assistant_line, TASK_DIGEST_TEXT_MAX, true), pending_question: clip(raw.pending_question, TASK_DIGEST_TEXT_MAX), error: clip(raw.error, TASK_DIGEST_TEXT_MAX), pr_status: clip(raw.pr_status, TASK_DIGEST_TEXT_MAX), }; for (let max = TASK_DIGEST_TEXT_MAX; payloadBytes(digest) > TASK_DIGEST_MAX_BYTES && max >= 20; max = Math.floor(max / 2)) { - for (const key of SHRINKABLE) digest[key] = clip(digest[key], max); + for (const key of SHRINKABLE) digest[key] = clip(digest[key], max, key === 'last_assistant_line'); digest.title = clip(digest.title, Math.min(TITLE_MAX, max)) ?? ''; } // 极端情况(超长 id / 模型名):去掉可选文字,保证合同成立。 @@ -108,7 +111,7 @@ export function boundTaskDigest(raw: TaskDigestWire): TaskDigestWire { } const DESCRIPTION = [ - '读一件任务的短摘要(≤2KB):状态 phase、已持续分钟数、最近用户消息(排除隐藏协调/续跑输入)、最新助手回复片段、待回答的问题、错误、当前模型路线、', + '读一件任务的短摘要(≤2KB):状态 phase、已持续分钟数、最近用户消息(排除隐藏协调/续跑输入)、最新助手回复首尾片段、待回答的问题、错误、当前模型路线、', '建议换到的下一条路线(只在出错或撞用量上限、换模型确实有用且装得下当前上下文时,给你候选链里还没试过的那条;否则 null,此时提醒主人)、PR 状态、排队消息数、谁开的 / 谁在跟。', 'idle_minutes 距最后一次动静的分钟数;tool_in_flight 为 true 表示有工具调用(如长时间构建、测试)正在执行,宿主不知道时不出现。', '被程序叫醒跟进任务时先读这一份再决定;要看原文再用 get_chat_history。',