Merge remote-tracking branch 'origin/master' into feat/web-message-feedback-ui

Adapt to two contract changes master introduced:

- The generated Remote face now wraps every business result in
  RemoteResult, folding carrier failures into an ok:false branch instead
  of rejecting. The controller reads that envelope at its three call
  sites and maps a carrier failure onto the same settled shape the
  controls already render; three specs cover the new branch.
- Client packages split their tsconfig into host and client halves, and
  the host aggregate now compiles any test not named *.client.spec.*.
  Rename this package's specs to the client convention and drop the
  ../connection project reference, which pointed at a solution file that
  no longer carries the client sources.

Keep master's mount loop with its rollback-on-failure in api-remotes and
add messageFeedbackRemote to it.
This commit is contained in:
Chinesezjc
2026-08-12 10:43:23 +08:00
parent 47f254a252
commit b462d5fd69
507 changed files with 3130 additions and 2238 deletions

View File

@@ -40,31 +40,6 @@ const NS = 'goal'
/** Required services for the Goal dock, command-input projection, Remote mutations, and copy. */
export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', 'conversationEvents']
/** Map one generated Remote call, including synchronous namespace lookup failures, to the fields rendered by the goal strip. */
async function settle(invoke: () => Promise<unknown>): Promise<GoalActionResult> {
try {
await invoke()
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'
}
/**
* Client plugin body: the GoalBar dock entry with its mutation verbs.
* @param ctx - client root context.
@@ -91,7 +66,7 @@ export function apply(ctx: ClientContext): void {
const noCurrentGoal: GoalActionResult = {
ok: false,
error: { code: 'no-current-goal', message: 'no current goal to mutate' },
error: { code: 'no-current-goal', message: 'no current goal to mutate', details: {} },
}
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
@@ -103,22 +78,22 @@ export function apply(ctx: ClientContext): void {
onEdit: async (objective) => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle(() => ctx.remote.goals.edit(sessionId, ref, { objective }))
return await ctx.remote.goals.edit(sessionId, ref, { objective })
},
onPause: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle(() => ctx.remote.goals.pause(sessionId, ref))
return await ctx.remote.goals.pause(sessionId, ref)
},
onResume: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle(() => ctx.remote.goals.resume(sessionId, ref))
return await ctx.remote.goals.resume(sessionId, ref)
},
onClear: async () => {
const ref = refOf(sessionId)
if (ref === undefined) return noCurrentGoal
return settle(() => ctx.remote.goals.clear(sessionId, ref))
return await ctx.remote.goals.clear(sessionId, ref)
},
}),
}, GoalDock))

View File

@@ -7,10 +7,14 @@
* (callbacks from inject, live state from useProjection).
*/
/** Settled outcome of one goal mutation, rendered inline by the strip. */
export type GoalActionResult =
| { ok: true }
| { ok: false; error: { code: string; message: string } }
import type { RemoteResult } from '@deepseek-ai/dsh-type-meta'
/**
* Settled outcome of one goal mutation, rendered inline by the strip. The
* strip renders the failure only — the mutated goal arrives through the
* projection — so the success value stays unread here.
*/
export type GoalActionResult = RemoteResult<unknown>
/** Injected business face of the GoalBar dock entry: the mutation verbs (function properties: the strip destructures them freely). */
export interface GoalBarActions {

View File

@@ -5,8 +5,8 @@
* 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
* 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
* to the no-current-goal error without touching the wire, and a Remote failure
* reaches the strip verbatim. Registration disposal rides the
* plugin fiber (HMR safety). The node half and the invariant companion are
* exercised over the same Context.
*/
@@ -48,8 +48,7 @@ function makeProjection(revision = 3): GoalProjection {
/** 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
failWith?: { code: string; message: string; details: object }
} = {}) {
const ctx = new Context()
const calls: { method: string; args: unknown[] }[] = []
@@ -57,12 +56,8 @@ async function bench(options: {
function answer<T>(method: string, value: T) {
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)
if (options.failWith !== undefined) return Promise.resolve({ ok: false, error: options.failWith })
return Promise.resolve({ ok: true, value })
}
}
const ref = { id: 'g-1', revision: 3 }
@@ -139,10 +134,13 @@ describe('ui-goal browser plugin', () => {
const b = await bench({ projection: makeProjection(5) })
await b.fiber.await()
const verbs = b.entry()!.inject!(sid('s1'))
expect(await verbs.onEdit('New objective')).toEqual({ ok: true })
expect(await verbs.onPause()).toEqual({ ok: true })
expect(await verbs.onResume()).toEqual({ ok: true })
expect(await verbs.onClear()).toEqual({ ok: true })
// The strip forwards the Remote value verbatim; `answered` is the fake's
// reply, unrelated to the CAS ref the call carries.
const answered = { id: 'g-1', revision: 3 }
expect(await verbs.onEdit('New objective')).toEqual({ ok: true, value: { ref: answered } })
expect(await verbs.onPause()).toEqual({ ok: true, value: { ref: answered } })
expect(await verbs.onResume()).toEqual({ ok: true, value: { ref: answered } })
expect(await verbs.onClear()).toEqual({ ok: true, value: answered })
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]?.args).toEqual(['s1', ref, { objective: 'New objective' }])
@@ -157,18 +155,22 @@ describe('ui-goal browser plugin', () => {
const verbs = b.entry()!.inject!(sid('s1'))
b.remountGoals()
expect(await verbs.onPause()).toEqual({ ok: true })
expect(await verbs.onPause()).toEqual({ ok: true, value: { ref: { id: 'g-1', revision: 3 } } })
expect(b.calls).toMatchObject([{ method: 'remounted-goals/pause' }])
})
it('settles every verb when the Remote namespace is temporarily absent', async () => {
it('rejects every verb once the Remote namespace is gone', async () => {
const b = await bench({ projection: makeProjection() })
await b.fiber.await()
const verbs = b.entry()!.inject!(sid('s1'))
b.unmountGoals()
for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) {
expect(result).toMatchObject({ ok: false, error: { code: 'internal' } })
// A missing namespace is an assembly fault, not a call outcome: this plugin
// declares remote.goals in `inject`, so cordis disposes the dock entry along
// with the namespace. Only a React closure that outlived that disposal can
// reach these verbs, so no consumer-side guard renders it as an error.
for (const verb of [() => verbs.onEdit('x'), () => verbs.onPause(), () => verbs.onResume(), () => verbs.onClear()]) {
await expect(verb()).rejects.toThrow(TypeError)
}
expect(b.calls).toHaveLength(0)
})
@@ -179,30 +181,17 @@ describe('ui-goal browser plugin', () => {
await b.fiber.await()
const verbs = b.entry()!.inject!(sid('s1'))
for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) {
expect(result).toEqual({ ok: false, error: { code: 'no-current-goal', message: 'no current goal to mutate' } })
expect(result).toEqual({ ok: false, error: { code: 'no-current-goal', message: 'no current goal to mutate', details: {} } })
}
expect(b.calls).toHaveLength(0)
}
})
it('maps a Remote error onto the inline-render shape', async () => {
const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision' } })
it('forwards a Remote failure to the strip verbatim', async () => {
const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision', details: {} } })
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 } })
expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision', details: {} } })
})
it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => {
@@ -223,10 +212,10 @@ describe('GoalDock adapter', () => {
const projection = makeProjection()
const useProjection = vi.fn(() => projection)
const actions: GoalBarActions = {
onEdit: () => Promise.resolve({ ok: true }),
onPause: () => Promise.resolve({ ok: true }),
onResume: () => Promise.resolve({ ok: true }),
onClear: () => Promise.resolve({ ok: true }),
onEdit: () => Promise.resolve({ ok: true, value: undefined }),
onPause: () => Promise.resolve({ ok: true, value: undefined }),
onResume: () => Promise.resolve({ ok: true, value: undefined }),
onClear: () => Promise.resolve({ ok: true, value: undefined }),
}
const t = makeTranslate(zh, commonZh)
const dockProps = (up: () => GoalProjection | null | undefined) =>

View File

@@ -30,10 +30,10 @@ function makeGoal(over: Partial<GoalSnapshot> = {}): GoalSnapshot {
function makeActions() {
return {
onEdit: vi.fn<GoalBarActions['onEdit']>(() => Promise.resolve({ ok: true })),
onPause: vi.fn<GoalBarActions['onPause']>(() => Promise.resolve({ ok: true })),
onResume: vi.fn<GoalBarActions['onResume']>(() => Promise.resolve({ ok: true })),
onClear: vi.fn<GoalBarActions['onClear']>(() => Promise.resolve({ ok: true })),
onEdit: vi.fn<GoalBarActions['onEdit']>(() => Promise.resolve({ ok: true, value: undefined })),
onPause: vi.fn<GoalBarActions['onPause']>(() => Promise.resolve({ ok: true, value: undefined })),
onResume: vi.fn<GoalBarActions['onResume']>(() => Promise.resolve({ ok: true, value: undefined })),
onClear: vi.fn<GoalBarActions['onClear']>(() => Promise.resolve({ ok: true, value: undefined })),
} satisfies GoalBarActions
}
@@ -75,7 +75,7 @@ describe('GoalBar', () => {
expect(actions.onClear).toHaveBeenCalledTimes(1)
expect(clear.disabled).toBe(true)
await act(async () => { resolveClear({ ok: true }) })
await act(async () => { resolveClear({ ok: true, value: undefined }) })
expect(container.firstChild).toBeNull()
rerender(<GoalBar goal={makeGoal({ id: 'g2' as GoalSnapshot['id'], objective: 'Next goal' })} {...actions} t={t} />)
@@ -178,7 +178,7 @@ describe('GoalBar', () => {
it('keeps the edit draft open and reports a failed save', async () => {
const actions = makeActions()
actions.onEdit.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'stale revision' } })
actions.onEdit.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'stale revision', details: {} } })
render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
fireEvent.click(screen.getByRole('button', { name: '编辑目标' }))
const box = screen.getByRole('textbox', { name: '目标内容' })
@@ -191,12 +191,12 @@ describe('GoalBar', () => {
it('reports resume and clear failures without hiding the goal', async () => {
const actions = makeActions()
actions.onResume.mockResolvedValue({ ok: false, error: { code: 'internal', message: 'resume failed' } })
actions.onResume.mockResolvedValue({ ok: false, error: { code: 'internal', message: 'resume failed', details: {} } })
const { rerender } = render(<GoalBar goal={makeGoal({ phase: 'paused' })} {...actions} t={t} />)
fireEvent.click(screen.getByRole('button', { name: '恢复目标' }))
expect((await screen.findByRole('alert')).textContent).toBe('resume failed (internal)')
actions.onClear.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'clear failed' } })
actions.onClear.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'clear failed', details: {} } })
rerender(<GoalBar goal={makeGoal()} {...actions} t={t} />)
fireEvent.click(screen.getByRole('button', { name: '清除目标' }))
expect((await screen.findByRole('alert')).textContent).toBe('clear failed (agent-busy)')