fix(web): address skill row review feedback
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 页永不携带该块,未装注册表的组合则两个面都不提供。
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>>
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user