Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	apps/cli/README.i18n.yaml
#	apps/cli/package.json
#	apps/cli/src/app-cli-entry.ts
#	apps/cli/src/args.ts
#	apps/cli/src/bin.ts
#	apps/cli/src/web.ts
#	apps/cli/tests/args.spec.ts
#	docs/architecture.i18n.yaml
#	docs/config-catalog.md
#	docs/module-graph.md
#	packages/README.i18n.yaml
#	packages/client/connection/README.i18n.yaml
#	packages/client/connection/src/client/api.ts
#	packages/client/connection/src/client/fixture.ts
#	packages/client/connection/src/index.ts
#	packages/client/connection/tests/node-half.spec.ts
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/host/apiproxy/src/api/host.schema.ts
#	packages/host/apiproxy/src/index.ts
#	packages/ui/tui/README.i18n.yaml
#	pnpm-lock.yaml
This commit is contained in:
creatixchu
2026-07-29 14:38:34 +08:00
307 changed files with 10892 additions and 1737 deletions
@@ -57,6 +57,8 @@ function scriptedApi(overrides: {
host: {
describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }),
pickDirectory: r => ok(r, { path: null }),
listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [], truncated: false }),
createDirectory: r => ok(r, { path: '/t/new' }),
openPath: r => ok(r, { opened: true as const }),
...overrides.host,
},
@@ -155,7 +157,7 @@ describe('unary round trip', () => {
it('rejects a method/path mismatch as bad-request', async () => {
const handler = toFetchHandler(scriptedApi())
const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} }
const response = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify(body) })
const response = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
expect(response.status).toBe(200)
const parsed = await response.json() as { result: { ok: boolean; error?: { code: string; message: string } } }
expect(parsed.result.ok).toBe(false)
@@ -166,13 +168,13 @@ describe('unary round trip', () => {
it('rejects a malformed envelope as bad-request, salvaging the rpcId or falling back to the sentinel', async () => {
const handler = toFetchHandler(scriptedApi())
// No salvageable rpcId → the fixed invalid-request sentinel keeps the response a valid ServerResponse.
const noId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify({ nonsense: true }) })
const noId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nonsense: true }) })
expect(noId.status).toBe(200)
const noIdParsed = await noId.json() as { rpcId: string; result: { ok: boolean } }
expect(noIdParsed.result.ok).toBe(false)
expect(noIdParsed.rpcId).toBe('invalid-request')
// A string rpcId in the otherwise-bad body is salvaged for correlation.
const withId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) })
const withId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) })
const withIdParsed = await withId.json() as { rpcId: string; result: { ok: boolean } }
expect(withIdParsed.result.ok).toBe(false)
expect(withIdParsed.rpcId).toBe('salvage-me')
@@ -181,16 +183,34 @@ describe('unary round trip', () => {
it('maps carrier failures to HTTP statuses and the client throws transport failure', async () => {
const handler = toFetchHandler(scriptedApi())
// Unknown method → 404.
const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', body: '{}' })
const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
expect(notFound.status).toBe(404)
// Non-JSON body → 400.
const badBody = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: '{oops' })
const badBody = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' })
expect(badBody.status).toBe(400)
// Impl crash → 500, and through the client that is a throw, not an err result.
const crashing = scriptedApi({ sessions: { list: () => { throw new Error('impl exploded') } } })
await expect(client(crashing).sessions.list({})).rejects.toThrow(/transport failure .*500/)
})
it('rejects non-JSON media types before executing anything (cross-site simple-request fence)', async () => {
const list = vi.fn((r: RpcRequest<{}>) => ok(r, { items: [] }))
const handler = toFetchHandler(scriptedApi({ sessions: { list } }))
const body = JSON.stringify({ type: 'client-request', rpcId: 'r1', method: 'session.list', payload: {} })
// A "simple" browser POST (text/plain — sent with no CORS preflight) is
// refused at the carrier before the impl runs.
const plain = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'text/plain' }, body })
expect(plain.status).toBe(415)
// A string body with no explicit header defaults to text/plain — same fence.
const unlabelled = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body })
expect(unlabelled.status).toBe(415)
expect(list).not.toHaveBeenCalled()
// Media-type parameters pass: the fence checks the type, not the exact string.
const charset = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body })
expect(charset.status).toBe(200)
expect(list).toHaveBeenCalledTimes(1)
})
it('rejects when the transport never resolves within timeoutMs', async () => {
// AbortSignal.timeout is immune to fake timers; a short real timeout keeps this fast.
const never = new InProcessApiClient({
@@ -499,7 +519,7 @@ describe('respond path', () => {
it('returns bad-response for a malformed client-response without reaching the impl', async () => {
const respond = vi.fn()
const handler = toFetchHandler(scriptedApi({ respond }))
const response = await handler.fetch('http://dsh.internal/api/respond', { method: 'POST', body: JSON.stringify({ type: 'client-response' }) })
const response = await handler.fetch('http://dsh.internal/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-response' }) })
expect(await response.json()).toEqual({ accepted: false, reason: 'bad-response' })
expect(respond).not.toHaveBeenCalled()
})