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

@@ -55,6 +55,7 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-message-feedback": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
@@ -69,6 +70,7 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-message-feedback": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",

View File

@@ -7,6 +7,7 @@
* @module @deepseek-ai/dsh-client-ui-feedback/client/controller
*/
import type { RemoteResult } from '@deepseek-ai/dsh-type-meta'
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { MessageId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type {
@@ -17,21 +18,26 @@ import type {
MessageFeedbackRating,
} from '@deepseek-ai/dsh-message-feedback/types'
/** The three Remote calls this controller needs, named without the transport. */
/**
* The three Remote calls this controller needs. The generated face wraps every
* business result in {@link RemoteResult}: a carrier failure arrives as the
* `ok: false` branch rather than a rejection, so this controller reads one
* envelope and never wraps a call to recover a transport error.
*/
export interface MessageFeedbackRemote {
list: (request: { sessionId: SessionId }) => Promise<MessageFeedbackListResult>
list: (request: { sessionId: SessionId }) => Promise<RemoteResult<MessageFeedbackListResult>>
put: (request: {
sessionId: SessionId
messageId: MessageId
rating: MessageFeedbackRating
note?: string
ifVersion: MessageFeedbackItem['version'] | null
}) => Promise<MessageFeedbackPutResult>
}) => Promise<RemoteResult<MessageFeedbackPutResult>>
delete: (request: {
sessionId: SessionId
messageId: MessageId
ifVersion: MessageFeedbackItem['version']
}) => Promise<MessageFeedbackDeleteResult>
}) => Promise<RemoteResult<MessageFeedbackDeleteResult>>
}
/** Load state of the one list read that seeds every per-message control. */
@@ -87,6 +93,11 @@ function fail(code: string): FeedbackActionResult {
return { ok: false, error: { code, message: describe(code) } }
}
/** Carrier failure rendered with the Host-supplied code and message. */
function carrierFailure(error: { code: string; message: string }): FeedbackActionResult {
return { ok: false, error: { code: error.code, message: error.message } }
}
/**
* Per-session feedback object layer. One instance backs every per-message
* control in that Session, so a single list read seeds them all.
@@ -231,13 +242,15 @@ export class FeedbackController implements HostObservable<FeedbackView> {
note: string | undefined,
observed: MessageFeedbackItem | undefined,
): Promise<FeedbackActionResult> {
const result = await this.remote.put({
const carried = await this.remote.put({
sessionId: this.sessionId,
messageId,
rating,
...(note === undefined ? {} : { note }),
ifVersion: observed?.version ?? null,
})
if (!carried.ok) return carrierFailure(carried.error)
const result = carried.value
if (result.ok) {
this.commit(messageId, result.value)
return OK
@@ -251,11 +264,13 @@ export class FeedbackController implements HostObservable<FeedbackView> {
messageId: MessageId,
observed: MessageFeedbackItem,
): Promise<FeedbackActionResult> {
const result = await this.remote.delete({
const carried = await this.remote.delete({
sessionId: this.sessionId,
messageId,
ifVersion: observed.version,
})
if (!carried.ok) return carrierFailure(carried.error)
const result = carried.value
if (result.ok) {
this.commit(messageId, null)
return OK
@@ -273,8 +288,13 @@ export class FeedbackController implements HostObservable<FeedbackView> {
/** Fetch the whole sidecar and publish it as the seeded view. */
private async load(): Promise<FeedbackActionResult> {
try {
const result = await this.remote.list({ sessionId: this.sessionId })
const carried = await this.remote.list({ sessionId: this.sessionId })
if (this.disposed) return OK
if (!carried.ok) {
this.publish({ status: 'error', items: this.view.items, error: carried.error.message })
return carrierFailure(carried.error)
}
const result = carried.value
if (!result.ok) {
this.publish({ status: 'error', items: this.view.items, error: describe(result.error.code) })
return fail(result.error.code)

View File

@@ -36,18 +36,20 @@ const seeded: MessageFeedbackItem = {
async function bench() {
const ctx = new Context()
const calls: { method: string; request: unknown }[] = []
// The generated face wraps every business result in the carrier envelope.
const carried = <T,>(value: T) => Promise.resolve({ ok: true as const, value })
const messageFeedback = {
list: (request: unknown) => {
calls.push({ method: 'list', request })
return Promise.resolve({ ok: true as const, value: { items: [seeded] } })
return carried({ ok: true as const, value: { items: [seeded] } })
},
put: (request: unknown) => {
calls.push({ method: 'put', request })
return Promise.resolve({ ok: true as const, value: seeded })
return carried({ ok: true as const, value: seeded })
},
delete: (request: unknown) => {
calls.push({ method: 'delete', request })
return Promise.resolve({ ok: true as const, value: { absent: true as const } })
return carried({ ok: true as const, value: { absent: true as const } })
},
}
class RemoteService extends Service {

View File

@@ -30,24 +30,34 @@ function item(overrides: Partial<MessageFeedbackItem> = {}): MessageFeedbackItem
}
/** A recording fake Remote whose per-method answers are scripted per call. */
function fakeRemote(script: Partial<MessageFeedbackRemote> = {}) {
type Script = {
list?: (request: unknown) => Promise<unknown>
put?: (request: unknown) => Promise<unknown>
delete?: (request: unknown) => Promise<unknown>
}
/**
* A recording fake Remote. Scripts return the *business* result; this wraps it
* in the carrier envelope the generated face uses, so specs stay readable. A
* script may also return an already-enveloped `{ok:false,error:{code,message,
* details}}` to exercise a carrier failure.
*/
function fakeRemote(script: Script = {}) {
const calls: { method: string; request: unknown }[] = []
const record = <K extends keyof MessageFeedbackRemote>(
method: K,
real: MessageFeedbackRemote[K] | undefined,
fallback: Awaited<ReturnType<MessageFeedbackRemote[K]>>,
): MessageFeedbackRemote[K] =>
((request: Parameters<MessageFeedbackRemote[K]>[0]) => {
const isCarrier = (v: unknown): boolean =>
typeof v === 'object' && v !== null && 'ok' in v && v.ok === false
&& 'error' in v && 'details' in ((v as { error: object }).error ?? {})
const record = (method: 'list' | 'put' | 'delete', real: Script[keyof Script], fallback: unknown) =>
(request: never): Promise<never> => {
calls.push({ method, request })
return real === undefined
? Promise.resolve(fallback)
: (real as (input: typeof request) => ReturnType<MessageFeedbackRemote[K]>)(request)
}) as MessageFeedbackRemote[K]
const remote: MessageFeedbackRemote = {
const business = real === undefined ? Promise.resolve(fallback) : real(request)
return business.then(v => (isCarrier(v) ? v : { ok: true, value: v })) as Promise<never>
}
const remote = {
list: record('list', script.list, { ok: true, value: { items: [] } }),
put: record('put', script.put, { ok: true, value: item() }),
delete: record('delete', script.delete, { ok: true, value: { absent: true } }),
}
} as unknown as MessageFeedbackRemote
return { remote, calls }
}
@@ -323,7 +333,7 @@ describe('FeedbackController', () => {
it('swallows a rejected list that settles after disposal', async () => {
let reject = (): void => {}
const gate = new Promise<void>((_resolve, rejectFn) => { reject = () => { rejectFn(new Error('late')) } })
const { remote } = fakeRemote({ list: () => gate as Promise<never> })
const { remote } = fakeRemote({ list: () => gate })
const controller = new FeedbackController(remote, SESSION)
const pending = controller.ensure()
@@ -612,4 +622,53 @@ describe('FeedbackController', () => {
expect(await pending).toMatchObject({ ok: false, error: { code: 'disposed' } })
expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
})
it('renders a carrier failure from the Remote envelope', async () => {
// The generated face folds transport faults into ok:false with a
// RemoteFailure, so the controller reads them as values, not rejections.
const { remote } = fakeRemote({
list: () => Promise.resolve({
ok: false,
error: { code: 'carrier-closed', message: 'socket closed', details: {} },
}),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.ensure()).toEqual({
ok: false,
error: { code: 'carrier-closed', message: 'socket closed' },
})
expect(controller.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' })
})
it('renders a carrier failure on a mutation without touching the view', async () => {
const { remote } = fakeRemote({
put: () => Promise.resolve({
ok: false,
error: { code: 'carrier-closed', message: 'socket closed', details: {} },
}),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.rate(MSG, 'positive')).toEqual({
ok: false,
error: { code: 'carrier-closed', message: 'socket closed' },
})
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
})
it('renders a carrier failure on a delete', async () => {
const { remote } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
delete: () => Promise.resolve({
ok: false,
error: { code: 'carrier-closed', message: 'socket closed', details: {} },
}),
})
const controller = new FeedbackController(remote, SESSION)
await controller.ensure()
expect(await controller.clear(MSG)).toMatchObject({ ok: false, error: { code: 'carrier-closed' } })
expect(controller.getSnapshot().items.has(MSG)).toBe(true)
})
})

View File

@@ -21,7 +21,7 @@
"path": "../../support/invariants"
},
{
"path": "../connection"
"path": "../../typert/type-meta"
},
{
"path": "../locale"