fix(web): address skill row review feedback

This commit is contained in:
Yichen Jiang
2026-08-06 17:21:13 +08:00
parent 690f7ae035
commit a667d2cd64
39 changed files with 326 additions and 142 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: 00b9ea075af7eb55041d48ceb3a1363e9de90397
README.zh.md: e62e3fa99c2908afeca85b7182701f6fcdf19de9
README.md: 7f5d7b50cf86e251b73e2e67e38939827bd7eb13
README.zh.md: 71b40929e065981c08a2d2b4fb1e941cbef687ff

View File

@@ -12,7 +12,7 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
Question responses are validated against their pending request before the first answer claims it. A multi-select item may carry both requested option labels in `selected` and non-empty `custom` text; a single-select item must use one or the other. Duplicate labels, unknown labels, mismatched ids, incomplete batches, and empty custom text are rejected as `bad-response`.
`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it.
`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it. A `tool/result` entry additionally carries its paired call's name, exact arguments JSON, and event time as a transient history annotation derived from the complete log, so a page cut cannot erase keyed toolview dispatch, argument-derived summaries, or duration. Result render intents use that same complete-log pair; an orphan result or malformed arguments still soft-fall to the generic presentation path.
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.

View File

@@ -12,7 +12,7 @@
首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。
`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message``assistant/message` 事件因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间从而让压缩compaction的仅日志溯源信息与引用它的替换留在同一页。
`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message``assistant/message` 事件因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间从而让压缩compaction的仅日志溯源信息与引用它的替换留在同一页。`tool/result` 条目还会携带其配对调用的名称、精确的 arguments JSON 和事件时间,作为从完整日志派生的瞬时 history 注解,因此分页切分无法抹掉键控 toolview 分派、由参数派生的摘要或耗时。结果渲染意图使用完整日志中的同一配对;无配对结果或参数损坏时,仍会软降级到通用呈现路径。
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections``@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema协议 schema 对 `values`/`value` 保持宽松loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。

View File

@@ -26,7 +26,7 @@ import {
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup,
ApiProxy, CredentialView, GoalRef, HistoryEntry, HistoryToolCall, HostFrame, ModelCatalogFailure, ModelProviderGroup,
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
WorkspaceId, WorkspaceView,
@@ -408,9 +408,9 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues
* Compute the render intent for a tool/call or tool/result event through the
* presenters registered at this moment; every other event type gets none. A
* result's presenter needs its call's parsed args — `argsFor` supplies them
* (live: the per-session call table; history: an in-page backscan), returning
* undefined when the pairing is unavailable (e.g. the call fell off the page),
* which soft-falls to no view. Presenter or JSON.parse throws also soft-fall:
* (live: the per-session call table; history: the full-log pairing index),
* returning undefined when the pairing is unavailable, which soft-falls to no
* view. Presenter or JSON.parse throws also soft-fall:
* the client's documented default (generic JSON card) covers every miss.
*/
function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined {
@@ -442,10 +442,8 @@ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) =>
}
/**
* Resolve a tool/result's call pairing by scanning a window of events backwards
* for the matching tool/call. Used by the history path (the page is the
* window — a cross-page pairing soft-falls to no view) and by live-path table
* misses after a reconnect-eviction.
* Resolve a tool/result's call pairing by scanning a live session backwards
* for the matching tool/call after the open-call table missed.
*/
function backscanArgs(events: readonly SessionEvent[], callId: string): { name: string; args: unknown } | undefined {
for (let i = events.length - 1; i >= 0; i--) {
@@ -463,6 +461,34 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name:
return undefined
}
/** Index durable call metadata once so every history result keeps its pair across page cuts. */
function historyCallIndex(events: readonly SessionEvent[]): ReadonlyMap<string, HistoryToolCall> {
const calls = new Map<string, HistoryToolCall>()
for (const event of events) {
if (event.type !== 'tool/call') continue
calls.set(String(event.data.callId), {
name: event.data.name,
arguments: event.data.arguments,
time: event.time,
})
}
return calls
}
/** Parse one indexed history pair for a result presenter, soft-falling malformed arguments. */
function historyArgs(
calls: ReadonlyMap<string, HistoryToolCall>,
callId: string,
): { name: string; args: unknown } | undefined {
const call = calls.get(callId)
if (call === undefined) return undefined
try {
return { name: call.name, args: JSON.parse(call.arguments) }
} catch {
return undefined
}
}
/** Render one detached history page through the same presenter path as ordinary history. */
function historyPage(
ctx: Context,
@@ -471,10 +497,18 @@ function historyPage(
maxMessages: number | undefined,
): { events: HistoryEntry[]; hasMore: boolean } {
const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
const calls = historyCallIndex(events)
return {
events: page.events.map((event) => {
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
return { event, ...view === undefined ? {} : { view } }
const view = viewFor(ctx, event, callId => historyArgs(calls, callId))
const call = event.type === 'tool/result'
? calls.get(String(event.data.message.source.callId))
: undefined
return {
event,
...view === undefined ? {} : { view },
...call === undefined ? {} : { call },
}
}),
hasMore: page.hasMore,
}

View File

@@ -36,7 +36,7 @@ export interface ApiProxy {
// ---- Domain interfaces and payload entities ----
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
HistoryEntry, HistoryToolCall, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, QueueAction, SessionModels, SessionProjectionsBlock, SessionSearchItem,
SessionsApi, SessionSummary,
} from './sessions.ts'

View File

@@ -11,7 +11,7 @@ import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
HistoryEntry, HistoryToolCall, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
} from './sessions.ts'
import type { ToolEventView } from './events.ts'
@@ -193,10 +193,18 @@ export const toolEventViewSchema = z.discriminatedUnion('for', [
z.object({ for: z.literal('result'), view: z.looseObject({ card: z.string() }) }),
]) as unknown as z.ZodType<ToolEventView>
/** One session.history item: the session event plus its optional host-computed tool view. */
/** Paired tool/call metadata carried with a paged tool/result. */
export const historyToolCallSchema: z.ZodType<Wire<HistoryToolCall>> = z.object({
name: z.string(),
arguments: z.string(),
time: z.number(),
})
/** One session.history item: raw event plus optional host-computed tool annotations. */
export const historyEntrySchema: z.ZodType<Wire<HistoryEntry>> = z.object({
event: sessionEventSchema,
view: toolEventViewSchema.optional(),
call: historyToolCallSchema.optional(),
}) as unknown as z.ZodType<Wire<HistoryEntry>>
/**

View File

@@ -26,14 +26,26 @@ declare module '@deepseek-ai/dsh-llm' {
}
}
/** Paired tool/call metadata carried beside a paged result whose call may be outside the page. */
export interface HistoryToolCall {
/** Registered tool name used for keyed presentation dispatch. */
name: string
/** Exact durable arguments JSON from the paired tool/call. */
arguments: string
/** Unix epoch ms of the paired tool/call event. */
time: number
}
/**
* One history page entry: the raw event plus the optional host-computed render
* intent (same semantics as the mux frame's `view` slot — a pagination-time
* derivation, never persisted).
* One history page entry: the raw event plus optional host-computed render
* intent and result pairing. Both annotations are pagination-time derivations,
* never persisted; `call` preserves a tool/result's identity when its call
* event lies outside this page.
*/
export interface HistoryEntry {
event: SessionEvent
view?: ToolEventView
call?: HistoryToolCall
}
/**

View File

@@ -231,9 +231,50 @@ describe('mux live view computation', () => {
]))
expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } })
expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
expect(byKey.get('tool/result:h-term')?.call).toEqual({
name: 'term', arguments: '{"cmd":"ls"}', time: byKey.get('tool/call:h-term')?.event.time,
})
expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false)
expect('call' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false)
expect('view' in (byKey.get('tool/result:h-bad') ?? {})).toBe(false)
expect(byKey.get('tool/result:h-bad')?.call?.arguments).toBe('{broken')
expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
expect(byKey.get('tool/result:h-plain')?.call?.name).toBe('plain')
})
it('carries a result pair and computes its view when the call is outside the history page', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const session = ctx.sessions.create()
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1 })
const call = session.append('tool/call', {
turn: 1, step: 1, callId: CallId('cross-page'), name: 'term', arguments: '{"cmd":"tail"}',
})
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', { turn: 2 })
appendUserText(session, 'newer message cuts the page')
const result = session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('cross-page'),
content: [{ type: 'text', text: 'late result' }],
isError: false,
}),
}, { surfaceOp: 'append' })
const response = await api.sessions.history({
rpcId: RpcId('t-hist-cross-page'),
payload: { sessionId: session.id, maxMessages: 1 },
})
if (!response.result.ok) throw new Error('unreachable')
const entries = response.result.value.events
expect(entries.some(entry => entry.event.seq === call.seq)).toBe(false)
const entry = entries.find(candidate => candidate.event.seq === result.seq)
expect(entry).toMatchObject({
call: { name: 'term', arguments: '{"cmd":"tail"}', time: call.time },
view: { for: 'result', view: { card: 'terminal', output: 'done' } },
})
})
it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => {

View File

@@ -190,10 +190,16 @@ describe('sessions domain schemas', () => {
expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3)
expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow()
expect(sessionHistoryValueSchema.parse({
events: [],
events: [{
event: { type: 'tool/result', seq: 3, time: 30, data: {} },
call: { name: 'skill', arguments: '{"name":"review"}', time: 20 },
}],
hasMore: false,
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}).hasMore).toBe(false)
})).toMatchObject({
events: [{ call: { name: 'skill', arguments: '{"name":"review"}', time: 20 } }],
hasMore: false,
})
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionModelsValueSchema.parse({
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' },