fix(cordis): skip offline Client inspect queries
Use Gateway event stream availability before dispatch without changing Peer or manifest synchronization APIs. Keep the response deadline after disconnection and cover connected, offline, and disconnect-after-dispatch behavior.
This commit is contained in:
1 parent
f0e97fb7bc
commit
10a4a17ccc
27 files changed
+453
-12
No files matched your search
@@ -184,7 +184,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-api-gateway`
|
||||
|
||||
- `inject`: `typert`
|
||||
- `source`: [`packages/api/gateway/src/index.ts:145`](../packages/api/gateway/src/index.ts)
|
||||
- `source`: [`packages/api/gateway/src/index.ts:146`](../packages/api/gateway/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Gateway transport configuration. */
|
||||
|
||||
@@ -186,7 +186,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-api-gateway`
|
||||
|
||||
- `inject`: `typert`
|
||||
- `source`: [`packages/api/gateway/src/index.ts:145`](../packages/api/gateway/src/index.ts)
|
||||
- `source`: [`packages/api/gateway/src/index.ts:146`](../packages/api/gateway/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Gateway transport configuration. */
|
||||
|
||||
@@ -46,7 +46,7 @@ list(): CordisInspectProviderView[]
|
||||
* @param input - optional lossless JSON input.
|
||||
* @param agent - requesting Agent and scope.
|
||||
* @param signal - tool-call cancellation.
|
||||
* @returns provider JSON data; Client queries time out with the first failure, or reconnect/retry guidance if none was received.
|
||||
* @returns provider JSON data; Client queries fail fast when Gateway has no live Client and retain failure diagnostics on timeout.
|
||||
*/
|
||||
async query( platform: CordisInspectPlatform, providerId: string, methodName: string, input: JsonValue | undefined, agent: Agent, signal: AbortSignal, ): Promise<JsonValue>
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ list(): CordisInspectProviderView[]
|
||||
* @param input - optional lossless JSON input.
|
||||
* @param agent - requesting Agent and scope.
|
||||
* @param signal - tool-call cancellation.
|
||||
* @returns provider JSON data; Client queries time out with the first failure, or reconnect/retry guidance if none was received.
|
||||
* @returns provider JSON data; Client queries fail fast when Gateway has no live Client and retain failure diagnostics on timeout.
|
||||
*/
|
||||
async query( platform: CordisInspectPlatform, providerId: string, methodName: string, input: JsonValue | undefined, agent: Agent, signal: AbortSignal, ): Promise<JsonValue>
|
||||
|
||||
|
||||
@@ -315,6 +315,11 @@ type TypertGatewayErrorCode =
|
||||
interface TypertGateway {
|
||||
/** Carrier adapter shared by WebSocket and in-process transports. */
|
||||
readonly wireStream: TypertGatewayWireStream
|
||||
/**
|
||||
* Check for an active Client event stream.
|
||||
* @returns whether a stream is open and has not been cancelled.
|
||||
*/
|
||||
hasLiveClient(): boolean
|
||||
/**
|
||||
* Register the application-selected forwarded-event source.
|
||||
* @param source - stream factory installed by the Remote assembly.
|
||||
@@ -448,6 +453,12 @@ Source: [`packages/typert/registry/src/service.ts`](../../packages/typert/regist
|
||||
Resolve strict generated definitions or conservative SRC markers against current Cordis Services and Typert providers.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Check for an active Client event stream.
|
||||
* @returns whether a stream is open and has not been cancelled.
|
||||
*/
|
||||
hasLiveClient(): boolean
|
||||
|
||||
/**
|
||||
* Register the sole application-selected forwarded-event source.
|
||||
* @param source - stream factory installed by the Remote assembly.
|
||||
|
||||
@@ -315,6 +315,11 @@ type TypertGatewayErrorCode =
|
||||
interface TypertGateway {
|
||||
/** Carrier adapter shared by WebSocket and in-process transports. */
|
||||
readonly wireStream: TypertGatewayWireStream
|
||||
/**
|
||||
* Check for an active Client event stream.
|
||||
* @returns whether a stream is open and has not been cancelled.
|
||||
*/
|
||||
hasLiveClient(): boolean
|
||||
/**
|
||||
* Register the application-selected forwarded-event source.
|
||||
* @param source - stream factory installed by the Remote assembly.
|
||||
@@ -448,6 +453,12 @@ Source: [`packages/typert/registry/src/service.ts`](../../packages/typert/regist
|
||||
Resolve strict generated definitions or conservative SRC markers against current Cordis Services and Typert providers.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Check for an active Client event stream.
|
||||
* @returns whether a stream is open and has not been cancelled.
|
||||
*/
|
||||
hasLiveClient(): boolean
|
||||
|
||||
/**
|
||||
* Register the sole application-selected forwarded-event source.
|
||||
* @param source - stream factory installed by the Remote assembly.
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d9859219cdb0f367
|
||||
zh: 2a3cfa8cb786e327
|
||||
/deepseek-ai-dsh-api-gateway/host-service-typertgatewayservice-ctx-key-typertgateway:
|
||||
en: 65092c0f187de45c
|
||||
zh: 453bbb826aa7c242
|
||||
en: 5179435c09004549
|
||||
zh: c904462396531947
|
||||
/deepseek-ai-dsh-api-gateway/client-service-clientremote-ctx-key-remote:
|
||||
en: c106f0ca3e547f84
|
||||
zh: 833e30d7151c41c7
|
||||
|
||||
@@ -42,6 +42,8 @@ Stream cancellation and stopping keep read-local state, not a history of deliver
|
||||
|
||||
Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready.
|
||||
|
||||
`hasLiveClient()` checks the existing `$events` records for an uncancelled stream. A cancelled stream does not count even while its iterator is still awaiting cleanup; a bare WebSocket does not count. This synchronous observation does not guarantee delivery or Client provider readiness.
|
||||
|
||||
<a id="client-service-clientremote-ctx-key-remote"></a>
|
||||
## Client service: `ClientRemote` (ctx key: `remote`)
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ Host 下行、Host 上行和 Client 上行泵的取消与停止状态归属单
|
||||
|
||||
Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的流。事件名单、参数校验、每个 Client 的队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。
|
||||
|
||||
`hasLiveClient()` 检查已有 `$events` 记录中是否有未取消的流。已取消的流即使尚未完成 iterator 清理也不计入,单独的 WebSocket 也不计入。这一同步观察不保证后续投递成功或 Client Provider 已就绪。
|
||||
|
||||
<a id="client-service-clientremote-ctx-key-remote"></a>
|
||||
## Client 服务:`ClientRemote`(ctx key:`remote`)
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ interface RegisteredRemoteEventSource {
|
||||
interface RemoteEventClient {
|
||||
readonly id: RemoteEventClientId
|
||||
readonly queue: RemoteEventQueue
|
||||
readonly signal: AbortSignal
|
||||
readonly deliveries: Map<RemoteEventId, PendingRemoteEvent>
|
||||
}
|
||||
|
||||
@@ -276,6 +277,17 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for an active Client event stream.
|
||||
* @returns whether a stream is open and has not been cancelled.
|
||||
*/
|
||||
hasLiveClient(): boolean {
|
||||
for (const client of this.remoteEventClients.values()) {
|
||||
if (!client.signal.aborted) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the sole application-selected forwarded-event source.
|
||||
* @param source - stream factory installed by the Remote assembly.
|
||||
@@ -494,6 +506,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
const client: RemoteEventClient = {
|
||||
id: clientId,
|
||||
queue: new RemoteEventQueue(),
|
||||
signal: lifetime,
|
||||
deliveries: new Map(),
|
||||
}
|
||||
this.remoteEventClients.set(clientId, client)
|
||||
|
||||
@@ -139,6 +139,12 @@ export interface TypertGateway {
|
||||
/** Carrier adapter shared by WebSocket and in-process transports. */
|
||||
readonly wireStream: TypertGatewayWireStream
|
||||
|
||||
/**
|
||||
* Check for an active Client event stream.
|
||||
* @returns whether a stream is open and has not been cancelled.
|
||||
*/
|
||||
hasLiveClient(): boolean
|
||||
|
||||
/**
|
||||
* Register the application-selected forwarded-event source.
|
||||
* @param source - stream factory installed by the Remote assembly.
|
||||
|
||||
@@ -339,6 +339,55 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('Typert Remote streams', () => {
|
||||
it('reports Client event streams without counting a bare WebSocket', async () => {
|
||||
const { ctx } = await setup(true)
|
||||
const source = new RemoteEventSourceProbe()
|
||||
const unregister = ctx.effect(() => ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST))
|
||||
expect(ctx.typertGateway.hasLiveClient()).toBe(false)
|
||||
expect(await acceptsSocket(ctx)).toBe(true)
|
||||
expect(ctx.typertGateway.hasLiveClient()).toBe(false)
|
||||
const first = await openEventClient(ctx, 'first-live')
|
||||
const second = await openEventClient(ctx, 'second-live')
|
||||
expect(ctx.typertGateway.hasLiveClient()).toBe(true)
|
||||
const firstClosed = once(first.socket, 'close')
|
||||
first.socket.close()
|
||||
await firstClosed
|
||||
expect(ctx.typertGateway.hasLiveClient()).toBe(true)
|
||||
const secondClosed = once(second.socket, 'close')
|
||||
second.socket.close()
|
||||
await secondClosed
|
||||
await vi.waitFor(() => { expect(ctx.typertGateway.hasLiveClient()).toBe(false) })
|
||||
await unregister()
|
||||
})
|
||||
|
||||
it('ignores cancelled event streams before their paused consumers finish cleanup', async () => {
|
||||
const { ctx } = await setup(false)
|
||||
const source = new RemoteEventSourceProbe()
|
||||
const unregister = ctx.effect(() => ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST))
|
||||
const abort = new AbortController()
|
||||
const empty = (async function* () {})()
|
||||
const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, empty, undefined, abort.signal)
|
||||
const first = events[Symbol.asyncIterator]()
|
||||
await first.next()
|
||||
expect(ctx.typertGateway.hasLiveClient()).toBe(true)
|
||||
abort.abort()
|
||||
expect(ctx.typertGateway.hasLiveClient()).toBe(false)
|
||||
const replacement = await ctx.typertGateway.wireStream.open('$events', { args: {} }, empty, undefined, new AbortController().signal)
|
||||
const second = replacement[Symbol.asyncIterator]()
|
||||
try {
|
||||
await second.next()
|
||||
expect(ctx.typertGateway.hasLiveClient()).toBe(true)
|
||||
await first.return?.()
|
||||
expect(ctx.typertGateway.hasLiveClient()).toBe(true)
|
||||
await unregister()
|
||||
expect(ctx.typertGateway.hasLiveClient()).toBe(false)
|
||||
} finally {
|
||||
await unregister()
|
||||
await first.return?.()
|
||||
await second.return?.()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([false, true])('accepts WebSockets only after application readiness (already ready: %s)', async (alreadyReady) => {
|
||||
const startup = new StartupProbe()
|
||||
if (alreadyReady) startup.commit()
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: ca844fa88b307274
|
||||
zh: da2032bfe3c1b792
|
||||
/deepseek-ai-dsh-cordis-host-runner/use-this-package/client-inspection:
|
||||
en: cb787a95e31baebc
|
||||
zh: 3a6d569c58d7a963
|
||||
en: b4a2fe9e7aa9dee7
|
||||
zh: 56f55558809970da
|
||||
/deepseek-ai-dsh-cordis-host-runner/use-this-package/what-a-run-does:
|
||||
en: a3c86c081974a710
|
||||
zh: 180e1b7824c070d4
|
||||
|
||||
@@ -45,6 +45,8 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
|
||||
<a id="client-inspection"></a>
|
||||
### Client inspection
|
||||
|
||||
When Gateway is mounted, a Client query with no live event stream fails before dispatch. This check does not subscribe to disconnections: queries already dispatched retain their original deadline. Standalone event transports without Gateway retain the same timeout behavior.
|
||||
|
||||
Client queries accept the first valid page response within `clientInspectTimeoutMs`. A failed page does not prevent another page from answering successfully. If no valid result arrives, the query rejects with the first Client failure or output-validation diagnostic; when no page answers, it asks the caller to open or reconnect the Harness page and retry. Cancellation and registry disposal also end pending queries. Reconnecting a page does not replay a missed request, so retry after the connection is restored. Host queries are unaffected by this timeout.
|
||||
|
||||
### What a run does
|
||||
|
||||
@@ -45,6 +45,8 @@ kind: "package-reference"
|
||||
<a id="client-inspection"></a>
|
||||
### Client 检查
|
||||
|
||||
装载 Gateway 时,没有活动事件流的 Client 查询在发送前就会失败。此检查不订阅断连:已发送的查询仍沿用原期限。没有 Gateway 的独立事件传输保持相同的超时行为。
|
||||
|
||||
Client 查询在 `clientInspectTimeoutMs` 内接受首个有效页面响应。某个页面失败不会阻止其他页面成功回答。若没有有效结果,查询返回首个 Client 错误或输出校验诊断;若没有页面回答,则提示调用方打开或重新连接 Harness 页面后重试。取消和注册表卸载也会结束待处理查询。页面重连不会重放错过的请求,因此需在连接恢复后重试。Host 查询不受此超时影响。
|
||||
|
||||
### run 会做什么
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/cordis-plugin-timer": "workspace:~",
|
||||
"@deepseek-ai/dsh-agent": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:*",
|
||||
"@deepseek-ai/dsh-brand": "workspace:*",
|
||||
"@deepseek-ai/dsh-llm": "workspace:*",
|
||||
"@deepseek-ai/dsh-scope": "workspace:*",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-api-gateway'
|
||||
import { snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { assertSupportedJsonSchema, validateJsonSchemaValue } from '@deepseek-ai/dsh-tools'
|
||||
import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools'
|
||||
@@ -112,7 +113,7 @@ export class CordisInspectRegistryService extends Service {
|
||||
* @param input - optional lossless JSON input.
|
||||
* @param agent - requesting Agent and scope.
|
||||
* @param signal - tool-call cancellation.
|
||||
* @returns provider JSON data; Client queries time out with the first failure, or reconnect/retry guidance if none was received.
|
||||
* @returns provider JSON data; Client queries fail fast when Gateway has no live Client and retain failure diagnostics on timeout.
|
||||
*/
|
||||
async query(
|
||||
platform: CordisInspectPlatform,
|
||||
@@ -179,6 +180,10 @@ export class CordisInspectRegistryService extends Service {
|
||||
const method = findMethod(provider, methodName)
|
||||
validateInput('Client', providerId, method, input)
|
||||
signal.throwIfAborted()
|
||||
const gateway = this.ctx.get('typertGateway')
|
||||
if (gateway !== undefined && !gateway.hasLiveClient()) {
|
||||
throw new Error(`Client inspect query ${providerId}.${methodName} has no connected Harness page. Open or reconnect the Harness page, then retry.`)
|
||||
}
|
||||
const requestId = `inspect-${this.nextRequest++}` as CordisInspectRequestId
|
||||
const request: CordisInspectQueryRequest = {
|
||||
requestId,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Fiber } from '@deepseek-ai/cordis'
|
||||
import TypertGateway from '@deepseek-ai/dsh-api-gateway'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DynamicCordisRunnerService from '../src/index.ts'
|
||||
import { CordisInspectRegistryService } from '../src/inspect-registry.ts'
|
||||
@@ -49,7 +51,7 @@ afterEach(async () => {
|
||||
try {
|
||||
for (const controller of controllers) controller.abort()
|
||||
await Promise.allSettled(results)
|
||||
await fiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
} finally {
|
||||
for (const dispose of disposers) dispose()
|
||||
vi.restoreAllMocks()
|
||||
@@ -73,7 +75,61 @@ function answer(query: ReturnType<typeof start>) {
|
||||
return registry.resolveClientQuery(AGENT_A, query.request.requestId, { ok: true, data: { name: 'theme' } })
|
||||
}
|
||||
|
||||
async function connectGatewayClient(): Promise<AbortController> {
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(TypertGateway)
|
||||
ctx.effect(() => ctx.typertGateway.registerRemoteEvents(async function* (signal) {
|
||||
await new Promise<void>((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) })
|
||||
}, { home: '/fixture' }))
|
||||
const connection = new AbortController()
|
||||
const stream = await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), undefined, connection.signal)
|
||||
const client = stream[Symbol.asyncIterator]()
|
||||
ctx.effect(() => async () => {
|
||||
connection.abort()
|
||||
await client.return?.()
|
||||
})
|
||||
await client.next()
|
||||
expect(ctx.typertGateway.hasLiveClient()).toBe(true)
|
||||
return connection
|
||||
}
|
||||
|
||||
describe('Client inspect completion', () => {
|
||||
it('returns a result when the Gateway has a connected Client', async () => {
|
||||
await connectGatewayClient()
|
||||
registry.syncClientManifest([manifest])
|
||||
const query = start()
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(answer(query)).toEqual({ accepted: true })
|
||||
expect(await query.result).toEqual({ value: { name: 'theme' } })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not dispatch a query when the Gateway has no connected Client', async () => {
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(TypertGateway)
|
||||
const query = start()
|
||||
expect(requests).toEqual([])
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(await query.result).toEqual({
|
||||
error: 'Error: Client inspect query Service.listService has no connected Harness page. Open or reconnect the Harness page, then retry.',
|
||||
})
|
||||
expect(registry.list()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps the response deadline when the Client disconnects after dispatch', async () => {
|
||||
const connection = await connectGatewayClient()
|
||||
const query = start()
|
||||
expect(requests).toHaveLength(1)
|
||||
connection.abort()
|
||||
expect(ctx.typertGateway.hasLiveClient()).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(99)
|
||||
expect(query.settled()).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(await query.result).toEqual({ error: timeout + 'Open or reconnect the Harness page, then retry.' })
|
||||
expect(answer(query)).toEqual({ accepted: false })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('returns the Client catalog failure at the deadline instead of losing it', async () => {
|
||||
const query = start()
|
||||
expect(registry.resolveClientQuery(AGENT_A, query.request.requestId, {
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../api/gateway/tsconfig.host.json"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
|
||||
@@ -3376,6 +3376,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
description: 'Carrier adapter shared by the WebSocket mux and local Host transports.',
|
||||
parameters: [],
|
||||
},
|
||||
{
|
||||
signature: 'hasLiveClient(): boolean',
|
||||
description: 'Check for an active Client event stream.',
|
||||
parameters: [],
|
||||
returns: 'whether a stream is open and has not been cancelled.',
|
||||
},
|
||||
{
|
||||
signature: 'registerRemoteEvents( source: TypertRemoteEventSource, host: RemoteEventHostInfo, ): () => Promise<void>',
|
||||
description: 'Register the sole application-selected forwarded-event source.',
|
||||
|
||||
Generated
+3
@@ -8254,6 +8254,9 @@ importers:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:*
|
||||
version: link:../../core/agent
|
||||
'@deepseek-ai/dsh-api-gateway':
|
||||
specifier: workspace:*
|
||||
version: link:../../api/gateway
|
||||
'@deepseek-ai/dsh-brand':
|
||||
specifier: workspace:*
|
||||
version: link:../../util/brand
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/** Browser transport fixture using real Gateway event streams and Client inspect queries. */
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { registerSilentClientTransport } from '../cordis-inspect-timeout/client-fixture.mjs'
|
||||
|
||||
export const name = 'client-inspect-liveness-fixture'
|
||||
export const inject = ['cordisInspect', 'agents', 'llm', 'typertGateway']
|
||||
|
||||
/**
|
||||
* Leave a disconnected query pending until timeout, then answer after reconnection.
|
||||
* @param {import('@deepseek-ai/cordis').Context} ctx - isolated snapshot Host.
|
||||
*/
|
||||
export function apply(ctx) {
|
||||
const open = registerSilentClientTransport(ctx)
|
||||
const expected = readFileSync(process.env.DSH_SNAPSHOT_FILE, 'utf8').trim().split('\n')
|
||||
.map(line => JSON.parse(line))
|
||||
.filter(event => event.type === 'tool/result')
|
||||
.map(event => event.data.message)
|
||||
const resultFields = message => ({
|
||||
toolCallId: message.toolCallId,
|
||||
content: message.content,
|
||||
isError: message.isError,
|
||||
})
|
||||
const queries = []
|
||||
const resolved = []
|
||||
let requests = 0
|
||||
let client
|
||||
ctx.on('llm/stream', async function* (options, next) {
|
||||
assert.deepEqual(
|
||||
options.messages.filter(message => message.role === 'tool').map(resultFields),
|
||||
expected.slice(0, requests).map(resultFields),
|
||||
'Each continuing model request receives the recorded Client liveness results',
|
||||
)
|
||||
switch (requests++) {
|
||||
case 0:
|
||||
assert.equal(ctx.typertGateway.hasLiveClient(), false)
|
||||
break
|
||||
case 1:
|
||||
assert.equal(queries.length, 0, 'An offline query never reaches the browser transport')
|
||||
client = await open()
|
||||
break
|
||||
case 2:
|
||||
assert.equal(ctx.typertGateway.hasLiveClient(), false)
|
||||
assert.equal(queries.length, 1)
|
||||
assert.deepEqual(resolved, queries, 'Timeout settles the disconnected query')
|
||||
await client.close()
|
||||
client = await open()
|
||||
break
|
||||
case 3:
|
||||
assert.equal(queries.length, 2)
|
||||
assert.deepEqual(resolved, queries, 'The reconnected query settles separately')
|
||||
assert.equal(ctx.typertGateway.hasLiveClient(), true)
|
||||
await client.close()
|
||||
assert.equal(ctx.typertGateway.hasLiveClient(), false)
|
||||
break
|
||||
default:
|
||||
assert.fail('Unexpected model request after Client inspection recovered')
|
||||
}
|
||||
yield* next()
|
||||
})
|
||||
ctx.cordisInspect.syncClientManifest([{
|
||||
id: 'Service',
|
||||
description: 'Snapshot Client Service provider.',
|
||||
methods: [{
|
||||
name: 'listService',
|
||||
description: 'Inspect one Client Service.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { service: { type: 'string' } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: { type: 'object' },
|
||||
}],
|
||||
}])
|
||||
ctx.on('cordis/inspect-query-resolved', ({ requestId }) => { resolved.push(requestId) })
|
||||
ctx.on('cordis/inspect-query', (request) => {
|
||||
assert.equal(ctx.typertGateway.hasLiveClient(), true)
|
||||
assert.deepEqual(request.input, { service: 'remote' })
|
||||
queries.push(request.requestId)
|
||||
if (queries.length === 1) {
|
||||
client.abort()
|
||||
assert.equal(ctx.typertGateway.hasLiveClient(), false)
|
||||
assert.deepEqual(resolved, [], 'Disconnection leaves the query pending until timeout')
|
||||
return
|
||||
}
|
||||
assert.equal(queries.length, 2)
|
||||
assert.notEqual(queries[0], request.requestId)
|
||||
const agent = ctx.agents.get(request.agentId)
|
||||
assert.ok(agent, 'Client inspect request has an owning Agent')
|
||||
assert.deepEqual(ctx.cordisInspect.resolveClientQuery(agent, request.requestId, {
|
||||
ok: true,
|
||||
data: { service: 'remote' },
|
||||
}), { accepted: true })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek-api-key'
|
||||
disabled: true
|
||||
|
||||
- id: agent-default-model
|
||||
name: '@deepseek-ai/dsh-agent-default-model'
|
||||
config:
|
||||
provider: deepseek-official
|
||||
model: deepseek-v4-flash
|
||||
|
||||
- id: session-persistence-jsonl
|
||||
name: '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
config:
|
||||
root: !!js dshHomePath('sessions')
|
||||
compression: none
|
||||
|
||||
- id: agent-instructions
|
||||
name: '@deepseek-ai/dsh-agent-instructions'
|
||||
config:
|
||||
maxBytes: 65536
|
||||
|
||||
- id: tools
|
||||
name: '@deepseek-ai/dsh-tools'
|
||||
config:
|
||||
mode: both
|
||||
|
||||
- id: system-prompt
|
||||
name: '@deepseek-ai/dsh-system-prompt'
|
||||
config:
|
||||
personaPrefix: |
|
||||
You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}.
|
||||
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
|
||||
- insert:
|
||||
- id: cordis-host-runner
|
||||
name: '@deepseek-ai/dsh-cordis-host-runner'
|
||||
config:
|
||||
clientInspectTimeoutMs: 100
|
||||
- id: cordis-inspect-providers
|
||||
name: '@deepseek-ai/dsh-tool-cordis/host'
|
||||
- id: tool-cordis
|
||||
name: '@deepseek-ai/dsh-tool-cordis'
|
||||
- id: llm-replay
|
||||
name: '@deepseek-ai/dsh-llm-replay'
|
||||
config:
|
||||
providers:
|
||||
- id: deepseek-official
|
||||
name: DeepSeek
|
||||
models:
|
||||
- id: deepseek-v4-flash
|
||||
- id: deepseek-v4-pro
|
||||
- id: client-inspect-liveness-fixture
|
||||
name: ./client-fixture.mjs
|
||||
@@ -0,0 +1,41 @@
|
||||
- id: agent-default-model
|
||||
name: '@deepseek-ai/dsh-agent-default-model'
|
||||
config:
|
||||
provider: deepseek-official
|
||||
model: deepseek-v4-pro
|
||||
|
||||
- id: session-persistence-jsonl
|
||||
name: '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
config:
|
||||
root: !!js dshHomePath('sessions')
|
||||
compression: !!js 'process.env.DSH_SNAPSHOT === undefined ? ''zstd'' : ''none'''
|
||||
|
||||
- id: agent-instructions
|
||||
name: '@deepseek-ai/dsh-agent-instructions'
|
||||
config:
|
||||
maxBytes: 65536
|
||||
|
||||
- id: tools
|
||||
name: '@deepseek-ai/dsh-tools'
|
||||
config:
|
||||
mode: both
|
||||
|
||||
- id: system-prompt
|
||||
name: '@deepseek-ai/dsh-system-prompt'
|
||||
config:
|
||||
personaPrefix: |
|
||||
You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}.
|
||||
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
|
||||
- insert:
|
||||
- id: cordis-host-runner
|
||||
name: '@deepseek-ai/dsh-cordis-host-runner'
|
||||
config:
|
||||
clientInspectTimeoutMs: 100
|
||||
- id: cordis-inspect-providers
|
||||
name: '@deepseek-ai/dsh-tool-cordis/host'
|
||||
- id: tool-cordis
|
||||
name: '@deepseek-ai/dsh-tool-cordis'
|
||||
- id: client-inspect-liveness-fixture
|
||||
name: ./client-fixture.mjs
|
||||
@@ -0,0 +1,35 @@
|
||||
{"type":"session","version":4,"id":"{{session:1}}","createdAt":1783951000000,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
|
||||
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
|
||||
{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
|
||||
{"type":"approval/policy","data":{"policy":"never"}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Inspect Gateway Client liveness across three sequential cordis_inspect_query calls for the Client Service named remote. The page is offline for the first call, disconnects while the second call is pending, and reconnects before the third call. After an offline error, a timeout, and a successful result for remote, reply exactly CORDIS_INSPECT_LIVENESS_RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","data":{"turn":1,"step":1}}
|
||||
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Inspect Gateway Client liveness across three sequential cordis_inspect_query calls for the Client Service named remote. The page is offline for the first call, disconnects while the second call is pending, and reconnects before the third call. After an offline error, a timeout, and a successful result for remote, reply exactly CORDIS_INSPECT_LIVENESS_RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
|
||||
{"type":"session/title","data":{"title":"Inspect Gateway Client liveness across","messageSeqs":[8],"source":{"kind":"fallback"}}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-client-1","name":"cordis_inspect_query","arguments":"{\"platform\":\"client\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"remote\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778145981,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788778145981,"index":0,"dt":[],"id":"inspect-client-1","name":"cordis_inspect_query","args":["{\"platform\":\"client\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"remote\"}}"]},{"type":"chunk","time":1788778145981,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-client-1","name":"cordis_inspect_query","arguments":"{\"platform\":\"client\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"remote\"}}"}}},{"type":"chunk","time":1788778145981,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778145981,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"inspect-client-1","name":"cordis_inspect_query","arguments":"{\"platform\":\"client\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"remote\"}}"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"inspect-client-1"},"toolCallId":"inspect-client-1","content":[{"type":"text","text":"Error: Client inspect query Service.listService has no connected Harness page. Open or reconnect the Harness page, then retry."}],"isError":true,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-client-2","name":"cordis_inspect_query","arguments":"{\"platform\":\"client\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"remote\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778146017,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788778146017,"index":0,"dt":[],"id":"inspect-client-2","name":"cordis_inspect_query","args":["{\"platform\":\"client\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"remote\"}}"]},{"type":"chunk","time":1788778146017,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-client-2","name":"cordis_inspect_query","arguments":"{\"platform\":\"client\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"remote\"}}"}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":2,"callId":"inspect-client-2","name":"cordis_inspect_query","arguments":"{\"platform\":\"client\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"remote\"}}"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"inspect-client-2"},"toolCallId":"inspect-client-2","content":[{"type":"text","text":"Error: Service.listService: Client inspect query Service.listService timed out after 100ms. Open or reconnect the Harness page, then retry."}],"isError":true,"id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":2}}
|
||||
{"type":"step/start","data":{"turn":1,"step":3}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-client-3","name":"cordis_inspect_query","arguments":"{\"platform\":\"client\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"remote\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778146017,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788778146017,"index":0,"dt":[],"id":"inspect-client-3","name":"cordis_inspect_query","args":["{\"platform\":\"client\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"remote\"}}"]},{"type":"chunk","time":1788778146017,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-client-3","name":"cordis_inspect_query","arguments":"{\"platform\":\"client\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"remote\"}}"}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":3,"callId":"inspect-client-3","name":"cordis_inspect_query","arguments":"{\"platform\":\"client\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"remote\"}}"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":3,"message":{"role":"tool","source":{"kind":"tool","callId":"inspect-client-3"},"toolCallId":"inspect-client-3","content":[{"type":"text","text":"{\n \"platform\": \"client\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"service\": \"remote\"\n }\n}"}],"isError":false,"id":"{{message:9}}"}},"sourceEventSeqs":[24],"surfaceOp":"append"}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call."}],"source":{"kind":"repeat-tool-reminder","form":"notice","summary":"cordis_inspect_query × 3"},"role":"user","id":"{{message:10}}"}]}}
|
||||
{"type":"step/end","data":{"turn":1,"step":3}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","data":{"turn":1,"step":4}}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call."}],"source":{"kind":"repeat-tool-reminder","form":"notice","summary":"cordis_inspect_query × 3"},"role":"user","id":"{{message:10}}"},"surfaceOp":"append"}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_LIVENESS_RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:11}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778146037,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788778146037,"index":0,"dt":[],"texts":["CORDIS_INSPECT_LIVENESS_RECOVERED"]},{"type":"chunk","time":1788778146037,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORDIS_INSPECT_LIVENESS_RECOVERED"}}},{"type":"chunk","time":1788778146037,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778146037,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":4}}
|
||||
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,10 @@
|
||||
version: 1
|
||||
scenario: cordis-inspect-liveness
|
||||
profile: headless
|
||||
composition: cordis-inspect-liveness
|
||||
recording: authored
|
||||
header:
|
||||
class: advanced
|
||||
pin: true
|
||||
systemPromptSource: cordis-inspect-jsdoc
|
||||
toolSchemasSource: cordis-inspect-jsdoc
|
||||
@@ -4,13 +4,45 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
export const name = 'client-inspect-timeout-fixture'
|
||||
export const inject = ['cordisInspect', 'agents', 'llm']
|
||||
export const inject = ['cordisInspect', 'agents', 'llm', 'typertGateway']
|
||||
|
||||
/**
|
||||
* Register a silent event source and open real Gateway Client streams on demand.
|
||||
* @param {import('@deepseek-ai/cordis').Context} ctx - owner of the source and streams.
|
||||
* @returns opener resolving after readiness, with abort and quiescent close operations.
|
||||
*/
|
||||
export function registerSilentClientTransport(ctx) {
|
||||
ctx.effect(() => ctx.typertGateway.registerRemoteEvents(async function* (signal) {
|
||||
await new Promise(resolve => {
|
||||
if (signal.aborted) resolve()
|
||||
else signal.addEventListener('abort', resolve, { once: true })
|
||||
})
|
||||
}, { home: '/snapshot-host' }))
|
||||
return async () => {
|
||||
const controller = new AbortController()
|
||||
const stream = await ctx.typertGateway.wireStream.open(
|
||||
'$events', { args: {} }, (async function* () {})(), undefined, controller.signal,
|
||||
)
|
||||
const iterator = stream[Symbol.asyncIterator]()
|
||||
const close = ctx.effect(() => async () => {
|
||||
controller.abort()
|
||||
await iterator.return?.()
|
||||
})
|
||||
const ready = await iterator.next()
|
||||
assert.equal(ready.done, false)
|
||||
assert.equal(ready.value.type, 'ready')
|
||||
assert.equal(ctx.typertGateway.hasLiveClient(), true)
|
||||
return { abort: () => { controller.abort() }, close }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror a Client manifest and deliver only the first query's failure.
|
||||
* @param {import('@deepseek-ai/cordis').Context} ctx - isolated snapshot Host.
|
||||
* @returns after the Gateway Client stream is ready.
|
||||
*/
|
||||
export function apply(ctx) {
|
||||
export async function apply(ctx) {
|
||||
await registerSilentClientTransport(ctx)()
|
||||
const expected = readFileSync(process.env.DSH_SNAPSHOT_FILE, 'utf8').trim().split('\n')
|
||||
.map(line => JSON.parse(line))
|
||||
.filter(event => event.type === 'tool/result')
|
||||
@@ -22,6 +54,7 @@ export function apply(ctx) {
|
||||
})
|
||||
let requests = 0
|
||||
ctx.on('llm/stream', (options, next) => {
|
||||
assert.equal(ctx.typertGateway.hasLiveClient(), true)
|
||||
assert.deepEqual(
|
||||
options.messages.filter(message => message.role === 'tool').map(resultFields),
|
||||
expected.slice(0, requests++).map(resultFields),
|
||||
|
||||
Reference in new issue
Block a user