Repository navigation
Expand file tree
/
Copy pathsession.ts
More file actions
533 lines (491 loc) · 20.3 KB
/
Copy pathsession.ts
File metadata and controls
533 lines (491 loc) · 20.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
// ============================================
// Session API — OpenCode v2 原生
//
// ## v1 → v2 关键差异
//
// - 所有会话方法只按 `sessionID` 定位,**不接受 location/directory**
// (只有 create / import / list 涉及目录)
// - `status()` → `active()`(返回全部「运行中」会话)+ `session.status` 事件
// - `delete()` → `remove()`
// - `abort()` → `interrupt()`
// - `revert()` → `revert.stage()` / `revert.clear()` / `revert.commit()`
// - `unrevert()` → `revert.clear()`
// - `summarize()` → `compact()`
// - `children()` → `list({ parentID })`
// - `messages()` → `client.message.list()`(移出 session)
// - `todo()` → **v2 已移除**(无替代:整个 todo 能力连同 UI 一并删除)
// - `share()/unshare()` → 移除(导出改走 `session.export()`)
// - `archive` → **v2 无归档端点**(`time.archived` 只读不可写,功能已移除)
// - `list()` 返回 `{ data, cursor }`,不再是裸数组
// ============================================
import { getSDKClient } from './sdk'
import { isSessionBusyError } from '@opencode/client/promise'
import { locationParam } from './location'
import { resolveSessionTarget, makeSessionKey } from '../utils/sessionKey'
import { inboxStore } from '../store/inboxStore'
import { normalizeFileDiffs } from '../types/api/file'
import type { FileDiff, Session, SessionListParams, SessionRevert, SessionStatusMap } from './types'
import type { SessionTransferData, SessionInboxInfo } from '@opencode/client/promise'
import { sessionInfoCache } from '../store/sessionInfoCache'
// ============================================
// 会话状态
// ============================================
/**
* 获取所有「运行中」的会话。
*
* v2 的 `session.active()` 只返回运行中的会话(`{ [sessionID]: {type:'running'} }`),
* 完整的 idle/busy/retry 状态由 `session.status` 事件推送,
* 由 store 维护成 SessionStatusMap。
*/
export async function getActiveSessions(serverId?: string): Promise<Record<string, { type: 'running' }>> {
const sdk = getSDKClient(serverId)
return await sdk.session.active()
}
/**
* 兼容旧调用点:v2 没有「一次性拉全部状态」的接口,
* 这里把 `active()` 的结果投影成 status map(只有 running 是确定的)。
*/
export async function getSessionStatus(_directory?: string, serverId?: string): Promise<SessionStatusMap> {
const active = await getActiveSessions(serverId)
const result: SessionStatusMap = {}
for (const sessionID of Object.keys(active)) {
result[sessionID] = { type: 'busy' }
}
return result
}
// ============================================
// Diff
// ============================================
/**
* 获取会话当前轮的 diff(v2 `session.diff`,对齐官方 review 的 turn 模式)。
*
* v2 的 `session.diff({ sessionID })` 不带 from/to 时锚定最后一条 user
* message 所在的 turn(快照差:turn 起始快照 vs 结束快照/运行中的工作区)。
* from/to 可扩展范围(官方 UI 未暴露,这里也不透传)。
* 服务端按 session 自身 location 解析快照仓库,不需要调用方传 directory。
*/
export async function getSessionDiff(sessionId: string, serverId?: string): Promise<FileDiff[]> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
const diffs = await sdk.session.diff({ sessionID: target.sessionId })
return normalizeFileDiffs(diffs)
}
/** 单轮 diff 的 in-flight 去重(虚拟列表来回挂载时不重复请求) */
const inflightTurnDiffs = new Map<string, Promise<FileDiff[]>>()
/**
* 取某一轮(从指定 user message 起)的文件 diff。
*
* 官方 timeline 的 TurnDiff 行用 `session.diff({ sessionID, from })`,
* `from` 是该轮用户消息 id。返回的 patch 可直接交给 DiffView 渲染。
*/
export async function getTurnDiff(sessionId: string, from: string, serverId?: string): Promise<FileDiff[]> {
const target = resolveSessionTarget(sessionId, serverId)
const key = `${target.serverId}:${target.sessionId}:${from}`
const inflight = inflightTurnDiffs.get(key)
if (inflight) return inflight
const request = (async () => {
try {
const sdk = getSDKClient(target.serverId)
const diffs = await sdk.session.diff({ sessionID: target.sessionId, from })
return normalizeFileDiffs(diffs)
} finally {
inflightTurnDiffs.delete(key)
}
})()
inflightTurnDiffs.set(key, request)
return request
}
// ============================================
// 会话 CRUD
// ============================================
/** 一页会话列表;`next` 是下一页游标(null = 到底) */
export interface SessionListPage {
data: Session[]
next: string | null
}
/**
* 获取一页会话列表,保留服务端游标。
*
* 翻页(loadMore)走这里:cursor 分页只取新增的一页,不必把整张表
* 重新拉一遍(旧实现靠 limit 递增重拉,越翻越慢)。
*/
export async function listSessionsPage(
params: SessionListParams = {},
serverId?: string,
): Promise<SessionListPage> {
const sdk = getSDKClient(serverId)
const { directory, parentID, search, limit, order, cursor, project, subpath } = params
const result = await sdk.session.list({
directory: locationParam(directory, serverId)?.directory,
parentID,
search,
limit,
order,
cursor,
project,
subpath,
})
return { data: result.data, next: result.cursor?.next ?? null }
}
/**
* 获取会话列表(数组形态)。
*
* 只要第一页的调用点(搜索、e2e)继续用它;需要翻页的列表走
* `listSessionsPage` 或 `sessionIndexStore`。
*/
export async function getSessions(params: SessionListParams = {}, serverId?: string): Promise<Session[]> {
const page = await listSessionsPage(params, serverId)
return page.data
}
/**
* 获取子会话。
*
* v1 的 `session.children()` 在 v2 折进 `list({ parentID })`。
*/
export async function getSessionChildren(
sessionId: string,
_directory?: string,
serverId?: string,
): Promise<Session[]> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
const result = await sdk.session.list({ parentID: target.sessionId })
return result.data
}
export async function getSession(sessionId: string, _directory?: string, serverId?: string): Promise<Session> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
const session = await sdk.session.get({ sessionID: target.sessionId })
// 权威读顺手刷新交接缓存:从任何路径回读到的最新元信息都能被下一次进入命中
sessionInfoCache.remember(target.serverId, session)
return session
}
/**
* 创建会话。
*
* v2 的目录参数是 `location.directory`;title / agent / model 直接传。
*
* 注意:v2 **没有**「直接创建子会话」的入口——子会话只能由 task 工具、
* fork 或 import(带 parentID)产生;把 parentID 塞进 metadata 只会被
* 服务端原样存储,不会建立父子关系(官方 handlers/session.ts 无此处理),
* 因此这里不接受 parentID。
*/
export async function createSession(
params: {
directory?: string
title?: string
agent?: string
model?: { id: string; providerID: string; variant?: string }
} = {},
serverId?: string,
): Promise<Session> {
const sdk = getSDKClient(serverId)
const { directory, title, agent, model } = params
const location = locationParam(directory, serverId)
return await sdk.session.create({
title,
agent,
model,
// v2 的 create 里 location.directory 是必填的(给了 location 就必须带目录)
...(location ? { location: { directory: location.directory as string } } : {}),
})
}
/**
* 更新会话。
*
* v2 的 update 接受 title / metadata / permissions,但目前所有调用点只改标题
* (重命名),因此这里只暴露 `title`——不保留 `metadata` 这个没人传的参数,
* 它之前只是为了让 `metadata as never` 这个强转有地方待着。
*
* 注意:v2 **没有归档端点**——`SessionInfo.time.archived` 只读不可写,
* 因此 v1 的归档功能整体移除了(不再用 metadata 假装归档)。
*/
export async function updateSession(
sessionId: string,
params: { title?: string },
_directory?: string,
serverId?: string,
): Promise<Session> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
await sdk.session.update({
sessionID: target.sessionId,
title: params.title,
})
// v2 的 update 返回 void,回读一次保证调用点拿到最新会话
const session = await sdk.session.get({ sessionID: target.sessionId })
sessionInfoCache.remember(target.serverId, session)
return session
}
/** 删除会话(v1 的 delete → v2 的 remove) */
export async function deleteSession(sessionId: string, _directory?: string, serverId?: string): Promise<boolean> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
await sdk.session.remove({ sessionID: target.sessionId })
// 删掉的会话不该在 TTL 内还命中交接缓存
sessionInfoCache.forget(target.serverId, sessionId)
return true
}
/**
* 把会话移动到其他目录 / worktree(v2 `session.move`)。
*
* 官方入口是会话上的 workspace 菜单(session-workspace-menu.tsx:77);
* 移动后服务端发 `session.moved`,各列表据此增删。
*/
export async function moveSession(sessionId: string, directory: string, serverId?: string): Promise<void> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
await sdk.session.move({ sessionID: target.sessionId, directory })
// 目录归属变了:交接缓存里的 location 立刻作废,避免进会话用错目录
sessionInfoCache.forget(target.serverId, sessionId)
}
/**
* 把阻塞当前回合的 shell / subagent 任务转为后台运行(v2 `session.background`)。
*
* 官方 requests/model.ts:90:主会话(非子会话)在 shell/subagent 工具阻塞回合时
* 显示「Move to background」,按下后回合不再等待这些任务。
*/
export async function backgroundSession(sessionId: string, serverId?: string): Promise<void> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
await sdk.session.background({ sessionID: target.sessionId })
}
// ============================================
// 会话动作
// ============================================
/**
* 中断会话(v1 的 abort → v2 的 interrupt)。
* 返回是否真的中断了正在进行的执行。
*/
export async function abortSession(sessionId: string, _directory?: string, serverId?: string): Promise<boolean> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
const result = await sdk.session.interrupt({ sessionID: target.sessionId })
return result.interrupted
}
/**
* 回退到某条消息(v1 revert → v2 revert.stage)。
*
* 服务端在会话执行活跃时会拒绝 stage(409 SessionBusyError),
* 官方 app 的做法是先 interrupt 再 stage(packages/app/src/session/revert.ts),
* 这里复刻:遇到 busy 就先中断一次再重试。
*/
export async function revertMessage(
sessionId: string,
messageId: string,
_partId?: string,
_directory?: string,
serverId?: string,
): Promise<SessionRevert> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
const stage = async () => {
try {
return await sdk.session.revert.stage({ sessionID: target.sessionId, messageID: messageId })
} catch (error) {
if (!isSessionBusyError(error)) throw error
await sdk.session.interrupt({ sessionID: target.sessionId }).catch(() => undefined)
return await sdk.session.revert.stage({ sessionID: target.sessionId, messageID: messageId })
}
}
const staged = await stage()
// 回退到之前的 prompt 会废弃整个待投递队列(官方 revert.ts:58-79 同款):
// 它们是按被回滚的历史写下的。取消「本地快照 ∪ 服务端权威列表」里的
// 全部 user 条目,fire-and-forget 不阻塞恢复输入框;cutoff 防止误伤
// revert 之后新 admit 的条目。
const cutoff = Date.now()
const scopedKey = makeSessionKey(target.serverId, target.sessionId)
const local = inboxStore
.getItems(scopedKey)
.filter(item => item.type === 'user')
.map(item => item.id)
void sdk.session.inbox
.list({ sessionID: target.sessionId })
.then(rows => rows.filter(row => row.type === 'user' && row.time.created <= cutoff).map(row => row.id))
.catch(() => [] as string[])
.then(authoritative => {
for (const inboxID of new Set([...local, ...authoritative])) {
void sdk.session.inbox.cancel({ sessionID: target.sessionId, inboxID }).catch(() => undefined)
}
})
return staged
}
/**
* 取消已 staged 的回退(v1 的 unrevert → v2 revert.clear)。
*/
export async function unrevertSession(sessionId: string, _directory?: string, serverId?: string): Promise<void> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
await sdk.session.revert.clear({ sessionID: target.sessionId })
}
/**
* 提交已 staged 的回退(v2 新增)。
*/
export async function commitRevert(sessionId: string, _directory?: string, serverId?: string): Promise<void> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
await sdk.session.revert.commit({ sessionID: target.sessionId })
}
/**
* Fork 会话。
*
* v2 用 `before`(消息 ID)表达分叉边界,取代 v1 的 messageID。
*/
export async function forkSession(
sessionId: string,
messageId?: string,
_directory?: string,
serverId?: string,
): Promise<Session> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
return await sdk.session.fork({ sessionID: target.sessionId, before: messageId })
}
/**
* 压缩上下文(v1 summarise → v2 compact)。
*
* v2 不再需要显式传 provider/model —— 会话已绑定模型。
* in-flight 去重(官方 data.ts:1496-1498 的 compacting map 同款):
* 同一会话已有进行中的 compact 请求时直接复用,双击 /compact 不会
* 发出两个 POST(服务端回声会按同一 admission 合并)。
*/
const inflightCompactions = new Map<string, Promise<boolean>>()
export async function summarizeSession(
sessionId: string,
_params?: { providerID?: string; modelID?: string; auto?: boolean },
_directory?: string,
serverId?: string,
): Promise<boolean> {
const target = resolveSessionTarget(sessionId, serverId)
const scopedKey = makeSessionKey(target.serverId, target.sessionId)
const inflight = inflightCompactions.get(scopedKey)
if (inflight) return inflight
const sdk = getSDKClient(target.serverId)
const request = (async () => {
try {
await sdk.session.compact({ sessionID: target.sessionId })
return true
} finally {
// map 同一时刻只会有一个 in-flight 请求(后来者直接复用),
// 当前 finally 必属于它,直接删
inflightCompactions.delete(scopedKey)
}
})()
inflightCompactions.set(scopedKey, request)
return request
}
/**
* 等待会话进入 idle。
*/
export async function waitSession(sessionId: string, serverId?: string): Promise<void> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
await sdk.session.wait({ sessionID: target.sessionId })
}
// 占位会话构造器在 utils/sessionPlaceholder(纯函数,不经 api 桶导出,
// 否则 mock 了 api 模块的测试会拿到 undefined)
export { createSessionPlaceholder } from '../utils/sessionPlaceholder'
export type { SessionPlaceholderInput } from '../utils/sessionPlaceholder'
// ============================================
// 会话导出 / 导入(取代 v1 的 share / unshare)
// ============================================
/**
* 侧问(官方 /btw,session/btw/model.ts 同款)。
*
* `session.generate`:基于当前会话上下文一次性生成回答,
* 不落转写、不调工具(指令由调用方拼进 prompt)。
*/
export async function generateSessionAnswer(
sessionId: string,
prompt: string,
serverId?: string,
signal?: AbortSignal,
): Promise<string> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
const result = await sdk.session.generate(
{ sessionID: target.sessionId, prompt },
signal ? { signal } : undefined,
)
return result.text ?? ''
}
/**
* 导出会话转写。
*
* v2 用 export 取代了 v1 的 share:不再生成公开链接,
* 而是返回 `{ info, messages }` 的可序列化转写数据。
*/
export async function exportSession(
sessionId: string,
_directory?: string,
serverId?: string,
): Promise<SessionTransferData> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
return await sdk.session.export({ sessionID: target.sessionId })
}
/**
* 导入会话转写。
*
* v2 的 import 接受可选 `location`;不传会落到服务端的 process.cwd()
* (官方 app 传 `location: { directory: project.worktree }`)。
*/
export async function importSession(
transfer: SessionTransferData,
directory?: string,
serverId?: string,
): Promise<Session> {
const sdk = getSDKClient(serverId)
const location = locationParam(directory, serverId)
return await sdk.session.import({
...transfer,
...(location ? { location: { directory: location.directory as string } } : {}),
})
}
// ============================================
// Inbox(服务端队列)
//
// v2 的 prompt 是入队语义:delivery='queue' 的条目由服务端在当前回合
// 排空后自动投递。队列视图读 inbox.list,取消/插队走 cancel/update
//(官方 packages/app/src/session/composer/queue.ts 同款)。
// ============================================
/** 列出会话的 inbox 条目(排队中的 prompt / compaction 等) */
export async function getSessionInbox(sessionId: string, serverId?: string): Promise<SessionInboxInfo[]> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
return await sdk.session.inbox.list({ sessionID: target.sessionId })
}
/** 取消 inbox 条目(官方 retractLocal:回声会把转写里的对应用户消息一并撤下) */
export async function cancelInboxItem(sessionId: string, inboxID: string, serverId?: string): Promise<void> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
await sdk.session.inbox.cancel({ sessionID: target.sessionId, inboxID })
}
/** 把排队条目改为 steer(插队注入当前回合) */
export async function steerInboxItem(sessionId: string, inboxID: string, serverId?: string): Promise<void> {
return updateInboxDelivery(sessionId, inboxID, 'steer', serverId)
}
/** 切换 inbox 条目的投递方式(steer ↔ queue,官方 queue.steer 同款 inbox.update) */
export async function updateInboxDelivery(
sessionId: string,
inboxID: string,
delivery: 'steer' | 'queue',
serverId?: string,
): Promise<void> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
await sdk.session.inbox.update({ sessionID: target.sessionId, inboxID, delivery })
}
// ============================================
// Session Shell(`!` 命令)
//
// 官方 composer 的 shell mode:输入框 `!` 进入,提交即调 session.shell,
// shell 消息经 session.shell.started/ended 事件流入转写
//(packages/app/src/composer/submit.ts:147-149)。
// ============================================
/** 在会话 shell 里执行命令(输出以 shell 消息形式进入转写) */
export async function executeSessionShell(sessionId: string, command: string, serverId?: string): Promise<void> {
const target = resolveSessionTarget(sessionId, serverId)
const sdk = getSDKClient(target.serverId)
await sdk.session.shell({ sessionID: target.sessionId, command })
}