Merge remote-tracking branch 'origin/master' into feat/directory-picker-show-hidden

This commit is contained in:
creatixchu
2026-07-30 17:02:33 +08:00
50 changed files with 889 additions and 146 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 7129842a0cc89f5fa10c0bceec7cf0997ac71a99
README.zh.md: 8765627236ea7af9e5cd3b7029133181ef499905
README.md: bf5d0d762365deae83d004e00b94a0ba805d6a6f
README.zh.md: 06c2e37d2590c3c75d0a782bcdf4d98e7b398928

View File

@@ -44,3 +44,4 @@ None; this package neither assembles nor sends a provider request.
- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)).
- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/end-seed` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md).

View File

@@ -44,3 +44,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
- **预留 seam 不进入 `RpcMethodMap`**`session.fork``prompt.mode: 'inject'``task.list``host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime而每一次持久写入都会刷新它包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONLSQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会排在它最后一次真实活动之后。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。

View File

@@ -14,6 +14,7 @@ import type {
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import { lastActivityTime } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
@@ -164,7 +165,9 @@ function sessionBlank(session: Session): boolean {
function summarize(session: Session, running: boolean): SessionSummary {
return {
sessionId: session.id,
updatedAt: session.events.at(-1)?.time ?? session.header.createdAt,
// Excludes end-seed: a resumed-but-untouched session
// must not sort as freshly worked in.
updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
running,
blank: sessionBlank(session),
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },

View File

@@ -133,7 +133,11 @@ export type QueueAction =
/** Session list entry (v1 builds no index: list does readdir+stat). */
export interface SessionSummary {
sessionId: SessionId
/** Persisted file mtime. */
/**
* Last activity. Attached: the last non-`session/end-seed` event, since a
* pickup is not activity. Cold: the log's mtime, or `createdAt` for a backend
* with no per-session file (README Known Limitations covers the skew).
*/
updatedAt: number
/** Status of the attached agent; always false for cold (unattached) sessions. */
running: boolean

View File

@@ -78,6 +78,42 @@ describe('sessions.list cold merge', () => {
})
})
describe('attached updatedAt excludes end-seed', () => {
it('reports the last real work, not the pickup, so a resumed-untouched session does not float', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
// Old work, resumed just now: the log tail would report the pickup.
const worked = 1_000_000
const resumed = ctx.sessions.create(sid('resumed-untouched'), {
seed: [
{ type: 'turn/start', seq: 0, time: worked, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 1, time: worked, data: { turn: 1, reason: { kind: 'completed' } } },
],
meta: { cwd: '/proj', createdAt: 500 },
})
ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent)
const boundary = resumed.events.at(-1)
expect(boundary?.type).toBe('session/end-seed')
expect(boundary?.time).toBeGreaterThan(worked)
const listed = await api.sessions.list(request({}))
if (!listed.result.ok) throw new Error('list failed')
const summary = listed.result.value.items.find(item => item.sessionId === 'resumed-untouched')
expect(summary?.updatedAt).toBe(worked)
// Real work appended after end-seed does move it.
resumed.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
const after = await api.sessions.list(request({}))
if (!after.result.ok) throw new Error('list failed')
const moved = after.result.value.items.find(item => item.sessionId === 'resumed-untouched')
expect(moved?.updatedAt).toBeGreaterThan(worked)
})
})
describe('degenerate composition (no persistence, no factory)', () => {
it('list skips the cold merge and resume maps a non-not-found failure to internal', async () => {
const ctx = new Context()