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 @@ -20,6 +20,8 @@ const EMPTY_MESSAGES: readonly VoiceChatMessage[] = [];
// 共享连接的握手超时(AuthenticatedWebSocketClient 内部固定 5s)已经不归这里管;
// 这个只是给定位单独留的预算,拿不到就不带,不能让 connect() 本身被定位拖住。
const LOCATION_TIMEOUT_MS = 2000;
/** 每个按住说话的 turn 一个自增 id,随 voice.stream.start 发出、由服务端回显。 */
let turnRequestIdCounter = 0;

/**
* 一次"按住说话"编排的真实实现,按 AGENTS.md 第 6 节的时序把 transport / capture /
Expand All @@ -44,6 +46,8 @@ export class AssistantConversationService implements AssistantApplicationPort {
private unsubscribeConnection: (() => void) | null = null;
/** voice.dialogue.reply 的流式文字,展示层拿来当气泡内容;新一轮开始时清空。 */
private replyText: string | null = null;
/** 当前 turn 的 request_id;voice.dialogue.reply 拿它认轮次,对不上的是上一轮迟到的。 */
private turnRequestId: string | null = null;
/** 当前这一帧麦克风音量(dBFS),给波形展示;不在录音时是 null。 */
private soundLevel: number | null = null;
// 展示层的 onPressIn/onPressOut 不等待彼此:快速按放会让 endTurn() 在
Expand Down Expand Up @@ -132,7 +136,12 @@ export class AssistantConversationService implements AssistantApplicationPort {
this.streamStartedWaiter = resolve;
this.streamStartRejecter = reject;
});
// 紧挨着 send 换 id:connect() 和权限框都可能停留好几秒,提前换掉的话这段窗口
// 里线上跑的还是上一轮的流,它自己的 reply 会被当成"别人的"误丢。
const turnRequestId = `ptt-turn-${++turnRequestIdCounter}`;
this.turnRequestId = turnRequestId;
connection.send({
request_id: turnRequestId,
payload: {
audio_format: AUDIO_FORMAT,
channels: CHANNELS,
Expand Down Expand Up @@ -267,6 +276,12 @@ export class AssistantConversationService implements AssistantApplicationPort {
});
return;
case 'voice.dialogue.reply':
// 上一轮被打断时它的 reply 可能晚到,把已经点掉的旧气泡重新弹出来——气泡
// 是全屏点击层,会挡住语音条,用户得再点一次才能说话。只认当前这一轮的
// request_id;服务端把 voice.stream.start 上带的 id 回显在这条消息上。
if (!this.isCurrentTurnReply(message.request_id)) {
return;
}
this.replyText = message.payload.speech_text;
this.notifyListeners();
return;
Expand All @@ -290,6 +305,15 @@ export class AssistantConversationService implements AssistantApplicationPort {
}
}

/** 拿不到判断依据时一律放行:后端把缺省的 request_id 序列化成 null 而不是省略字段,
* 所以 null 和 undefined 都要当作"不知道是哪一轮",不能误伤。 */
private isCurrentTurnReply(requestId: string | null | undefined): boolean {
if (this.turnRequestId === null || requestId === null || requestId === undefined) {
return true;
}
return requestId === this.turnRequestId;
}

/** .catch(() => {}) 必须紧跟在同一条语句里同步接上——迟一拍再接(比如靠下一次
* queueCommandResult 调用里的第二个 then 参数兜底)这段窗口期这个被拒绝的
* promise 没有任何 handler,Node 的 unhandled rejection 检测在下一次调用到达前
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -610,4 +610,115 @@ describe('AssistantConversationService', () => {
expect(fake.unsubscribeCalls).toEqual({ audio: 1, close: 1, message: 1 });
expect(fake.closeCalls.count).toBe(1);
});

it('tags every voice.stream.start with a fresh per-turn request_id', async () => {
const fake = createFakeConnection();
const deps = createDeps({ connection: fake.connection });
const service = new AssistantConversationService({ accountId: 'acc_001' }, deps);

await completeStreamStart(fake, service.startTurn());
await completeStreamStart(fake, service.startTurn());

const starts = fake.sent.filter((message) => message.type === 'voice.stream.start');
expect(starts).toHaveLength(2);
expect(starts[0].request_id).toBeTruthy();
expect(starts[1].request_id).toBeTruthy();
expect(starts[1].request_id).not.toBe(starts[0].request_id);
});

it('drops a stale reply from the previous turn so it cannot repop the bubble', async () => {
// 上一轮被新一轮 voice.stream.start 打断时,execute 的 reply/tts 事件可能晚到;
// 不按 request_id 过滤的话,气泡被点掉后会被这条迟到回复重新弹回来,把输入条
// 整个挡住(AssistantVoiceOverlay 在有 replyText 时铺了一层全屏点击层)。
const fake = createFakeConnection();
const deps = createDeps({ connection: fake.connection });
const service = new AssistantConversationService({ accountId: 'acc_001' }, deps);

// turn 1:回复到了,用户点掉气泡。计数器是模块级的,用例之间会累加,所以
// 轮次 id 一律从实际发出的 voice.stream.start 上取,不写死字面量。
await completeStreamStart(fake, service.startTurn());
const turn1Id = fake.sent.filter((message) => message.type === 'voice.stream.start')[0]
.request_id;
fake.emitMessage({
conversation_id: 'conv_001',
request_id: turn1Id,
payload: { done: true, reply_id: 'reply_1', speech_text: '第一条回复' },
type: 'voice.dialogue.reply',
} as AssistantServerMessage);
await flushAsync();
await service.dismissReply();
expect(service.getReplyText()).toBeNull();

// turn 2:新的 stream.start 已经带上不同的 request_id(这里故意不先把新流
// started 发下去,复现旧事件在新一轮启动窗口里晚到的情形)。
const nextTurn = service.startTurn();
await flushAsync();
fake.emitMessage({
conversation_id: 'conv_001',
request_id: turn1Id,
payload: { done: true, reply_id: 'reply_1', speech_text: '第一条回复' },
type: 'voice.dialogue.reply',
} as AssistantServerMessage);
await flushAsync();
expect(service.getReplyText()).toBeNull();

// 新 turn 自己的回复到了才显示。
const starts = fake.sent.filter((message) => message.type === 'voice.stream.start');
const turn2Id = starts[1].request_id;
fake.emitMessage({
conversation_id: 'conv_001',
payload: { done: true, reply_id: 'reply_2', speech_text: '第二条回复' },
request_id: turn2Id,
type: 'voice.dialogue.reply',
} as AssistantServerMessage);
await flushAsync();
expect(service.getReplyText()).toBe('第二条回复');

await completeStreamStart(fake, nextTurn);
});

it('still shows a reply whose request_id is null', async () => {
// 后端 model_dump() 把缺省的 request_id 序列化成 null(不是省掉字段),所以
// null 必须当作"不知道是哪一轮"放行,否则回复永远显示不出来。
const fake = createFakeConnection();
const deps = createDeps({ connection: fake.connection });
const service = new AssistantConversationService({ accountId: 'acc_001' }, deps);

await completeStreamStart(fake, service.startTurn());
fake.emitMessage({
conversation_id: 'conv_001',
payload: { done: true, reply_id: 'reply_1', speech_text: '没带轮次的回复' },
request_id: null,
type: 'voice.dialogue.reply',
} as unknown as AssistantServerMessage);
await flushAsync();

expect(service.getReplyText()).toBe('没带轮次的回复');
});

it('resolves startTurn on a transport error that carries a stale request_id', async () => {
// 错误信封也带 request_id,而且可能是上一条流的(后端 voice_stream.py 的
// "A stream is already active" / "Audio frame is empty" 都会这样)。这条路径
// 是 startTurn() 唯一的解套机会,按轮次丢掉它会让按住说话永久卡死。
const fake = createFakeConnection();
const deps = createDeps({ connection: fake.connection });
const service = new AssistantConversationService({ accountId: 'acc_001' }, deps);

await completeStreamStart(fake, service.startTurn());
const staleId = fake.sent.filter((message) => message.type === 'voice.stream.start')[0]
.request_id;
const turn = service.startTurn();
await flushAsync();
fake.emitMessage({
error: { code: 'X', message: 'A stream is already active for this session' },
ok: false,
request_id: staleId,
} as AssistantServerMessage);

await expect(turn).resolves.toBeUndefined();
expect(service.getState()).toEqual({
message: 'A stream is already active for this session',
phase: 'error',
});
});
});
Loading