diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index bd83c38a3e..57e1054dfc 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 4268539ecf0d40a9e8080e0571992cc2c5d724af -2026-08-02-typert-remote-method-calls.zh.md: f9f426f2fb80c74cb9ebaef15e801ccfcf67e027 +2026-08-02-typert-remote-method-calls.md: 552e910b403312c7c7a1cec3a14c0dc1f9cc4380 +2026-08-02-typert-remote-method-calls.zh.md: 18b8c1687d2c01aa23bb7cb9402fccf85fec333d diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 4268539ecf..552e910b40 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -452,7 +452,7 @@ The Gateway registers only its ownership matcher and RPC handler with Connection The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. `@RemoteContext('agent')` remains the distinct scoped-receiver mode. -Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, cancellation, retries, idempotency, and cross-version protocol compatibility remain outside this decision. +Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index f9f426f2fb..18b8c1687d 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -452,7 +452,7 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H 已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。 -Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、取消、重试、幂等及跨版本协议兼容均不属于本决策。 +Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。 ## Alternatives considered diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 38eea7cf61..2dd5885cc7 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -245,6 +245,22 @@ describe('GoalService creation and replay', () => { }) describe('GoalService mutations', () => { + it('exposes the supported mutation sequence through Remote wrappers', async () => { + const { ctx, agent } = await harness() + const created = ctx.goals.remoteExportCreate(agent, { objective: 'remote lifecycle' }) + const edited = ctx.goals.remoteExportEdit(agent, created.ref, { objective: 'edited remotely' }) + const paused = ctx.goals.remoteExportPause(agent, edited) + const resumed = ctx.goals.remoteExportResume(agent, paused) + const completed = ctx.goals.remoteExportComplete(agent, resumed) + const cleared = ctx.goals.remoteExportClear(agent, completed) + + expect(edited).toMatchObject({ objective: 'edited remotely', revision: 2 }) + expect(paused).toMatchObject({ phase: 'paused', revision: 3 }) + expect(resumed).toMatchObject({ phase: 'active', revision: 4 }) + expect(completed).toMatchObject({ phase: 'complete', revision: 5 }) + expect(cleared).toEqual({ id: created.ref.id, revision: 6 }) + }) + it('edits with compare-and-set revisions and rejects empty edits', async () => { const { ctx, agent } = await harness() const created = ctx.goals.create(agent, { objective: 'old', maxGoalRounds: 4 }) diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index c05bfefb93..6558a7ca47 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -998,14 +998,16 @@ describe('TypertGatewayService', () => { }), }) expect(invalid.status).toBe(200) - await expect(invalid.json()).resolves.toMatchObject({ + const invalidBody = await invalid.json() as unknown + expect(invalidBody).toMatchObject({ type: 'server-response', rpcId: 'rpc-invalid', result: { ok: false, - error: { code: 'internal', message: expect.stringContaining('plain-object args field') }, + error: { code: 'internal' }, }, }) + expect(JSON.stringify(invalidBody)).toContain('plain-object args field') await removeStrict() strictActive = false @@ -1020,14 +1022,16 @@ describe('TypertGatewayService', () => { }), }) expect(withdrawn.status).toBe(200) - await expect(withdrawn.json()).resolves.toMatchObject({ + const withdrawnBody = await withdrawn.json() as unknown + expect(withdrawnBody).toMatchObject({ type: 'server-response', rpcId: 'rpc-withdrawn', result: { ok: false, - error: { code: 'internal', message: expect.stringContaining('strict definition was withdrawn') }, + error: { code: 'internal' }, }, }) + expect(JSON.stringify(withdrawnBody)).toContain('strict definition was withdrawn') const unclaimed = await fetch(`${server.origin}/api/legacy/list`, { method: 'POST' }) expect(unclaimed.status).toBe(404) diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 229dc7affc..d04f38cde5 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -562,7 +562,8 @@ function validateInvocation(descriptor: InvocationDescriptor): void { } validateCodec(parameter.codec, `${descriptor.id} parameter ${parameter.name}`) } - if (descriptor.cancellation !== undefined && descriptor.cancellation.parameter !== 'signal') { + const cancellation = descriptor.cancellation as { readonly parameter: string } | undefined + if (cancellation !== undefined && cancellation.parameter !== 'signal') { throw new Error(`typert: invocation "${descriptor.id}" cancellation parameter must be "signal"`) } if (descriptor.scope !== undefined) { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index f7669eac7e..6d6a76e476 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -601,6 +601,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts', 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', + 'packages/client/remotes/tests/built-lib.e2e.ts', // The worker-entry packages' built bundles: the only automated proof // that lib/index.js resolves its sibling lib/worker.cjs under plain node // (the e2e lane runs unbuilt, so these files self-skip there). diff --git a/vitest.config.ts b/vitest.config.ts index 4c4c668b94..56a5a1575b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,8 +3,7 @@ import { fileURLToPath } from 'node:url' import tsconfigPaths from 'vite-tsconfig-paths' import { resolvePwshPath } from './packages/bash/pwsh-local/src/resolve.ts' import { defineConfig } from 'vitest/config' -import ts from 'typescript' -import { vitestExecArgv } from './vitest.shared.ts' +import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts' // Prints exact `path:line:col` records for every uncovered statement, branch @@ -18,29 +17,6 @@ const uncoveredLocationsReporter = fileURLToPath(new URL('./scripts/coverage-unc // map applies to every test file. paths must win over package exports so built // lib/ never loads a second module-singleton copy. const pathsPlugin = (): ReturnType => tsconfigPaths({ projects: ['./tsconfig.base.json'] }) -const decoratorSyntax = /^\s*@[A-Za-z_$][\w$]*/m - -const standardDecoratorPlugin = () => ({ - name: 'dsh-standard-decorators', - enforce: 'pre' as const, - transform(code: string, id: string) { - const file = id.split('?', 1)[0]! - if (!/\.[cm]?tsx?$/.test(file) || !decoratorSyntax.test(code)) return - const result = ts.transpileModule(code, { - fileName: file, - compilerOptions: { - target: ts.ScriptTarget.ES2024, - module: ts.ModuleKind.ESNext, - jsx: file.endsWith('x') ? ts.JsxEmit.ReactJSX : undefined, - sourceMap: true, - }, - }) - return { - code: result.outputText.replace(/\n?\/\/# sourceMappingURL=.*$/u, '\n'), - map: result.sourceMapText, - } - }, -}) const windowsUnsupportedPackages = process.platform === 'win32' ? [ @@ -203,6 +179,10 @@ export default defineConfig({ 'packages/client/hmr/src/invariant.ts', 'packages/client/connection/src/index.ts', 'packages/client/connection/src/http-bridge.ts', + // This assembly imports generated Host-for-Client code that exists + // only in lib; the post-build built-bin smoke executes both entries. + 'packages/client/remotes/src/index.ts', + 'packages/client/remotes/src/client/index.ts', // Slash/command/input round: per-file gaps deferred with the same // client-lane debt. TODO(gui): cover and remove with the lane above. 'packages/client/connection/src/client/fixture.ts', diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index d8e6aa53a7..f898d2d9da 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -1,6 +1,6 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' -import { vitestExecArgv } from './vitest.shared.ts' +import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts' // Real-API suite, separate because it spends tokens. Each test self-skips without // its provider credential for keyless CI; credentialed workflows preflight the @@ -36,7 +36,7 @@ export default defineConfig({ // Built-artifact e2e suites are unaffected: their built-ness lives in // subprocesses and createRequire lookups, which bypass vite resolution // entirely. - plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })], + plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] }), standardDecoratorPlugin()], test: { execArgv: vitestExecArgv, setupFiles: ['./scripts/test-invariants.ts'], diff --git a/vitest.shared.ts b/vitest.shared.ts index 506fabb380..7c6ca2bee8 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -1,5 +1,42 @@ +import ts from 'typescript' + +const decoratorSyntax = /^\s*@[A-Za-z_$][\w$]*/m + /** * Worker arguments that keep process-wide Web Storage from shadowing jsdom storage. * Node lists the positive spelling in `allowedNodeEnvironmentFlags` for this negatable flag. */ export const vitestExecArgv = process.allowedNodeEnvironmentFlags.has('--webstorage') ? ['--no-webstorage'] : [] + +/** + * Transform standard TypeScript decorators before Vite's default parser sees source files. + * @returns a pre-transform Vite plugin shared by source-mode test configurations. + */ +export function standardDecoratorPlugin() { + return { + name: 'dsh-standard-decorators', + enforce: 'pre' as const, + transform(code: string, id: string) { + const file = id.split('?', 1)[0]! + if (!/\.[cm]?tsx?$/.test(file) || !decoratorSyntax.test(code)) return + const result = ts.transpileModule(code, { + fileName: file, + compilerOptions: { + target: ts.ScriptTarget.ES2024, + module: ts.ModuleKind.ESNext, + jsx: file.endsWith('x') ? ts.JsxEmit.ReactJSX : undefined, + sourceMap: true, + }, + }) + return { + code: result.outputText + .replace( + /^(\s*)(__esDecorate\()/gmu, + '$1/* v8 ignore next -- compiler-synthetic decorator accessors have no source behavior */ $2', + ) + .replace(/\n?\/\/# sourceMappingURL=.*$/u, '\n'), + map: result.sourceMapText, + } + }, + } +} diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index 455ecfb4d4..cfa7d12e17 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -1,7 +1,7 @@ import { availableParallelism } from 'node:os' import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' -import { vitestExecArgv } from './vitest.shared.ts' +import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts' const DEFAULT_SNAPSHOT_MAX_CONCURRENCY = 5 @@ -40,7 +40,7 @@ export default defineConfig({ // Same resolution note as vitest.config.ts: bare workspace names resolve // through the tsconfig.base.json paths facade; the native option cannot do // this (the root tsconfig is a solution file with no paths). - plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })], + plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] }), standardDecoratorPlugin()], test: { execArgv: vitestExecArgv, setupFiles: ['./scripts/test-invariants.ts'],