Преглед изворни кода

fix(typert): close remote gateway review gaps

imccyu пре 1 месец
родитељ
комит
1ea5507bf8

+ 2 - 2
.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

+ 1 - 1
.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
 

+ 1 - 1
.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
 

+ 16 - 0
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 })

+ 8 - 4
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)

+ 2 - 1
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) {

+ 1 - 0
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).

+ 5 - 25
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<typeof tsconfigPaths> => 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',

+ 2 - 2
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'],

+ 37 - 0
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,
+      }
+    },
+  }
+}

+ 2 - 2
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'],