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
19 changes: 18 additions & 1 deletion src/core/worker-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12032,7 +12032,24 @@ function deliverFinalOutput(
}
: undefined;
let visibleAssistantText = msg.content;
if (!imOrigin
// A listener-chat @mention is a direct human IM turn. Its normal sink is
// already plain text, but an existing receiver context may have taught
// the model the automatic delivery's decision envelope. Do not expose
// that internal protocol to the human; recover its user-visible content
// when the stale envelope is valid. (The trusted per-turn instruction
// added by the daemon prevents new turns from producing it.)
if (imOrigin) {
const directImControlledOutput = parseVcMeetingListenerOutput(msg.content);
if (directImControlledOutput.ok) {
visibleAssistantText = directImControlledOutput.decision === 'publish'
? directImControlledOutput.content
: '本次没有生成可展示的答复,请重新提问。';
logger.warn(
`[${t}] VC listener IM reply recovered stale control envelope `
+ `turn=${msg.turnId.substring(0, 8)} decision=${directImControlledOutput.decision}`,
);
}
} else if (!imOrigin
&& listenerOutputOwner
&& msg.dispatchAttempt !== undefined
&& listenerOutputProtocol === 'decision_v1') {
Expand Down
6 changes: 5 additions & 1 deletion src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,7 @@ import {
type VcMeetingImRoutingCandidate,
type VcMeetingSealedReceiverSessionBinding,
} from './services/vc-meeting-im-routing.js';
import { VC_MEETING_HUMAN_IM_OUTPUT_CONTRACT } from './services/vc-meeting-listener-output-protocol.js';

// ─── State ───────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -17097,7 +17098,10 @@ async function startInitialPassthroughSession(args: {


function vcMeetingApplicationContext(ctx: RoutingContext): string {
return (ctx.vcMeetingContextLifecycle === 'sealed'
return (ctx.vcMeetingImTurnOrigin
? VC_MEETING_HUMAN_IM_OUTPUT_CONTRACT
: '')
+ (ctx.vcMeetingContextLifecycle === 'sealed'
? '[会议上下文状态] 本轮正在复用一场已结束会议的专属会话;这是会后追问。可以基于既有会议上下文回答,但不得声称会议仍在进行,也不要尝试会中文本或语音动作。\n'
: '')
+ (ctx.vcMeetingContextMayLag
Expand Down
14 changes: 14 additions & 0 deletions src/services/vc-meeting-listener-output-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ export const VC_MEETING_LISTENER_OUTPUT_CONTRACT =
+ 'A correction to previously stated information (including time, owner, scope, status, or conclusion) '
+ 'is new information and must not be suppressed merely because most surrounding text is unchanged.';

/**
* Trusted per-turn override for a human question sent in the listener chat.
*
* Automatic meeting deliveries and listener-chat questions share one durable
* receiver session so the latter can use the meeting context. The automatic
* delivery protocol above must therefore be explicitly disabled for an IM
* follow-up; otherwise a model can carry its previous JSON transport contract
* into a normal human-facing answer.
*/
export const VC_MEETING_HUMAN_IM_OUTPUT_CONTRACT =
'[Meeting listener-chat direct question] This turn is a direct human question, not an automatic meeting delivery. '
+ 'Answer the person in natural-language Markdown only. Do not return, quote, or explain '
+ 'the internal {"decision":"skip"} / {"decision":"publish","content":"..."} control JSON.\n';

export const VC_MEETING_CONTROLLED_OUTPUT_INSTRUCTION_VERSION = 'meeting-consumer-v2' as const;

/** Frozen on the delivery receipt so a pre-upgrade v1 retry may still emit its
Expand Down
65 changes: 65 additions & 0 deletions test/bridge-final-output-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1596,6 +1596,71 @@ describe('Bridge final_output delivery (P2 retry)', () => {
expect(providerUuid).toBeTruthy();
});

it('unwraps a stale automatic meeting envelope before replying to a human listener-chat question', async () => {
const sessionReply = vi.fn(async () => 'om_vc_direct_reply');
initWorkerPool({
sessionReply,
getSessionWorkingDir: () => '/tmp',
getActiveCount: () => 1,
closeSession: vi.fn(),
});
const ds = makeDs();
ds.scope = 'chat';
ds.session.scope = 'chat';
ds.session.vcMeetingReceiver = {
listenerAppId: 'listener-app', meetingId: 'meeting-im-envelope',
memberId: 'member-im-envelope', memberEpoch: 1,
};
const origin = {
listenerAppId: 'listener-app', meetingId: 'meeting-im-envelope', memberId: 'member-im-envelope',
memberEpoch: 1, agentAppId: 'app_test', ownerBootId: 'owner-boot', ownerEpoch: 1,
membershipGeneration: 1, sinkOwnerGeneration: 1,
receiverSessionId: ds.session.sessionId, larkMessageId: 'om_human_envelope',
};
expect(applyVcMeetingMemberProjection('/tmp/test-sessions', {
listenerAppId: origin.listenerAppId,
meetingId: origin.meetingId,
memberId: origin.memberId,
memberEpoch: origin.memberEpoch,
agentAppId: origin.agentAppId,
ownerBootId: origin.ownerBootId,
ownerEpoch: origin.ownerEpoch,
role: 'minutes',
membershipGeneration: origin.membershipGeneration,
status: 'active',
responseMode: 'silent',
capabilities: ['meeting.read'],
ownedSinks: [],
sinkOwnerGeneration: origin.sinkOwnerGeneration,
joinedAtIngestSeq: 0,
receiverSessionId: origin.receiverSessionId,
outputChatId: ds.chatId,
})).toMatchObject({ ok: true });
ds.session.vcMeetingImTurnOrigins = { om_human_envelope: origin };
const msg = {
...finalOutputMsg(),
content: JSON.stringify({
decision: 'publish',
content: '请根据当前已启用的能力处理会议相关请求。',
}),
turnId: 'om_human_envelope',
lastUuid: 'bridge-human-envelope',
};
const { __testOnly_deliverFinalOutput } = await import('../src/core/worker-pool.js') as any;

__testOnly_deliverFinalOutput(ds, msg, 'tag', 0);
await vi.advanceTimersByTimeAsync(10);

expect(sessionReply).toHaveBeenCalledTimes(1);
const cardJson = sessionReply.mock.calls[0][1] as string;
expect(cardJson).toContain('请根据当前已启用的能力处理会议相关请求。');
expect(cardJson).not.toContain('"decision"');
expect(cardJson).not.toContain('publish');
expect(sessionReply.mock.calls[0][5]).toMatchObject({
quoteMessageId: 'om_human_envelope',
});
});

it('blocks the plain fallback when VC IM authority expires during a withdrawn quote request', async () => {
let plainFallbackCalls = 0;
const sessionReply = vi.fn(async (...args: any[]) => {
Expand Down
6 changes: 6 additions & 0 deletions test/vc-meeting-listener-output-protocol.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
parseVcMeetingListenerOutput,
VC_MEETING_HUMAN_IM_OUTPUT_CONTRACT,
VC_MEETING_LISTENER_OUTPUT_CONTRACT,
vcMeetingListenerOutputProtocolForInstructionVersion,
} from '../src/services/vc-meeting-listener-output-protocol.js';
Expand Down Expand Up @@ -46,6 +47,11 @@ describe('VC meeting listener output protocol', () => {
expect(VC_MEETING_LISTENER_OUTPUT_CONTRACT).not.toContain('debounce');
});

it('keeps direct listener-chat questions out of the automatic JSON transport protocol', () => {
expect(VC_MEETING_HUMAN_IM_OUTPUT_CONTRACT).toContain('direct human question');
expect(VC_MEETING_HUMAN_IM_OUTPUT_CONTRACT).toContain('natural-language Markdown only');
});

it('keeps pre-upgrade deliveries on plain output while enabling the v2 contract', () => {
expect(vcMeetingListenerOutputProtocolForInstructionVersion('meeting-consumer-v1')).toBe('plain');
expect(vcMeetingListenerOutputProtocolForInstructionVersion('meeting-consumer-v2')).toBe('decision_v1');
Expand Down