virlen-remote 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +192 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol/answer.d.ts +48 -0
- package/dist/protocol/answer.d.ts.map +1 -0
- package/dist/protocol/answer.js +65 -0
- package/dist/protocol/answer.js.map +1 -0
- package/dist/protocol/api.d.ts +576 -0
- package/dist/protocol/api.d.ts.map +1 -0
- package/dist/protocol/api.js +38 -0
- package/dist/protocol/api.js.map +1 -0
- package/dist/protocol/endpoint.d.ts +76 -0
- package/dist/protocol/endpoint.d.ts.map +1 -0
- package/dist/protocol/endpoint.js +307 -0
- package/dist/protocol/endpoint.js.map +1 -0
- package/dist/protocol/errors.d.ts +32 -0
- package/dist/protocol/errors.d.ts.map +1 -0
- package/dist/protocol/errors.js +56 -0
- package/dist/protocol/errors.js.map +1 -0
- package/dist/protocol/frame.d.ts +51 -0
- package/dist/protocol/frame.d.ts.map +1 -0
- package/dist/protocol/frame.js +154 -0
- package/dist/protocol/frame.js.map +1 -0
- package/dist/protocol/hello.d.ts +73 -0
- package/dist/protocol/hello.d.ts.map +1 -0
- package/dist/protocol/hello.js +37 -0
- package/dist/protocol/hello.js.map +1 -0
- package/dist/protocol/host.d.ts +116 -0
- package/dist/protocol/host.d.ts.map +1 -0
- package/dist/protocol/host.js +52 -0
- package/dist/protocol/host.js.map +1 -0
- package/dist/protocol/identity.d.ts +107 -0
- package/dist/protocol/identity.d.ts.map +1 -0
- package/dist/protocol/identity.js +143 -0
- package/dist/protocol/identity.js.map +1 -0
- package/dist/protocol/ids.d.ts +2 -0
- package/dist/protocol/ids.d.ts.map +1 -0
- package/dist/protocol/ids.js +20 -0
- package/dist/protocol/ids.js.map +1 -0
- package/dist/protocol/pairing.d.ts +43 -0
- package/dist/protocol/pairing.d.ts.map +1 -0
- package/dist/protocol/pairing.js +78 -0
- package/dist/protocol/pairing.js.map +1 -0
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/mock-host.d.ts +66 -0
- package/dist/testing/mock-host.d.ts.map +1 -0
- package/dist/testing/mock-host.js +573 -0
- package/dist/testing/mock-host.js.map +1 -0
- package/dist/transport/broadcast.d.ts +26 -0
- package/dist/transport/broadcast.d.ts.map +1 -0
- package/dist/transport/broadcast.js +71 -0
- package/dist/transport/broadcast.js.map +1 -0
- package/dist/transport/ice.d.ts +130 -0
- package/dist/transport/ice.d.ts.map +1 -0
- package/dist/transport/ice.js +338 -0
- package/dist/transport/ice.js.map +1 -0
- package/dist/transport/memory.d.ts +52 -0
- package/dist/transport/memory.d.ts.map +1 -0
- package/dist/transport/memory.js +135 -0
- package/dist/transport/memory.js.map +1 -0
- package/dist/transport/rtc.d.ts +76 -0
- package/dist/transport/rtc.d.ts.map +1 -0
- package/dist/transport/rtc.js +310 -0
- package/dist/transport/rtc.js.map +1 -0
- package/dist/transport/signaling.d.ts +101 -0
- package/dist/transport/signaling.d.ts.map +1 -0
- package/dist/transport/signaling.js +235 -0
- package/dist/transport/signaling.js.map +1 -0
- package/dist/transport/types.d.ts +41 -0
- package/dist/transport/types.d.ts.map +1 -0
- package/dist/transport/types.js +11 -0
- package/dist/transport/types.js.map +1 -0
- package/package.json +69 -0
- package/src/index.ts +155 -0
- package/src/protocol/answer.ts +89 -0
- package/src/protocol/api.ts +517 -0
- package/src/protocol/endpoint.ts +405 -0
- package/src/protocol/errors.ts +90 -0
- package/src/protocol/frame.ts +196 -0
- package/src/protocol/hello.ts +112 -0
- package/src/protocol/host.ts +150 -0
- package/src/protocol/identity.ts +184 -0
- package/src/protocol/ids.ts +20 -0
- package/src/protocol/pairing.ts +95 -0
- package/src/testing/index.ts +5 -0
- package/src/testing/mock-host.ts +691 -0
- package/src/transport/broadcast.ts +81 -0
- package/src/transport/ice.ts +402 -0
- package/src/transport/memory.ts +164 -0
- package/src/transport/rtc.ts +344 -0
- package/src/transport/signaling.ts +317 -0
- package/src/transport/types.ts +50 -0
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock 宿主数据源 —— 供「浏览器 harness」与单测使用。
|
|
3
|
+
*
|
|
4
|
+
* 行为:`send` 立即回投递确认(`messageId`),随后**异步**推送
|
|
5
|
+
* `message.added`(用户消息)→ `runtime.changed`(working)→ 多次 `message.stream`
|
|
6
|
+
* → `message.added`(AI 完整消息)+ `runtime.changed`(空闲)。
|
|
7
|
+
*
|
|
8
|
+
* 流式帧的形状**由客户端在 `hello` 里声明**(`streamMode`,默认 `full`):`delta` 时只发新增后缀
|
|
9
|
+
* (带 `offset`),规则与真实电脑侧(`store-bridge.ts` 的 `pushStream`)**逐条对齐**:
|
|
10
|
+
* 消息首帧 / 正文被改写 / `final` 收尾帧都发**整段**,只有「新正文以已发出内容为前缀」时才发增量。
|
|
11
|
+
* 两边不一致的后果很具体:mock 宽松 = 用例对着假行为发绿灯(§24 的教训)。
|
|
12
|
+
*
|
|
13
|
+
* 这正是 §3.3「RPC 只做投递确认、过程走事件」的可运行样例。
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ **订阅门(§24,2026-09-29)**:消息 / 流式 / 运行时 / 占用这四类事件**只推已订阅的会话**
|
|
16
|
+
* (`host.event.session.list.changed` 与交互事件恒推)——与真实电脑侧(`store-bridge` 的
|
|
17
|
+
* `subscriptions.has(s.id)`)**同一条规则**。
|
|
18
|
+
*
|
|
19
|
+
* 为何必须一致:mock 过去无条件推送,于是「客户端忘了 `host.session.subscribe`」这类缺陷在
|
|
20
|
+
* 联调 / 单测里**完全看不见**,真机上才表现为「新建会话的标题更新了,但消息永远是空的」。
|
|
21
|
+
* mock 比真机宽松 = 测试对着假行为发绿灯,这条闸是故意的:宁可让用例早失败。
|
|
22
|
+
*/
|
|
23
|
+
import { BridgeError } from '../protocol/errors'
|
|
24
|
+
import { answerActionError, normalizeChoiceAnswer } from '../protocol/answer'
|
|
25
|
+
import { COMPRESS_MIN_RATIO, DEFAULT_CONTEXT_WINDOW_TOKENS } from '../protocol/api'
|
|
26
|
+
import type { HostDataSource, HostEmit } from '../protocol/host'
|
|
27
|
+
import type { HostEvents, StreamMode } from '../protocol/api'
|
|
28
|
+
import type {
|
|
29
|
+
AnswerParams,
|
|
30
|
+
AnswerResult,
|
|
31
|
+
ApprovalTier,
|
|
32
|
+
CompressParams,
|
|
33
|
+
ContextInfoDTO,
|
|
34
|
+
ContextParams,
|
|
35
|
+
CreateSessionParams,
|
|
36
|
+
DeleteSessionParams,
|
|
37
|
+
InteractionDTO,
|
|
38
|
+
MessageDTO,
|
|
39
|
+
ModelProviderDTO,
|
|
40
|
+
MsgPageDTO,
|
|
41
|
+
MsgPageParams,
|
|
42
|
+
PinSessionParams,
|
|
43
|
+
RenameSessionParams,
|
|
44
|
+
SendParams,
|
|
45
|
+
SessionSummaryDTO,
|
|
46
|
+
SetModelParams,
|
|
47
|
+
WorkspaceOptionDTO,
|
|
48
|
+
} from '../protocol/api'
|
|
49
|
+
import type { HelloParams, HelloResult } from '../protocol/hello'
|
|
50
|
+
|
|
51
|
+
export interface MockHostOptions {
|
|
52
|
+
/** 流式段数。 */
|
|
53
|
+
streamSteps?: number
|
|
54
|
+
/** 每段间隔(ms)。 */
|
|
55
|
+
streamDelayMs?: number
|
|
56
|
+
/**
|
|
57
|
+
* `demo-1` 的预置消息条数(默认 2)。
|
|
58
|
+
*
|
|
59
|
+
* 供分页验证 / harness 压测用:默认值下只有 2 条,永远触发不了「还有更早的消息」。
|
|
60
|
+
*/
|
|
61
|
+
demoMessageCount?: number
|
|
62
|
+
/**
|
|
63
|
+
* 给 `demo-1` 追加一条带工具名的工具消息(默认关:会改变默认消息条数,影响分页用例)。
|
|
64
|
+
*
|
|
65
|
+
* 打开后手机端能看到「工具 · list_files」气泡(harness 人工验证用)。
|
|
66
|
+
*/
|
|
67
|
+
demoToolMessage?: boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 构造演示用交互(供 harness / 单测触发手机端卡片)。 */
|
|
71
|
+
export interface MockInteractionSpec {
|
|
72
|
+
kind?: 'choice' | 'authorization'
|
|
73
|
+
tier?: ApprovalTier
|
|
74
|
+
sessionId?: string
|
|
75
|
+
question?: string
|
|
76
|
+
options?: string[]
|
|
77
|
+
multi?: boolean
|
|
78
|
+
permName?: string
|
|
79
|
+
title?: string
|
|
80
|
+
subTitle?: string
|
|
81
|
+
desc?: string
|
|
82
|
+
hint?: string
|
|
83
|
+
risk?: string
|
|
84
|
+
sandboxBypass?: boolean
|
|
85
|
+
presentation?: 'modal' | 'terminal'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface MockHostDataSource extends HostDataSource {
|
|
89
|
+
/** 由 harness / 测试在注册后回填(把 `HostEvents` 推给对端)。 */
|
|
90
|
+
bind(emit: HostEmit): void
|
|
91
|
+
/** 推一个待应答交互给手机(返回其 `interactionId`)。 */
|
|
92
|
+
triggerInteraction(spec?: MockInteractionSpec): string
|
|
93
|
+
/** 当前仍挂起的交互 id。 */
|
|
94
|
+
pendingInteractions(): string[]
|
|
95
|
+
/**
|
|
96
|
+
* 已收到的 RPC 方法名流水(M5)。
|
|
97
|
+
*
|
|
98
|
+
* 供单测断言「请求确实到达了电脑侧」—— 否则只能靠副作用反推,
|
|
99
|
+
* 而像 `cancel` 这种「无事发生」的方法就没有可观察的副作用。
|
|
100
|
+
*/
|
|
101
|
+
readonly calls: string[]
|
|
102
|
+
/** 手动推一次上下文占用变化(模拟电脑侧使用量增长 / 压缩),供手机端 UI 联调。 */
|
|
103
|
+
bumpContext(sessionId: string, tokens: number): void
|
|
104
|
+
/**
|
|
105
|
+
* 手动推一次「工具参数生成进度」(§27),供手机端 UI 联调 / 单测。
|
|
106
|
+
*
|
|
107
|
+
* 真实链路里由引擎的 `AgentEvent.tool_progress` 驱动 —— 那是 provider 在**累积 tool 参数**
|
|
108
|
+
* 期间唯一的事件(其余时间静默)。`progress = null` 表示清空(工具开始执行 / 本轮结束)。
|
|
109
|
+
*/
|
|
110
|
+
setToolProgress(sessionId: string, progress: { name: string; chars: number } | null): void
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 演示用模型服务(与 `sessions` 里的 provider 字段一致)。 */
|
|
114
|
+
const DEMO_PROVIDERS: ModelProviderDTO[] = [
|
|
115
|
+
{ id: 'p-openai', name: 'OpenAI(演示)', models: ['gpt-4o', 'gpt-4o-mini'] },
|
|
116
|
+
{ id: 'p-anthropic', name: 'Anthropic(演示)', models: ['claude-sonnet-4'] },
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
/** 演示用工作目录候选集(新建会话只能从这里选)。 */
|
|
120
|
+
const DEMO_WORKSPACES = ['E:/code/virlen-demo', 'E:/code/another-project']
|
|
121
|
+
|
|
122
|
+
export function createMockHostDataSource(options: MockHostOptions = {}): MockHostDataSource {
|
|
123
|
+
const streamSteps = options.streamSteps ?? 3
|
|
124
|
+
const streamDelayMs = options.streamDelayMs ?? 40
|
|
125
|
+
|
|
126
|
+
let emit: HostEmit | null = null
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 已订阅的会话(与真实电脑侧的 `SubscriptionRegistry` 同语义)。
|
|
130
|
+
*
|
|
131
|
+
* 未订阅的会话:只有会话列表(`session.list.changed`)与交互事件会推给对端 ——
|
|
132
|
+
* 消息 / 流式 / 运行时 / 占用一律不推(见文件头「订阅门」)。
|
|
133
|
+
*/
|
|
134
|
+
const subscribed = new Set<string>()
|
|
135
|
+
|
|
136
|
+
/** 会话维度的事件推送(受订阅门约束)。 */
|
|
137
|
+
function emitFor<K extends keyof HostEvents & string>(
|
|
138
|
+
sessionId: string,
|
|
139
|
+
topic: K,
|
|
140
|
+
payload: HostEvents[K],
|
|
141
|
+
): void {
|
|
142
|
+
if (!subscribed.has(sessionId)) return
|
|
143
|
+
emit?.(topic, payload)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** RPC 方法名流水(见 `MockHostDataSource.calls`)。 */
|
|
147
|
+
const calls: string[] = []
|
|
148
|
+
|
|
149
|
+
const sessions: SessionSummaryDTO[] = [
|
|
150
|
+
{
|
|
151
|
+
id: 'demo-1',
|
|
152
|
+
title: '演示:手机控制',
|
|
153
|
+
updatedAt: Date.now(),
|
|
154
|
+
working: false,
|
|
155
|
+
agentId: 'agent-virlen',
|
|
156
|
+
agentName: 'Virlen',
|
|
157
|
+
workspace: DEMO_WORKSPACES[0],
|
|
158
|
+
providerConfigId: 'p-openai',
|
|
159
|
+
providerName: 'OpenAI(演示)',
|
|
160
|
+
modelId: 'gpt-4o',
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
id: 'demo-2',
|
|
164
|
+
title: '空会话',
|
|
165
|
+
updatedAt: Date.now() - 60_000,
|
|
166
|
+
working: false,
|
|
167
|
+
agentId: 'agent-reviewer',
|
|
168
|
+
agentName: '代码评审员',
|
|
169
|
+
workspace: DEMO_WORKSPACES[1],
|
|
170
|
+
providerConfigId: 'p-anthropic',
|
|
171
|
+
providerName: 'Anthropic(演示)',
|
|
172
|
+
modelId: 'claude-sonnet-4',
|
|
173
|
+
},
|
|
174
|
+
]
|
|
175
|
+
const messages = new Map<string, MessageDTO[]>()
|
|
176
|
+
const demoCount = options.demoMessageCount ?? 2
|
|
177
|
+
messages.set(
|
|
178
|
+
'demo-1',
|
|
179
|
+
demoCount <= 2
|
|
180
|
+
? [
|
|
181
|
+
{ id: 'm1', role: 'user', text: '你好,帮我看下今天的安排', createdAt: Date.now() - 120_000 },
|
|
182
|
+
{ id: 'm2', role: 'assistant', text: '好的,我来看看。', createdAt: Date.now() - 110_000 },
|
|
183
|
+
]
|
|
184
|
+
: Array.from({ length: demoCount }, (_, i) => ({
|
|
185
|
+
id: `m${i}`,
|
|
186
|
+
role: i % 2 === 0 ? ('user' as const) : ('assistant' as const),
|
|
187
|
+
text: `演示消息 #${i}`,
|
|
188
|
+
createdAt: Date.now() - (demoCount - i) * 1000,
|
|
189
|
+
})),
|
|
190
|
+
)
|
|
191
|
+
messages.set('demo-2', [])
|
|
192
|
+
if (options.demoToolMessage) {
|
|
193
|
+
messages.get('demo-1')?.push(
|
|
194
|
+
{
|
|
195
|
+
id: 'tool-1',
|
|
196
|
+
role: 'tool',
|
|
197
|
+
text: 'src/index.ts\nsrc/store.ts\nsrc/ui/pages/Chat.tsx',
|
|
198
|
+
createdAt: Date.now() - 105_000,
|
|
199
|
+
toolName: 'list_files',
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: 'empty-assistant',
|
|
203
|
+
role: 'assistant',
|
|
204
|
+
text: '',
|
|
205
|
+
createdAt: Date.now() - 100_000,
|
|
206
|
+
},
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 上下文占用(模拟电脑侧口径:最后一条带 usage / contextTokens 的消息)。
|
|
212
|
+
*
|
|
213
|
+
* `demo-2` 特意给一个很低的占用 —— 让手机端「上下文充裕时不显示压缩」的分支可验证。
|
|
214
|
+
*/
|
|
215
|
+
const contextTokens = new Map<string, number>([
|
|
216
|
+
['demo-1', 120_000],
|
|
217
|
+
['demo-2', 1_000],
|
|
218
|
+
])
|
|
219
|
+
|
|
220
|
+
let counter = 0
|
|
221
|
+
const nextId = (prefix: string) => `${prefix}-${++counter}-${Date.now().toString(36)}`
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 待应答交互(模拟电脑侧的交互注册表)。
|
|
225
|
+
*/
|
|
226
|
+
const interactions = new Map<string, InteractionDTO>()
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 手机在 `hello` 里声明的流式偏好(§32)。默认 `full` —— 不声明就按整帧发,
|
|
230
|
+
* 与真实电脑侧同一条规则(旧客户端收到增量帧会把一帧当全文,正文就错位了)。
|
|
231
|
+
*/
|
|
232
|
+
let peerStreamMode: StreamMode = 'full'
|
|
233
|
+
|
|
234
|
+
/** 被「暂存」而暂停的会话(模拟电脑侧的 paused 运行态,供 resume 演示 / 测试)。 */
|
|
235
|
+
const pausedSessions = new Set<string>()
|
|
236
|
+
|
|
237
|
+
function emitList(): void {
|
|
238
|
+
emit?.('host.event.session.list.changed', { sessions: sessions.map((s) => ({ ...s })) })
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 发一帧流式正文(按客户端的 `streamMode` 声明决定整帧还是增量)。
|
|
243
|
+
*
|
|
244
|
+
* `sent` = 上一步**已发出**的正文;首帧传 `''` → 必然走 `full`(客户端还没有基准)。
|
|
245
|
+
* 与真实电脑侧一样:写改(非前缀增长)时回落整帧,而不是硬发一个错的增量。
|
|
246
|
+
*/
|
|
247
|
+
function emitStreamFrame(
|
|
248
|
+
sessionId: string,
|
|
249
|
+
messageId: string,
|
|
250
|
+
seq: number,
|
|
251
|
+
text: string,
|
|
252
|
+
sent: string,
|
|
253
|
+
): void {
|
|
254
|
+
const delta = peerStreamMode === 'delta' && sent !== '' && text.startsWith(sent)
|
|
255
|
+
emitFor(sessionId, 'host.event.message.stream', {
|
|
256
|
+
sessionId,
|
|
257
|
+
messageId,
|
|
258
|
+
seq,
|
|
259
|
+
mode: delta ? 'delta' : 'full',
|
|
260
|
+
text: delta ? text.slice(sent.length) : text,
|
|
261
|
+
...(delta ? { offset: sent.length } : {}),
|
|
262
|
+
final: false,
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function streamReply(sessionId: string, list: MessageDTO[]): Promise<void> {
|
|
267
|
+
const messageId = nextId('a')
|
|
268
|
+
let text = ''
|
|
269
|
+
// 模拟生成过程中的积压:内容真实增长 → 流式帧逐次变长(与真实电脑侧同规则)
|
|
270
|
+
list.push({ id: messageId, role: 'assistant', text: '', createdAt: Date.now() })
|
|
271
|
+
for (let i = 0; i < streamSteps; i++) {
|
|
272
|
+
await delay(streamDelayMs)
|
|
273
|
+
const sent = text
|
|
274
|
+
text += `这是模拟流式的第 ${i + 1} 段。`
|
|
275
|
+
emitStreamFrame(sessionId, messageId, i + 1, text, sent)
|
|
276
|
+
}
|
|
277
|
+
await delay(streamDelayMs)
|
|
278
|
+
const finalMessage: MessageDTO = {
|
|
279
|
+
id: messageId,
|
|
280
|
+
role: 'assistant',
|
|
281
|
+
text,
|
|
282
|
+
createdAt: Date.now(),
|
|
283
|
+
}
|
|
284
|
+
// 定稿:就地替换那条临时消息(与电脑侧「流式期间不发 message.updated」的语义对齐)
|
|
285
|
+
const idx = list.findIndex((m) => m.id === messageId)
|
|
286
|
+
if (idx >= 0) list[idx] = finalMessage
|
|
287
|
+
else list.push(finalMessage)
|
|
288
|
+
// 收尾帧同样是**整段**(定稿全文可能被回填/修复改写,增量已经不可靠)
|
|
289
|
+
emitFor(sessionId, 'host.event.message.stream', {
|
|
290
|
+
sessionId,
|
|
291
|
+
messageId,
|
|
292
|
+
seq: streamSteps + 1,
|
|
293
|
+
mode: 'full',
|
|
294
|
+
text,
|
|
295
|
+
final: true,
|
|
296
|
+
})
|
|
297
|
+
emitFor(sessionId, 'host.event.message.added', { sessionId, message: finalMessage })
|
|
298
|
+
emitFor(sessionId, 'host.event.session.runtime.changed', { sessionId, runtime: { working: false } })
|
|
299
|
+
// 生成一轮 → 上下文占用增长(手机端可看到实时变化)
|
|
300
|
+
bumpContext(sessionId, (contextTokens.get(sessionId) ?? 0) + 2_000)
|
|
301
|
+
const session = sessions.find((s) => s.id === sessionId)
|
|
302
|
+
if (session) {
|
|
303
|
+
session.working = false
|
|
304
|
+
session.updatedAt = Date.now()
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** 推一次上下文变化(内部与 harness 共用)—— 同样受订阅门约束。 */
|
|
309
|
+
function bumpContext(sessionId: string, tokens: number): void {
|
|
310
|
+
contextTokens.set(sessionId, tokens)
|
|
311
|
+
emitFor(sessionId, 'host.event.session.context.changed', { sessionId, context: contextOf(sessionId) })
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** 推一次工具参数生成进度(§27)—— 同上,受订阅门约束。 */
|
|
315
|
+
function setToolProgress(
|
|
316
|
+
sessionId: string,
|
|
317
|
+
progress: { name: string; chars: number } | null,
|
|
318
|
+
): void {
|
|
319
|
+
emitFor(sessionId, 'host.event.session.runtime.changed', {
|
|
320
|
+
sessionId,
|
|
321
|
+
// 参数累积期一定是 working(真实链路里两者同源);progress=null 即清空
|
|
322
|
+
runtime: { working: true, ...(progress ? { toolProgress: progress } : {}) },
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function contextOf(sessionId: string): ContextInfoDTO {
|
|
327
|
+
const tokens = contextTokens.get(sessionId)
|
|
328
|
+
return {
|
|
329
|
+
tokens: tokens ?? null,
|
|
330
|
+
windowTokens: DEFAULT_CONTEXT_WINDOW_TOKENS,
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const host: MockHostDataSource = {
|
|
335
|
+
calls,
|
|
336
|
+
bind(fn) {
|
|
337
|
+
emit = fn
|
|
338
|
+
},
|
|
339
|
+
/**
|
|
340
|
+
* 模拟配对校验 + 授权凭证签发(M6 口径,见 docs/phone-control-bridge.md §30):
|
|
341
|
+
*
|
|
342
|
+
* - 无 token → 拒(`invalid`)
|
|
343
|
+
* - `token === 'expired'` → 凭证过期(`data.reason='expired'`)
|
|
344
|
+
* - `token === 'removed'` → 手机已被电脑端移除(`data.reason='revoked'`)
|
|
345
|
+
* - `token === 'ticket-old'` → 二维码已过期(`data.reason='ticket-expired'`)
|
|
346
|
+
* - 其它非空 token → 通过,并**回传一条授权凭证**(手机端存的应该是它,不是手上那张票)
|
|
347
|
+
*/
|
|
348
|
+
async hello(params: HelloParams): Promise<HelloResult> {
|
|
349
|
+
const token = params.token
|
|
350
|
+
// §32:记下手机声明的流式偏好(后续 `message.stream` 按它决定整帧/增量)
|
|
351
|
+
peerStreamMode = params.streamMode === 'delta' ? 'delta' : 'full'
|
|
352
|
+
if (!token) {
|
|
353
|
+
throw new BridgeError('E_DENIED', '缺少配对令牌', { data: { reason: 'invalid' } })
|
|
354
|
+
}
|
|
355
|
+
if (token === 'expired') {
|
|
356
|
+
throw new BridgeError('E_DENIED', '令牌已过期', { data: { reason: 'expired' } })
|
|
357
|
+
}
|
|
358
|
+
if (token === 'removed') {
|
|
359
|
+
throw new BridgeError('E_DENIED', '该设备已被电脑端移除', { data: { reason: 'revoked' } })
|
|
360
|
+
}
|
|
361
|
+
if (token === 'ticket-old') {
|
|
362
|
+
throw new BridgeError('E_DENIED', '二维码已过期', { data: { reason: 'ticket-expired' } })
|
|
363
|
+
}
|
|
364
|
+
const now = Date.now()
|
|
365
|
+
return {
|
|
366
|
+
protocolVersion: params.protocolVersion,
|
|
367
|
+
host: { platform: 'web-harness', appVersion: '0.0.0' },
|
|
368
|
+
capabilities: [
|
|
369
|
+
'session.list',
|
|
370
|
+
'session.send',
|
|
371
|
+
'session.cancel',
|
|
372
|
+
'session.resume',
|
|
373
|
+
'session.create',
|
|
374
|
+
'session.rename',
|
|
375
|
+
'session.pin',
|
|
376
|
+
'session.delete',
|
|
377
|
+
'interaction.answer',
|
|
378
|
+
'stream.delta',
|
|
379
|
+
'session.model',
|
|
380
|
+
'session.workspace',
|
|
381
|
+
'session.context',
|
|
382
|
+
'session.compress',
|
|
383
|
+
],
|
|
384
|
+
paired: true,
|
|
385
|
+
deviceName: 'Virlen 电脑(演示)',
|
|
386
|
+
deviceId: 'demo-host',
|
|
387
|
+
// M6:派发授权凭证(30 天)—— 手机端应把它存起来,下次直接连
|
|
388
|
+
grant: {
|
|
389
|
+
token: 'gt-demo',
|
|
390
|
+
issuedAt: now,
|
|
391
|
+
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
|
|
392
|
+
lastSeenAt: now,
|
|
393
|
+
},
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
async listSessions() {
|
|
397
|
+
return sessions.map((s) => ({ ...s }))
|
|
398
|
+
},
|
|
399
|
+
async getMessages(params: MsgPageParams): Promise<MsgPageDTO> {
|
|
400
|
+
calls.push(params.fromRowid != null ? 'host.session.messages(older)' : 'host.session.messages')
|
|
401
|
+
const list = messages.get(params.sessionId) ?? []
|
|
402
|
+
const limit = params.limit ?? 50
|
|
403
|
+
// 游标 = 已返回窗口中最旧一条的下标(与真实电脑侧的 rowid 同形:不透明、原样回传)
|
|
404
|
+
if (params.fromRowid != null) {
|
|
405
|
+
const end = Math.max(0, Math.min(params.fromRowid, list.length))
|
|
406
|
+
const start = Math.max(0, end - limit)
|
|
407
|
+
return {
|
|
408
|
+
messages: list.slice(start, end).map((m) => ({ ...m })),
|
|
409
|
+
hasMore: start > 0,
|
|
410
|
+
cursor: start > 0 ? start : null,
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const start = Math.max(0, list.length - limit)
|
|
414
|
+
return {
|
|
415
|
+
messages: list.slice(start).map((m) => ({ ...m })),
|
|
416
|
+
hasMore: start > 0,
|
|
417
|
+
cursor: start > 0 ? start : null,
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
async getMessage(params: { sessionId: string; messageId: string }) {
|
|
421
|
+
const list = messages.get(params.sessionId) ?? []
|
|
422
|
+
const found = list.find((m) => m.id === params.messageId)
|
|
423
|
+
if (!found) throw new BridgeError('E_NOT_FOUND', `message not found: ${params.messageId}`)
|
|
424
|
+
return { ...found }
|
|
425
|
+
},
|
|
426
|
+
async send(params: SendParams) {
|
|
427
|
+
calls.push('host.session.send')
|
|
428
|
+
const list = messages.get(params.sessionId)
|
|
429
|
+
if (!list) throw new BridgeError('E_NOT_FOUND', `session not found: ${params.sessionId}`)
|
|
430
|
+
const userMessage: MessageDTO = {
|
|
431
|
+
id: nextId('u'),
|
|
432
|
+
role: 'user',
|
|
433
|
+
text: params.text,
|
|
434
|
+
createdAt: Date.now(),
|
|
435
|
+
}
|
|
436
|
+
list.push(userMessage)
|
|
437
|
+
emitFor(params.sessionId, 'host.event.message.added', { sessionId: params.sessionId, message: userMessage })
|
|
438
|
+
emitFor(params.sessionId, 'host.event.session.runtime.changed', {
|
|
439
|
+
sessionId: params.sessionId,
|
|
440
|
+
runtime: { working: true },
|
|
441
|
+
})
|
|
442
|
+
const session = sessions.find((s) => s.id === params.sessionId)
|
|
443
|
+
if (session) {
|
|
444
|
+
session.working = true
|
|
445
|
+
session.updatedAt = Date.now()
|
|
446
|
+
}
|
|
447
|
+
void streamReply(params.sessionId, list)
|
|
448
|
+
return { messageId: userMessage.id }
|
|
449
|
+
},
|
|
450
|
+
async cancel() {
|
|
451
|
+
calls.push('host.session.cancel')
|
|
452
|
+
return { ok: true as const }
|
|
453
|
+
},
|
|
454
|
+
async resume(params: { sessionId: string }) {
|
|
455
|
+
calls.push('host.session.resume')
|
|
456
|
+
if (!sessions.some((s) => s.id === params.sessionId)) {
|
|
457
|
+
throw new BridgeError('E_NOT_FOUND', `session not found: ${params.sessionId}`)
|
|
458
|
+
}
|
|
459
|
+
pausedSessions.delete(params.sessionId)
|
|
460
|
+
emitFor(params.sessionId, 'host.event.session.runtime.changed', {
|
|
461
|
+
sessionId: params.sessionId,
|
|
462
|
+
runtime: { working: true },
|
|
463
|
+
})
|
|
464
|
+
return { ok: true as const }
|
|
465
|
+
},
|
|
466
|
+
/**
|
|
467
|
+
* 记录订阅(与真实电脑侧同语义:只推已订阅会话的会话维度事件)。
|
|
468
|
+
*
|
|
469
|
+
* ⚠️ 真实电脑侧还会先 `requireSession`(未知名会话 → `E_NOT_FOUND`),mock 保持一致:
|
|
470
|
+
* 否则「客户端订阅了一个不存在的会话」这类接线错误在单测里喑默通过。
|
|
471
|
+
*/
|
|
472
|
+
async subscribe(params: { sessionId: string }) {
|
|
473
|
+
calls.push('host.session.subscribe')
|
|
474
|
+
if (!sessions.some((s) => s.id === params.sessionId)) {
|
|
475
|
+
throw new BridgeError('E_NOT_FOUND', `session not found: ${params.sessionId}`)
|
|
476
|
+
}
|
|
477
|
+
subscribed.add(params.sessionId)
|
|
478
|
+
return { ok: true as const }
|
|
479
|
+
},
|
|
480
|
+
/**
|
|
481
|
+
* 模拟「终端内确认」(`presentation:'terminal'`):
|
|
482
|
+
* 手机只能原样放行 / 拒绝;电脑侧落点是 `terminalConfirmSubmit/Cancel`(§16.4)。
|
|
483
|
+
*/
|
|
484
|
+
triggerInteraction(spec: MockInteractionSpec = {}): string {
|
|
485
|
+
const interactionId = nextId('it')
|
|
486
|
+
const kind = spec.kind ?? 'authorization'
|
|
487
|
+
const interaction: InteractionDTO = {
|
|
488
|
+
interactionId,
|
|
489
|
+
sessionId: spec.sessionId ?? 'demo-1',
|
|
490
|
+
toolCallId: nextId('tc'),
|
|
491
|
+
kind,
|
|
492
|
+
createdAt: Date.now(),
|
|
493
|
+
tier: spec.tier ?? 'low',
|
|
494
|
+
question: spec.question,
|
|
495
|
+
options: spec.options,
|
|
496
|
+
multi: spec.multi,
|
|
497
|
+
permName: spec.permName,
|
|
498
|
+
title: spec.title ?? (kind === 'authorization' ? '执行命令' : undefined),
|
|
499
|
+
subTitle: spec.subTitle,
|
|
500
|
+
desc: spec.desc,
|
|
501
|
+
hint: spec.hint,
|
|
502
|
+
risk: spec.risk,
|
|
503
|
+
sandboxBypass: spec.sandboxBypass,
|
|
504
|
+
presentation: spec.presentation ?? 'modal',
|
|
505
|
+
}
|
|
506
|
+
interactions.set(interactionId, interaction)
|
|
507
|
+
emit?.('host.event.interaction.requested', { interaction })
|
|
508
|
+
return interactionId
|
|
509
|
+
},
|
|
510
|
+
/**
|
|
511
|
+
* 应答:与真实电脑侧同样的**独立校验**(高风险批准必须带 `confirmed`;
|
|
512
|
+
* 选择结果必须能规范化成非空回执)—— 演示 / 测试才能真实反映「手机 UI 漏改不算放行」。
|
|
513
|
+
*/
|
|
514
|
+
async answer(params: AnswerParams): Promise<AnswerResult> {
|
|
515
|
+
const interaction = interactions.get(params.interactionId)
|
|
516
|
+
if (!interaction) return { accepted: false, reason: 'not-found' }
|
|
517
|
+
// 动作合法性:与真实电脑侧(`interaction-registry`)**共用同一份**判据 ——
|
|
518
|
+
// mock 偏宽松会让「手机 UI 的动作与交互类型不匹配」这类缺陷测不出来(§18.6)。
|
|
519
|
+
const actionError = answerActionError(interaction, params.action)
|
|
520
|
+
if (actionError) return { accepted: false, reason: actionError }
|
|
521
|
+
if (params.action === 'allow' && interaction.tier === 'high' && params.confirmed !== true) {
|
|
522
|
+
return { accepted: false, reason: 'confirm-required' }
|
|
523
|
+
}
|
|
524
|
+
if (params.action === 'choose' && !normalizeChoiceAnswer(params.value)) {
|
|
525
|
+
return { accepted: false, reason: 'invalid-value' }
|
|
526
|
+
}
|
|
527
|
+
interactions.delete(params.interactionId)
|
|
528
|
+
// 暂存 → 会话进入「暂停运行」态(供手机端显示「继续」,与电脑侧 paused 语义一致)
|
|
529
|
+
if (params.action === 'shelve') {
|
|
530
|
+
pausedSessions.add(interaction.sessionId)
|
|
531
|
+
emitFor(interaction.sessionId, 'host.event.session.runtime.changed', {
|
|
532
|
+
sessionId: interaction.sessionId,
|
|
533
|
+
runtime: { working: false, paused: true },
|
|
534
|
+
})
|
|
535
|
+
}
|
|
536
|
+
emit?.('host.event.interaction.resolved', {
|
|
537
|
+
interactionId: params.interactionId,
|
|
538
|
+
by: 'mobile',
|
|
539
|
+
outcome: params.action === 'allow' ? 'allow' : params.action === 'shelve' ? 'shelve' : 'deny',
|
|
540
|
+
})
|
|
541
|
+
return { accepted: true }
|
|
542
|
+
},
|
|
543
|
+
pendingInteractions() {
|
|
544
|
+
return [...interactions.keys()]
|
|
545
|
+
},
|
|
546
|
+
async listInteractions(): Promise<InteractionDTO[]> {
|
|
547
|
+
return [...interactions.values()].map((i) => ({ ...i }))
|
|
548
|
+
},
|
|
549
|
+
// ── M4 写操作 ──
|
|
550
|
+
async createSession(params: CreateSessionParams) {
|
|
551
|
+
calls.push('host.session.create')
|
|
552
|
+
const id = nextId('s')
|
|
553
|
+
// 工作目录:**与真实电脑侧同一条防线** —— 候选集由电脑侧给出,手机不能自造(§22.3)
|
|
554
|
+
let workspace: string | undefined
|
|
555
|
+
if (params.workspace != null) {
|
|
556
|
+
const match = DEMO_WORKSPACES.find((w) => w === params.workspace)
|
|
557
|
+
if (!match) {
|
|
558
|
+
throw new BridgeError('E_BAD_REQUEST', `工作目录不在电脑侧既有目录内:${params.workspace}`)
|
|
559
|
+
}
|
|
560
|
+
workspace = match
|
|
561
|
+
}
|
|
562
|
+
const model = resolveModel(params.providerConfigId, params.modelId)
|
|
563
|
+
sessions.unshift({
|
|
564
|
+
id,
|
|
565
|
+
title: params.title || '手机新建的会话',
|
|
566
|
+
updatedAt: Date.now(),
|
|
567
|
+
working: false,
|
|
568
|
+
agentId: 'agent-virlen',
|
|
569
|
+
agentName: 'Virlen',
|
|
570
|
+
workspace: workspace ?? DEMO_WORKSPACES[0],
|
|
571
|
+
...model,
|
|
572
|
+
})
|
|
573
|
+
messages.set(id, [])
|
|
574
|
+
contextTokens.set(id, 0)
|
|
575
|
+
// 与真实电脑侧同一条兜底(§24):**自建的会话直接纳入订阅集合** ——
|
|
576
|
+
// 客户端创建会话的意图就是要马上看它,订阅不该押在它记得再调一次 RPC 上。
|
|
577
|
+
subscribed.add(id)
|
|
578
|
+
emitList()
|
|
579
|
+
return { sessionId: id }
|
|
580
|
+
},
|
|
581
|
+
async renameSession(params: RenameSessionParams) {
|
|
582
|
+
const session = sessions.find((s) => s.id === params.sessionId)
|
|
583
|
+
if (!session) throw new BridgeError('E_NOT_FOUND', `session not found: ${params.sessionId}`)
|
|
584
|
+
session.title = params.title
|
|
585
|
+
session.updatedAt = Date.now()
|
|
586
|
+
emitList()
|
|
587
|
+
return { ok: true as const }
|
|
588
|
+
},
|
|
589
|
+
async setPinned(params: PinSessionParams) {
|
|
590
|
+
const session = sessions.find((s) => s.id === params.sessionId)
|
|
591
|
+
if (!session) throw new BridgeError('E_NOT_FOUND', `session not found: ${params.sessionId}`)
|
|
592
|
+
;(session as { pinned?: boolean }).pinned = params.pinned
|
|
593
|
+
emitList()
|
|
594
|
+
return { ok: true as const }
|
|
595
|
+
},
|
|
596
|
+
async deleteSession(params: DeleteSessionParams) {
|
|
597
|
+
if (params.confirm !== true) {
|
|
598
|
+
throw new BridgeError('E_CONFIRM_REQUIRED', '删除会话需二次确认')
|
|
599
|
+
}
|
|
600
|
+
const idx = sessions.findIndex((s) => s.id === params.sessionId)
|
|
601
|
+
if (idx === -1) throw new BridgeError('E_NOT_FOUND', `session not found: ${params.sessionId}`)
|
|
602
|
+
sessions.splice(idx, 1)
|
|
603
|
+
messages.delete(params.sessionId)
|
|
604
|
+
contextTokens.delete(params.sessionId)
|
|
605
|
+
emitList()
|
|
606
|
+
return { ok: true as const }
|
|
607
|
+
},
|
|
608
|
+
// ── §22:模型 / 工作目录 / 上下文 ──
|
|
609
|
+
async listModels() {
|
|
610
|
+
calls.push('host.model.list')
|
|
611
|
+
return DEMO_PROVIDERS.map((p) => ({ ...p, models: [...p.models] }))
|
|
612
|
+
},
|
|
613
|
+
async setModel(params: SetModelParams) {
|
|
614
|
+
calls.push('host.session.setModel')
|
|
615
|
+
const session = sessions.find((s) => s.id === params.sessionId)
|
|
616
|
+
if (!session) throw new BridgeError('E_NOT_FOUND', `session not found: ${params.sessionId}`)
|
|
617
|
+
Object.assign(session, resolveModel(params.providerConfigId, params.modelId))
|
|
618
|
+
emitList()
|
|
619
|
+
return { ok: true as const }
|
|
620
|
+
},
|
|
621
|
+
async listWorkspaces(): Promise<WorkspaceOptionDTO[]> {
|
|
622
|
+
calls.push('host.workspace.list')
|
|
623
|
+
return DEMO_WORKSPACES.map((path) => ({
|
|
624
|
+
path,
|
|
625
|
+
name: path.split('/').pop() ?? path,
|
|
626
|
+
sessionCount: sessions.filter((s) => s.workspace === path).length,
|
|
627
|
+
}))
|
|
628
|
+
},
|
|
629
|
+
async getContext(params: ContextParams): Promise<ContextInfoDTO> {
|
|
630
|
+
calls.push('host.session.context')
|
|
631
|
+
if (!sessions.some((s) => s.id === params.sessionId)) {
|
|
632
|
+
throw new BridgeError('E_NOT_FOUND', `session not found: ${params.sessionId}`)
|
|
633
|
+
}
|
|
634
|
+
return contextOf(params.sessionId)
|
|
635
|
+
},
|
|
636
|
+
/**
|
|
637
|
+
* 压缩上下文:**与真实电脑侧同判据**(confirm + 最小占用比例),
|
|
638
|
+
* 然后把消息换成一条 summary,并推 `messages.reset`(手机重拉窗口)。
|
|
639
|
+
*/
|
|
640
|
+
async compress(params: CompressParams) {
|
|
641
|
+
calls.push('host.session.compress')
|
|
642
|
+
if (params.confirm !== true) {
|
|
643
|
+
throw new BridgeError('E_CONFIRM_REQUIRED', '压缩上下文需二次确认(confirm:true)')
|
|
644
|
+
}
|
|
645
|
+
if (!sessions.some((s) => s.id === params.sessionId)) {
|
|
646
|
+
throw new BridgeError('E_NOT_FOUND', `session not found: ${params.sessionId}`)
|
|
647
|
+
}
|
|
648
|
+
const context = contextOf(params.sessionId)
|
|
649
|
+
const tokens = context.tokens ?? 0
|
|
650
|
+
if (tokens < context.windowTokens * COMPRESS_MIN_RATIO) {
|
|
651
|
+
throw new BridgeError('E_BAD_REQUEST', '当前上下文很充裕,无需压缩')
|
|
652
|
+
}
|
|
653
|
+
const summary: MessageDTO = {
|
|
654
|
+
id: nextId('sum'),
|
|
655
|
+
role: 'system',
|
|
656
|
+
text: '[上下文摘要] 之前的内容已压缩为摘要。',
|
|
657
|
+
createdAt: Date.now(),
|
|
658
|
+
}
|
|
659
|
+
messages.set(params.sessionId, [summary])
|
|
660
|
+
emitFor(params.sessionId, 'host.event.session.messages.reset', { sessionId: params.sessionId })
|
|
661
|
+
emitFor(params.sessionId, 'host.event.message.added', { sessionId: params.sessionId, message: summary })
|
|
662
|
+
bumpContext(params.sessionId, 2_000)
|
|
663
|
+
return { ok: true as const }
|
|
664
|
+
},
|
|
665
|
+
/** 手动推一次占用变化(harness 按钮 / 测试用)。 */
|
|
666
|
+
bumpContext,
|
|
667
|
+
setToolProgress,
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return host
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function delay(ms: number): Promise<void> {
|
|
674
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** 把 `providerConfigId` / `modelId` 解析成会话上的三个展示 / 切换字段(非法组合 → E_BAD_REQUEST)。 */
|
|
678
|
+
function resolveModel(
|
|
679
|
+
providerConfigId?: string,
|
|
680
|
+
modelId?: string,
|
|
681
|
+
): Pick<SessionSummaryDTO, 'providerConfigId' | 'providerName' | 'modelId'> {
|
|
682
|
+
if (providerConfigId == null && modelId == null) {
|
|
683
|
+
return { providerConfigId: DEMO_PROVIDERS[0].id, providerName: DEMO_PROVIDERS[0].name, modelId: DEMO_PROVIDERS[0].models[0] }
|
|
684
|
+
}
|
|
685
|
+
const provider = DEMO_PROVIDERS.find((p) => p.id === providerConfigId)
|
|
686
|
+
if (!provider) throw new BridgeError('E_BAD_REQUEST', `模型服务不存在或未启用:${providerConfigId}`)
|
|
687
|
+
if (!modelId || !provider.models.includes(modelId)) {
|
|
688
|
+
throw new BridgeError('E_BAD_REQUEST', `该服务下没有模型:${modelId}`)
|
|
689
|
+
}
|
|
690
|
+
return { providerConfigId: provider.id, providerName: provider.name, modelId }
|
|
691
|
+
}
|