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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### 修复与改进

- Task 结果自动播报默认限定当前会话,避免同一 owner 的新会话自动领取并播报旧会话结果;保留同会话重连恢复,并明确共享设备的身份隔离边界(#549)。

- 后台可用性缓存对「不可达」结果也按 TTL 后台重探,后台恢复后的首个派活回执不再被过期结果拒绝。

## 2.0.1
Expand Down
9 changes: 9 additions & 0 deletions docs/gateway-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ creation, and device administration, are outside the interactive GCP Session.
They authenticate independently and never claim or replace the active Client
lease.

Automatic Task-result announcements are scoped to the current owner and session.
Reconnecting to the same session can resume its pending notifications; opening a
different session does not announce older-session results. Trusted host code may
explicitly opt into cross-session notification claims after verifying the recipient.
Personal identity mode shares one owner and assumes a single trusted user; session
IDs are not an authentication boundary. Shared-device or telephone integrations must
use separate authenticated owners for independent users and avoid reusing a
conversation session across callers.

## 3. Connection and negotiation

The Client connects to `ws://<gateway>/api/realtime`. The first message is `session.hello`.
Expand Down
6 changes: 6 additions & 0 deletions docs/gateway-protocol.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ HTTPS 换取 Token。浏览器扫码页仅把 fragment 中的 Token 换成 HttpO
端点发布、设备连接码签发与设备管理等 Host 管理请求不属于交互式 GCP Session。
它们独立完成认证,也不会取得或替换活动 Client 租约。

Task 结果自动播报限定在当前 owner 和 session 内。同一 session 重连可恢复其待播报
通知;新 session 不会自动播报旧 session 的结果。可信宿主代码只有在确认接收人一致后,
才应显式开启跨 session 通知领取。个人身份模式共享一个 owner,假设只有一位可信用户;
session ID 不是身份认证边界。共享设备或电话接入应为独立用户配置不同的已认证 owner,
并避免在不同来电用户之间复用同一个会话 session。

## 3. 连接与能力协商

Client 连接 `ws://<gateway>/api/realtime`,第一条消息必须是 `session.hello`。
Expand Down
4 changes: 3 additions & 1 deletion server/src/orchestration/session-task-coordinator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@ export class SessionTaskCoordinator {
this.announcePendingPermissions()
}

claimPendingNotifications(taskIds, { includeOtherSessions = !taskIds?.length } = {}) {
// Reconnect/wake must not replay another conversation's results. A trusted
// host can explicitly opt in when it knows both sessions share a recipient.
claimPendingNotifications(taskIds, { includeOtherSessions = false } = {}) {
if (!this.canDeliver()) return
const claimed = this.taskManager.claimNotifications({
ownerId: this.ownerId,
Expand Down
37 changes: 34 additions & 3 deletions server/test/realtime-session-runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ async function until(predicate) {
}
}

function harness(t, { backend = true, ...overrides } = {}) {
const ownerId = 'runtime-owner', sessionId = 'conversation'
function harness(t, {
backend = true, ownerId = 'runtime-owner', sessionId = 'conversation',
taskManager = new TaskManager(), ...overrides
} = {}) {
const events = [], frontends = [], tasks = [], observations = [], done = []
const runs = new Map(), memoryListeners = new Set()
const manager = new TaskManager()
const manager = taskManager
const conversationSync = new ConversationSync()
const logger = { info() {}, warn() {}, error() {}, debug() {} }
const provider = {
Expand Down Expand Up @@ -424,6 +426,35 @@ test('rejected restoration quarantines only replay context and stops repeated re
assert.ok(h.events.some(e => e.message?.includes('自动恢复已停止')))
})

for (const completesAfterClose of [true, false]) {
test(`new session does not announce previous session results (completes after close: ${completesAfterClose})`, async t => {
const a = harness(t, { ownerId: 'user_personal', sessionId: 'call-A' })
const frontendA = await a.connect()
const task = a.request('private task for A')
await until(() => a.runs.has(task.id))
if (completesAfterClose) a.runtime.close()
a.runs.get(task.id).resolve({ content: 'private result for A' })
await a.manager.wait(task.id)
if (!completesAfterClose) {
await until(() => frontendA.deliveries.some(delivery => delivery.origin === 'announcement'))
assert.equal(a.manager.get(task.id).notificationStatus, 'delivering')
a.runtime.close()
}
assert.equal(a.manager.get(task.id).notificationStatus, 'pending')
const b = harness(t, { ownerId: 'user_personal', sessionId: 'call-B', taskManager: a.manager })
const frontendB = await b.connect()
// Allow the zero-delay announcement batch timer to run if onReady claimed it.
await new Promise(resolve => setTimeout(resolve, 20))
assert.equal(frontendB.deliveries.length, 0)
assert.equal(a.manager.get(task.id).notificationStatus, 'pending')
b.runtime.close()
const resumed = harness(t, { ownerId: 'user_personal', sessionId: 'call-A', taskManager: a.manager })
const frontendResumed = await resumed.connect()
await until(() => frontendResumed.deliveries.some(delivery => delivery.origin === 'announcement'))
assert.match(frontendResumed.deliveries[0].text, /private result for A/u)
})
}

test('input state separates client mute, host suspension and deactivation', async t => {
const h = harness(t, { backend: false })
await h.connect()
Expand Down
42 changes: 40 additions & 2 deletions server/test/session-task-coordinator.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -265,15 +265,15 @@ test('output-disabled sessions leave results claimable and never cancel accepted
run.complete({ content: 'after disconnection' })
await h.taskManager.wait(run.task.id)
assert.equal(h.taskManager.get(run.task.id).notificationStatus, 'pending')
const next = harness(t, { taskManager: h.taskManager, sessionId: 'new-session' })
const next = harness(t, { taskManager: h.taskManager })
next.state.outputEnabled = false
next.coordinator.claimPendingNotifications()
await flush()
assert.equal(next.injectCalls.length, 0)
next.state.outputEnabled = true
next.coordinator.claimPendingNotifications()
await flush()
assert.equal(next.injectCalls.length, 1, 'restoration may claim the same owner’s older-session result')
assert.equal(next.injectCalls.length, 1, 'reconnecting to the same session restores its result')
})

test('explicit cancellation clears queued progress and never announces a completion', async t => {
Expand Down Expand Up @@ -325,3 +325,41 @@ test('user Task kinds share result delivery while system jobs stay outside the f
assert.equal(h.injectCalls.length, 3)
assert.equal(h.events.length, count)
})

test('default notification claims exclude other sessions even with an explicit task ID', async t => {
const a = harness(t, { sessionId: 'call-A' })
const run = await startTask(a)
a.coordinator.close()
run.complete({ content: 'private result for A' })
await a.taskManager.wait(run.task.id)
const b = harness(t, { taskManager: a.taskManager, sessionId: 'call-B' })
b.coordinator.claimPendingNotifications()
b.coordinator.claimPendingNotifications([run.task.id])
await flush()
assert.equal(b.injectCalls.length, 0)
assert.equal(b.records.length, 0)
assert.equal(a.taskManager.get(run.task.id).notificationStatus, 'pending')
const resumed = harness(t, { taskManager: a.taskManager, sessionId: 'call-A' })
resumed.coordinator.claimPendingNotifications()
await flush()
assert.equal(resumed.injectCalls.length, 1)
assert.match(resumed.injectCalls[0].text, /private result for A/u)
})

test('explicit cross-session opt-in still enforces owner isolation', async t => {
const a = harness(t, { sessionId: 'call-A' })
const run = await startTask(a)
a.coordinator.close()
run.complete({ content: 'result for the same recipient' })
await a.taskManager.wait(run.task.id)
const foreign = harness(t, { taskManager: a.taskManager, ownerId: 'other-owner', sessionId: 'call-B' })
foreign.coordinator.claimPendingNotifications(undefined, { includeOtherSessions: true })
await flush()
assert.equal(foreign.injectCalls.length, 0)
assert.equal(a.taskManager.get(run.task.id).notificationStatus, 'pending')
const b = harness(t, { taskManager: a.taskManager, sessionId: 'call-B' })
b.coordinator.claimPendingNotifications(undefined, { includeOtherSessions: true })
await flush()
assert.equal(b.injectCalls.length, 1)
assert.match(b.injectCalls[0].text, /result for the same recipient/u)
})
Loading