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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down Expand Up @@ -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}`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<AgentEvent['type']>(['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) => {');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/**
* 测试价值
* - 生产入口与可观察结果:boundTranscript / lastExchange 的输出(伙伴读到的摘录与任务摘要的两句)。
* - 规则依据:工作台摘录有界且去掉指令块;get_task_digest 取「主人最后的要求 / 助手最后一句」。
* - 现有覆盖缺口:放弃「项目」后本机转录扫描已删除(其测试随之删除);lastExchange 为新增。
* - 最小失败输入:末尾是空助手消息 / 指令块;多行助手回复。
* - 生产入口与可观察结果:boundTranscript / lastExchange 的摘录与任务摘要。
* - 规则依据:内部协调不是用户要求;最新助手回复保留重点,由工具层按预算截短。
* - 现有覆盖缺口:内部回报覆盖了用户要求;多行回复只保留最后一行而丢失结论。
* - 最小失败输入:用户要求之后出现隐藏协调输入;助手先说结论再说下一步。
* - tier:unit(纯函数,数据库模块 mock 掉,不加载)。
*/
import { describe, expect, it, vi } from 'vitest';
Expand All @@ -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: '<system-reminder>internal</system-reminder>再跑一遍测试' },
{ role: 'assistant', text: '跑完了。\n还有 2 个失败' },
{ role: 'user', text: '[UI_ACTION_TRIGGER]后台回报:正在修复失败项' },
{ role: 'assistant', text: '<system-reminder>only a hint</system-reminder>' },
])).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 });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(); }
});

Expand Down
14 changes: 9 additions & 5 deletions apps/desktop/src/main/maker-ipc/botWorkbenchTranscripts.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
/**
* 伙伴工作台与任务摘要的只读会话读取:有界摘录与「最后一问 / 最后一句」。
* 伙伴工作台与任务摘要的只读会话读取:有界摘录与最近用户消息 / 助手回复。
*
* 2026-10-17 放弃「项目」后,不再扫描本机 Claude Code / Codex / Pi 转录目录,也不再为项目
* 现算摘要;只读 Cindy 自己库里的任务,两条有界查询,输出统一去掉 `<system-reminder>` 之类的
* 指令块。不写库、不导入。
*/
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';
Expand All @@ -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 {
Expand Down Expand Up @@ -112,13 +113,16 @@ export function lastExchange(items: readonly Pick<WorkbenchTranscriptItem, 'role
for (let index = items.length - 1; index >= 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;
Comment thread
dashhuang marked this conversation as resolved.
Comment thread
dashhuang marked this conversation as resolved.
}
return { lastUserAsk, lastAssistantLine };
}

/** 本机任务的「最后一问 / 最后一句」:一条有界查询。 */
/** 本机任务的最近用户消息 / 助手回复:一条有界查询。 */
export async function readSessionLastExchange(sessionId: string) {
const { db, where } = await sessionMessageWhere(sessionId);
const rows = await db
Expand Down
12 changes: 0 additions & 12 deletions apps/desktop/src/main/maker-ipc/coordinationOutput.ts

This file was deleted.

6 changes: 1 addition & 5 deletions apps/desktop/src/main/maker-ipc/sessionEventPipeline.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/main/maker-ipc/sessionEventPreparation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -603,7 +604,6 @@ export function prepareSessionEvent(
attributedEvent,
botTaskResultInputIds,
groupLaneInputClientId,
taskCoordination,
broadcastEvent,
eventAgentMeta,
pendingContextSnapshot,
Expand Down
8 changes: 5 additions & 3 deletions apps/desktop/src/shared/botTaskCoordination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
4 changes: 4 additions & 0 deletions docs/product-rules/agent-user-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ 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)
Expand Down
Loading
Loading