refactor(goal): call entity methods through remote API

This commit is contained in:
imccyu
2026-08-07 10:47:17 +08:00
parent 2f619b1b88
commit da1ebd2b68
16 changed files with 140 additions and 204 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/client/ui-goal/README.md
README.md: 0ea00b8bf9b07f02b5df0f7b3e7d3d9c6f109fde
README.zh.md: 70bf443118e5d2b1ce46e7bc1479bf932507b3f9
README.md: b99aaf624a7d669879ba668938ee455e3cdc68ad
README.zh.md: 3d823d013066bc912398f61c85553887e05ca3b4

View File

@@ -2,13 +2,13 @@
English | [中文](README.zh.md)
Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing.
Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.api.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing.
The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
## Model Experience
Indirectly, through the `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPCs the strip's verbs submit: each accepted mutation commits in a durable `agent/inbox/spliced` insertion, which the goal projection folds immediately, and queues a `goal/change` context message. The model sees that context only if a later pre-step admits it; discarding the queued message does not roll back the projected state. The strip itself adds no prompt content.
Indirectly, through the `goals/edit`, `goals/pause`, `goals/resume`, and `goals/clear` Remote methods the strip invokes: each accepted mutation commits in a durable `agent/inbox/spliced` insertion, which the goal projection folds immediately, and queues a `goal/change` context message. The model sees that context only if a later pre-step admits it; discarding the queued message does not roll back the projected state. The strip itself adds no prompt content.
#### KV Cache effect

View File

@@ -2,13 +2,13 @@
[English](README.md) | 中文
Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片order 10位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词edit / pause / resume / clear `goal.*` 协议域——active 的 goal 提供暂停动作paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片order 10位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词edit / pause / resume / clear `ctx.api.goals` 调用——active 的 goal 提供暂停动作paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref将 Remote 调用的拒绝错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
`/client` 的导出接口包括插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
## 模型体验
间接影响:条带动词提交的 `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPC 每次被接受后,变更都会在持久 `agent/inbox/spliced` 插入项中提交goal 投影会立即折叠该插入项,同时将一条 `goal/change` 上下文消息排队。只有后续 pre-step 准入该上下文时,模型才会看到它;丢弃已排队的消息不会回滚投影状态。条带自身不添加任何提示词内容。
间接影响:条带通过调用 `goals/edit``goals/pause``goals/resume``goals/clear` Remote 方法提交变更;每次被接受变更都会在持久 `agent/inbox/spliced` 插入项中提交goal 投影会立即折叠该插入项,同时将一条 `goal/change` 上下文消息排队。只有后续 pre-step 准入该上下文时,模型才会看到它;丢弃已排队的消息不会回滚投影状态。条带自身不添加任何提示词内容。
#### KV Cache 影响

View File

@@ -25,6 +25,7 @@
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-remotes",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation"
],
@@ -36,8 +37,8 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-remotes": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
@@ -48,8 +49,8 @@
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-remotes": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",

View File

@@ -4,19 +4,19 @@
* arrives through `useProjection('goal')` (seeded by the history tail page,
* updated by session/projection frames), so this plugin owns no store, no
* refresh chain, and no event listener. The inject face carries only the
* three mutation verbs (edit/resume/clear over the goal.* wire domain);
* four mutation verbs through the generated Goal Remote API;
* their CAS ref reads the session's current projected value at call time.
* Goal creation stays on the /goal host command.
*/
import type { ConnectionHandle, GoalRef, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { RpcResult } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the generated Remote API and ctx.api merge through the Client assembly boundary.
import type {} from '@deepseek-ai/dsh-client-remotes/client'
// Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only: the `goal` SessionProjectionMap key merge (single source, the domain's pure outlet).
import type { GoalProjection } from '@deepseek-ai/dsh-goal/client'
import type { GoalProjection, GoalRef } from '@deepseek-ai/dsh-goal/client'
import type { GoalActionResult, GoalBarActions } from './slots.ts'
import { GoalDock } from './GoalBar.tsx'
import { en, zh, type GoalKey } from './locales.ts'
@@ -35,13 +35,32 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Dictionary namespace owned by this plugin. */
const NS = 'goal'
/** Required services: slots for the dock entry, sessions for the projected ref, connection for the wire verbs, locale for the copy. */
export const inject = ['slots', 'sessions', 'connection', 'locale']
/** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */
export const inject = ['slots', 'sessions', 'api', 'locale']
/** Map one settled RPC result onto the strip's inline-render shape. */
function settle<T>(result: RpcResult<T>): GoalActionResult {
if (result.ok) return { ok: true }
return { ok: false, error: { code: result.error.code, message: result.error.message } }
/** Map one generated Remote call onto the strip's inline-render shape. */
async function settle(result: Promise<unknown>): Promise<GoalActionResult> {
try {
await result
return { ok: true }
} catch (error) {
const cause = error instanceof Error ? error.cause : undefined
if (isRemoteError(cause)) return { ok: false, error: { code: cause.code, message: cause.message } }
return {
ok: false,
error: {
code: 'internal',
message: error instanceof Error ? error.message : 'goal mutation failed',
},
}
}
}
function isRemoteError(value: unknown): value is { readonly code: string; readonly message: string } {
return value !== null
&& typeof value === 'object'
&& typeof (value as { code?: unknown }).code === 'string'
&& typeof (value as { message?: unknown }).message === 'string'
}
/**
@@ -51,7 +70,7 @@ function settle<T>(result: RpcResult<T>): GoalActionResult {
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries')
const { goals } = (ctx.get('connection') as ConnectionHandle).api
const { goals } = ctx.api
const sessions = ctx.sessions
@@ -77,22 +96,22 @@ export function apply(ctx: ClientContext): void {
onEdit: async (objective) => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle((await goals.edit({ sessionId, ref, objective })).result)
return settle(goals.edit(sessionId, ref, { objective }))
},
onPause: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle((await goals.pause({ sessionId, ref })).result)
return settle(goals.pause(sessionId, ref))
},
onResume: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle((await goals.resume({ sessionId, ref })).result)
return settle(goals.resume(sessionId, ref))
},
onClear: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle((await goals.clear({ sessionId, ref })).result)
return settle(goals.clear(sessionId, ref))
},
}),
}, GoalDock))

View File

@@ -1,11 +1,11 @@
// @vitest-environment jsdom
/**
* ui-goal browser half on a real cordis Context with fake slots/connection/
* ui-goal browser half on a real cordis Context with fake slots/api/
* sessions faces: the plugin registers the GoalBar dock entry at
* conversation.input.dock, the inject face's three verbs read the CAS ref
* conversation.input.dock, the inject face's four verbs read the CAS ref
* from the session's CURRENT projected value at call time (no fence — the
* RPC's compare-and-set is the guard), a missing projection short-circuits
* to the no-current-goal error without touching the wire, and RPC errors
* Remote method's compare-and-set is the guard), a missing projection short-circuits
* to the no-current-goal error without touching the wire, and Remote errors
* map onto the inline-render result shape. Registration disposal rides the
* plugin fiber (HMR safety). The node half and the invariant companion are
* exercised over the same Context.
@@ -44,27 +44,32 @@ function makeProjection(revision = 3): GoalProjection {
}
}
/** Boot the plugin over fake faces; goals verbs record payloads and answer per the script. */
async function bench(options: { projection?: GoalProjection | null | undefined; failWith?: { code: string; message: string } } = {}) {
/** Boot the plugin over fake faces; Goal Remote methods record arguments and answer per the script. */
async function bench(options: {
projection?: GoalProjection | null | undefined
failWith?: { code: string; message: string }
rejectWith?: unknown
} = {}) {
const ctx = new Context()
const calls: { method: string; payload: unknown }[] = []
const calls: { method: string; args: unknown[] }[] = []
function answer<T>(method: string, value: T) {
return (payload: unknown) => {
calls.push({ method, payload })
return Promise.resolve({
result: options.failWith === undefined
? { ok: true as const, value }
: { ok: false as const, error: { ...options.failWith, details: {} } },
})
return (...args: unknown[]) => {
calls.push({ method, args })
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the defensive scenario under test.
if ('rejectWith' in options) return Promise.reject(options.rejectWith)
if (options.failWith !== undefined) {
return Promise.reject(new Error(`Remote ${method} failed`, { cause: options.failWith }))
}
return Promise.resolve(value)
}
}
const ref = { id: 'g-1', revision: 3 }
ctx.provide('connection', { api: { goals: {
edit: answer('goal.edit', { ref }),
pause: answer('goal.pause', { ref }),
resume: answer('goal.resume', { ref }),
clear: answer('goal.clear', { cleared: true as const }),
} } })
ctx.provide('api', { goals: {
edit: answer('goals/edit', { ref }),
pause: answer('goals/pause', { ref }),
resume: answer('goals/resume', { ref }),
clear: answer('goals/clear', ref),
} })
await ctx.plugin(SlotsService).await()
ctx.slots.register({
name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } },
@@ -113,12 +118,12 @@ describe('ui-goal browser plugin', () => {
expect(await verbs.onPause()).toEqual({ ok: true })
expect(await verbs.onResume()).toEqual({ ok: true })
expect(await verbs.onClear()).toEqual({ ok: true })
expect(b.calls.map(c => c.method)).toEqual(['goal.edit', 'goal.pause', 'goal.resume', 'goal.clear'])
expect(b.calls.map(c => c.method)).toEqual(['goals/edit', 'goals/pause', 'goals/resume', 'goals/clear'])
const ref = { id: 'g-1', revision: 5 }
expect(b.calls[0]?.payload).toEqual({ sessionId: 's1', ref, objective: 'New objective' })
expect(b.calls[1]?.payload).toEqual({ sessionId: 's1', ref })
expect(b.calls[2]?.payload).toEqual({ sessionId: 's1', ref })
expect(b.calls[3]?.payload).toEqual({ sessionId: 's1', ref })
expect(b.calls[0]?.args).toEqual(['s1', ref, { objective: 'New objective' }])
expect(b.calls[1]?.args).toEqual(['s1', ref])
expect(b.calls[2]?.args).toEqual(['s1', ref])
expect(b.calls[3]?.args).toEqual(['s1', ref])
})
it('a null or absent projection short-circuits every verb without touching the wire', async () => {
@@ -133,13 +138,26 @@ describe('ui-goal browser plugin', () => {
}
})
it('maps a settled RPC error onto the inline-render shape', async () => {
it('maps a Remote error onto the inline-render shape', async () => {
const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision' } })
await b.fiber.await()
const verbs = b.entry()!.inject!(sid('s1'))
expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision' } })
})
it.each([
[new Error('connection closed'), 'connection closed'],
['connection closed', 'goal mutation failed'],
[new Error('invalid Remote failure', { cause: null }), 'invalid Remote failure'],
[new Error('invalid Remote failure', { cause: { code: 1, message: 'stale revision' } }), 'invalid Remote failure'],
[new Error('invalid Remote failure', { cause: { code: 'internal', message: 1 } }), 'invalid Remote failure'],
])('maps an unstructured rejection onto an internal error', async (rejection, message) => {
const b = await bench({ projection: makeProjection(), rejectWith: rejection })
await b.fiber.await()
const verbs = b.entry()!.inject!(sid('s1'))
expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message } })
})
it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => {
const b = await bench()
await b.fiber.await()

View File

@@ -12,10 +12,10 @@
"path": "../../../vendor/cordis"
},
{
"path": "../connection"
"path": "../locale"
},
{
"path": "../locale"
"path": "../remotes"
},
{
"path": "../runtime"