test(subprocess): cover source-only control launches and refresh generated references

This commit is contained in:
Tianyi Cui committed 2026-09-12 15:03:01 +08:00
1 parent c45d722d7b
commit 96e12ddaf4
10 files changed
+61 -14

No files matched your search

+2 -2
View File
@@ -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 docs/module-graph.md
module-graph.md: d2f7fd72e9026a7f7eb248a1c4ae234125f3571a
module-graph.zh.md: cc85a23a7866411aa69edf47a1b437f05270a930
module-graph.md: 1fe166cfbe4a5c8a8d214cf5cd8438cbff32e5f7
module-graph.zh.md: a2adf21e35734e8cd28a325cd3398c3638c963b0
+2 -1
View File
@@ -423,6 +423,7 @@ flowchart TD
pkg_credentials_local --> pkg_credentials
pkg_credentials_local --> pkg_home_paths
pkg_credentials_local --> pkg_launch_environment
pkg_sandbox_windows_acl --> pkg_subprocess
pkg_subprocess_local --> pkg_subprocess
pkg_subprocess_local --> pkg_timeout
pkg_skill_badge --> pkg_skill
@@ -1262,7 +1263,6 @@ flowchart TD
| [`host-open-in-app`](../packages/host/open-in-app) | `host` | — |
| [`host-webserver`](../packages/host/webserver) | `host` | — |
| [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — |
| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | — |
| [`session-format`](../packages/session/session-format) | `session` | — |
| [`session-format-v0-to-v1`](../packages/session/session-format-v0-to-v1) | `session` | — |
| [`session-format-v1-to-v2`](../packages/session/session-format-v1-to-v2) | `session` | — |
@@ -1301,6 +1301,7 @@ flowchart TD
| [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) |
| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) |
| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`subprocess`](../packages/subprocess/subprocess) |
| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) |
| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
+2 -1
View File
@@ -425,6 +425,7 @@ flowchart TD
pkg_credentials_local --> pkg_credentials
pkg_credentials_local --> pkg_home_paths
pkg_credentials_local --> pkg_launch_environment
pkg_sandbox_windows_acl --> pkg_subprocess
pkg_subprocess_local --> pkg_subprocess
pkg_subprocess_local --> pkg_timeout
pkg_skill_badge --> pkg_skill
@@ -1264,7 +1265,6 @@ flowchart TD
| [`host-open-in-app`](../packages/host/open-in-app) | `host` | — |
| [`host-webserver`](../packages/host/webserver) | `host` | — |
| [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — |
| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | — |
| [`session-format`](../packages/session/session-format) | `session` | — |
| [`session-format-v0-to-v1`](../packages/session/session-format-v0-to-v1) | `session` | — |
| [`session-format-v1-to-v2`](../packages/session/session-format-v1-to-v2) | `session` | — |
@@ -1303,6 +1303,7 @@ flowchart TD
| [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) |
| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) |
| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`subprocess`](../packages/subprocess/subprocess) |
| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) |
| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
@@ -23,7 +23,7 @@ describe.skipIf(process.platform !== 'win32')('managed Windows ACL control pipe'
ctx = new Context()
await ctx.plugin(LocalSubprocessRuntime)
const runner = fileURLToPath(new URL('../src/runner.ts', import.meta.url))
const helper = import.meta.resolve('@deepseek-ai/dsh-subprocess/control')
const helper = import.meta.resolve('@deepseek-ai/dsh-subprocess/src/control.ts')
const program = `
const { openInheritedControlChannel } = await import(process.argv[1]);
const { writeFileSync } = await import('node:fs');
@@ -358,6 +358,23 @@ describe('AclSandbox init', () => {
})
describe('AclSandbox spawn', () => {
it('refuses control with piped stdio before starting a restricted process', async () => {
const sandbox = new AclSandbox({ writableDirs: [], tempDir: null, mode: 'read-only' })
await sandbox.init()
expect(() => sandbox.spawn({ command: 'probe.exe', controlFileDescriptor: 7 })).toThrow('control pipe requires inherited stdio')
sandbox.dispose()
})
it('forwards the inherited control pipe to the restricted child', async () => {
const { api } = state.stubs as HappyStubs
Object.assign(api, { uvGetOsfhandle: vi.fn(() => 107n), getFileType: vi.fn(() => 3) })
const sandbox = new AclSandbox({ writableDirs: [], tempDir: null, mode: 'read-only' })
await sandbox.init()
const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit', controlFileDescriptor: 7 })
await expect(child.wait()).resolves.toEqual({ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 42 })
sandbox.dispose()
})
it('refuses to spawn before init', () => {
const workspace = scratch()
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-11', mode: 'workspace-write' })
@@ -5,9 +5,10 @@ import { fileURLToPath } from 'node:url'
import { once } from 'node:events'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { SUBPROCESS_CONTROL_ENV } from '@deepseek-ai/dsh-subprocess/control'
import { LocalSubprocessRuntime } from '../src/index.ts'
import { spawnSubprocess } from '../src/spawn.ts'
const fixture = fileURLToPath(new URL('./fixtures/control-child.ts', import.meta.url))
const helper = fileURLToPath(new URL('../../subprocess/src/control.ts', import.meta.url))
@@ -16,6 +17,8 @@ let root: string | undefined
let handle: SubprocessHandle | undefined
afterEach(async () => {
handle?.terminate()
await handle?.waitForExit()
handle?.control?.destroy()
await ctx?.fiber.dispose()
if (root !== undefined) await rm(root, { recursive: true, force: true })
@@ -58,18 +61,19 @@ describe('managed subprocess control pipe', () => {
expect(await handle.waitForExit()).toBe(true)
})
it('returns exact binary control bytes independently of stdout and stderr', async () => {
it.each(['managed', 'fallback'] as const)('returns exact binary control bytes through %s independently of stdio', async (backend) => {
root = await mkdtemp(join(tmpdir(), 'dsh-control-'))
ctx = new Context()
await ctx.plugin(LocalSubprocessRuntime)
const input = Buffer.alloc(256 * 1024)
for (let index = 0; index < input.length; index++) input[index] = index % 256
handle = ctx.subprocess.spawn({
const request: SubprocessSpawnSpec = {
argv: [process.execPath, fixture, helper, String(input.length)],
cwd: root,
stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 }, control: 'pipe' },
graceMs: 1000,
})
}
handle = backend === 'managed' ? ctx.subprocess.spawn(request) : spawnSubprocess(request)
const channel = handle.control
if (channel === undefined) throw new Error('requested control pipe is absent')
const received = (async () => {
@@ -543,8 +543,8 @@ describe('Linux one-shot exec bootstrap', () => {
})
})
it('retries ENOEXEC through /bin/sh with the resolved file and original arguments', async () => {
const files = track(createLinuxLaunchFiles({ cwd: '/work', env: { PATH: 'bin' } }))
it.each([undefined, 'pipe'] as const)('retries ENOEXEC through /bin/sh with control %s', async (control) => {
const files = track(createLinuxLaunchFiles({ cwd: '/work', env: { PATH: 'bin' }, ...control === undefined ? {} : { control } }))
const execve = vi.fn()
.mockImplementationOnce(() => { throw Object.assign(new Error('exec format'), { code: 'ENOEXEC' }) })
.mockImplementationOnce(() => { throw Object.assign(new Error('shell failed'), { code: 'EIO' }) })
@@ -555,8 +555,8 @@ describe('Linux one-shot exec bootstrap', () => {
internals({ execve: execve as never }),
)
expect(execve.mock.calls).toEqual([
['/work/bin/tool', ['tool', 'literal arg'], { PATH: 'bin' }],
['/bin/sh', ['/bin/sh', '/work/bin/tool', 'literal arg'], { PATH: 'bin' }],
['/work/bin/tool', ['tool', 'literal arg'], { PATH: 'bin' }, ...control === undefined ? [] : [control]],
['/bin/sh', ['/bin/sh', '/work/bin/tool', 'literal arg'], { PATH: 'bin' }, ...control === undefined ? [] : [control]],
])
expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({
type: 'error', error: { code: 'EIO', path: 'tool' },
@@ -138,6 +138,20 @@ describe('Windows Job capability', () => {
})
describe('Windows parent runner contract', () => {
it('keeps the requested control endpoint separate from runner IPC and ordinary output', () => {
const child = new FakeChild()
const control = new PassThrough()
child.stdio.push(control)
const request = { ...spec, stdio: { ...spec.stdio, control: 'pipe' as const } }
const { result, spawn } = launch(child, request)
expect(result.control).toBe(control)
expect(child.sent).toEqual([{ type: 'start', cwd: 'C:\\target', env: { TARGET: 'yes' }, control: 'pipe' }])
expect(spawn).toHaveBeenCalledWith('C:\\node.exe', expect.any(Array), expect.objectContaining({
stdio: ['ignore', 'ignore', 'ignore', 'ipc', 'pipe', 'pipe', 2, 'overlapped'],
}))
control.destroy()
})
it('isolates runner stdio, carries target stdio on fd 4 through fd 6, and sends cwd/env', () => {
const { child, result, spawn } = launch()
expect(spawn).toHaveBeenCalledWith('C:\\node.exe', [
@@ -70,6 +70,16 @@ function inheritedApi(overrides: Partial<Win32ProcessBindings> = {}): {
describe('spawnInheritedJobProcess', () => {
const token = 70n as NativePtr
it('inherits the control carrier into a restricted child before resume', () => {
const descriptorHandle = vi.fn(() => 107n as NativePtr)
const { api, events } = inheritedApi({ uvGetOsfhandle: descriptorHandle, getFileType: vi.fn(() => 3) })
const child = spawnInheritedJobProcess(api, { command: 'node.exe', args: [], cwd: 'C:\\work', token, controlFileDescriptor: 7 })
expect(child.pid).toBe(1234)
expect(descriptorHandle).toHaveBeenCalledExactlyOnceWith(7)
expect(events.filter(event => event === 'inherit')).toHaveLength(4)
expect(events.indexOf('assign')).toBeLessThan(events.indexOf('resume'))
})
it('creates suspended, assigns the Job, then resumes the restricted child', () => {
const {
api,
@@ -19,7 +19,7 @@
{"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-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}],"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-subprocess-api","name":"cordis_inspect_query","args":["{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"]},{"type":"chunk","time":1788778146017,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}}},{"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-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}}
{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-subprocess-api"},"content":[{"type":"tool-result","toolCallId":"inspect-subprocess-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"subprocess\",\n \"description\": \"Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).\\n\\nImplementations must honor these semantics:\\n\\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\\n- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures.\\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\\n- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits.\\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"subprocess\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"subprocess\"\n ],\n \"expression\": \"ctx.subprocess\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"abstract resolveExecutable( command: string, env?: Readonly<Record<string, string>>, signal?: AbortSignal, ): Promise<string>\",\n \"description\": \"Resolve one configured executable in this provider's execution world. Absolute paths are verified; bare names use the provider's scrubbed PATH plus explicit environment overrides. Relative paths containing separators are rejected: the resolution base is undefined, so providers fail loud instead of guessing.\",\n \"parameters\": [\n {\n \"name\": \"command\",\n \"description\": \"absolute executable path or bare PATH name.\"\n },\n {\n \"name\": \"env\",\n \"description\": \"explicit environment entries used for lookup.\"\n },\n {\n \"name\": \"signal\",\n \"description\": \"aborts remote or local lookup.\"\n }\n ],\n \"returns\": \"a canonical executable path.\"\n },\n {\n \"signature\": \"abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle\",\n \"description\": \"Start one managed child process from a fully-specified spec; this seam applies no defaults.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"argv, directory, stdio dispositions, grace, cancellation, and environment.\"\n }\n ],\n \"returns\": \"the live process handle (streams/readers, signalling, outcome promise).\",\n \"throws\": [\n \"synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.\"\n ]\n },\n {\n \"signature\": \"abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle>\",\n \"description\": \"Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and whole-session quiescence.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.\"\n }\n ],\n \"returns\": \"the live terminal handle after allocation succeeds.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"SubprocessCollect\",\n \"declaration\": \"export interface SubprocessCLine truncated
{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-subprocess-api"},"content":[{"type":"tool-result","toolCallId":"inspect-subprocess-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"subprocess\",\n \"description\": \"Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).\\n\\nImplementations must honor these semantics:\\n\\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\\n- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures.\\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\\n- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits.\\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"subprocess\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"subprocess\"\n ],\n \"expression\": \"ctx.subprocess\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"abstract resolveExecutable( command: string, env?: Readonly<Record<string, string>>, signal?: AbortSignal, ): Promise<string>\",\n \"description\": \"Resolve one configured executable in this provider's execution world. Absolute paths are verified; bare names use the provider's scrubbed PATH plus explicit environment overrides. Relative paths containing separators are rejected: the resolution base is undefined, so providers fail loud instead of guessing.\",\n \"parameters\": [\n {\n \"name\": \"command\",\n \"description\": \"absolute executable path or bare PATH name.\"\n },\n {\n \"name\": \"env\",\n \"description\": \"explicit environment entries used for lookup.\"\n },\n {\n \"name\": \"signal\",\n \"description\": \"aborts remote or local lookup.\"\n }\n ],\n \"returns\": \"a canonical executable path.\"\n },\n {\n \"signature\": \"abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle\",\n \"description\": \"Start one managed child process from a fully-specified spec; this seam applies no defaults.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"argv, directory, stdio dispositions, grace, cancellation, and environment.\"\n }\n ],\n \"returns\": \"the live process handle (streams/readers, signalling, outcome promise).\",\n \"throws\": [\n \"synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.\"\n ]\n },\n {\n \"signature\": \"abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle>\",\n \"description\": \"Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and whole-session quiescence.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.\"\n }\n ],\n \"returns\": \"the live terminal handle after allocation succeeds.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"SubprocessCollect\",\n \"declaration\": \"export interface SubprocessCLine truncated
{"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":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"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_JSDOC_OK"]},{"type":"chunk","time":1788778146037,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}},{"type":"chunk","time":1788778146037,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778146037,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}