From 7df07bf100b9afc4a61d9459c7eaeb45ab0db2bb Mon Sep 17 00:00:00 2001 From: moonyue-w <300878504+moonyue-w@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:58:35 +0800 Subject: [PATCH 1/3] refactor(test): break examples/tests cycle; slim examples to pure samples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TurnResult 迁 tests/execution.mjs 解环;删示例测试哈内斯 + 3 个 examples 测试;examples 改独立瘦身演示。三块示例哈内斯覆盖(批清理 owned-scope/sanitizer/ProjectMemory 反泄漏)按决定丢弃。 Task: 1789906942 --- examples/forward-scenarios.mjs | 215 +++++---------- examples/forward-support.mjs | 252 ------------------ examples/managed-scenarios.mjs | 221 +++++---------- examples/managed-support.mjs | 242 ----------------- examples/memory-proof.mjs | 51 ---- examples/run.mjs | 183 ------------- package.json | 2 +- tests/examples-batch-cleanup.test.mjs | 169 ------------ tests/examples-cli.test.mjs | 119 --------- tests/examples.test.mjs | 97 ------- {examples => tests}/execution.mjs | 0 tests/live/forward-support.mjs | 4 +- tests/live/managed-support.mjs | 4 +- tests/scenarios/execution-assertions.test.mjs | 5 +- 14 files changed, 135 insertions(+), 1429 deletions(-) delete mode 100644 examples/forward-support.mjs delete mode 100644 examples/managed-support.mjs delete mode 100644 examples/memory-proof.mjs delete mode 100644 examples/run.mjs delete mode 100644 tests/examples-batch-cleanup.test.mjs delete mode 100644 tests/examples-cli.test.mjs delete mode 100644 tests/examples.test.mjs rename {examples => tests}/execution.mjs (100%) diff --git a/examples/forward-scenarios.mjs b/examples/forward-scenarios.mjs index 11fa5f4..8657e3e 100644 --- a/examples/forward-scenarios.mjs +++ b/examples/forward-scenarios.mjs @@ -1,168 +1,75 @@ -import assert from 'node:assert/strict'; -import { toFile } from '../dist/forward/index.js'; -import { batchTerminal } from '../tests/live/forward-support.mjs'; -import { exampleMarker, exampleName } from './forward-support.mjs'; -import { ProjectMemory, PROJECT_MEMORY_PATH } from './memory-proof.mjs'; +// Forward SDK 示例:最小可运行演示,展示会话生命周期与流式回复。 +// 仅作代码示例,不承担测试作用;运行:node examples/forward-scenarios.mjs +import { pathToFileURL } from 'node:url'; +import { ForwardClient } from '../dist/forward/index.js'; -export { createForwardExampleSuite } from './forward-support.mjs'; +const name = (kind) => `sdk-example-${kind}-${Math.random().toString(16).slice(2, 10)}`; -async function models(suite) { - const enabled = await suite.models(); - suite.log(`模型列表:${enabled.join(', ')}`); +// 从公开 API 构造客户端;凭据来自环境变量。 +function makeClient() { + const pat = process.env.QODER_FORWARD_PAT ?? process.env.QODER_PAT; + if (!pat) throw new Error('设置 QODER_FORWARD_PAT 或 QODER_PAT 后运行本示例'); + return new ForwardClient({ pat, baseURL: process.env.QODER_FORWARD_BASE_URL, maxRetries: 0 }); } -async function session(suite) { - const environmentID = await suite.createEnvironment(); - const identityID = await suite.createIdentity(); - const templateID = await suite.createTemplate({ environment_id: environmentID }); - const sessionID = await suite.createSession({ identity_id: identityID, template_id: templateID }); - const marker = exampleMarker(); - await suite.turn(sessionID, `请用一句话介绍你能提供什么帮助,并在回复末尾原样附上:${marker}`, [marker], false, true); +// 选择一个已启用的模型。 +async function chooseModel(client) { + const models = await client.models.list(); + const enabled = models.data.filter((m) => m.is_enabled).map((m) => m.id); + if (!enabled.length) throw new Error('账号没有已启用的模型'); + return enabled[0]; } -async function resources(suite) { - const environmentID = await suite.createEnvironment(); - const identityID = await suite.createIdentity(); - const fileToken = exampleMarker(), envToken = exampleMarker(), skillToken = exampleMarker(); - suite.step('上传示例文件,供会话中的工具读取'); - const file = await suite.client.files.upload({ file: await toFile(fileToken, 'sdk-example.txt'), purpose: 'session_resource' }, suite.options); - suite.track('file', file.id, (options) => suite.client.files.delete(file.id, options)); - const skillName = exampleName('skill'); - suite.step('上传自定义 Skill,其中包含一个随机校验值'); - const skill = await suite.client.skills.create({ files: [await toFile( - `---\nname: ${skillName}\ndescription: Provides a sample verification code for the SDK example.\n---\nThe example verification value EXAMPLE_SKILL_CODE is: ${skillToken}\n`, - `${skillName}/SKILL.md`, - )] }, suite.options); - suite.track('skill', skill.id, (options) => suite.client.skills.delete(skill.id, options)); - const templateID = await suite.createTemplate({ - environment_id: environmentID, - skills: [{ type: 'custom', skill_id: skill.id, version: skill.latest_version }], - environment_variables: { SDK_EXAMPLE_VALUE: 'template-default' }, +// 演示:创建环境 / 身份 / 模板 / 会话,发一轮消息并流式打印助手回复,最后清理。 +async function sessionDemo(client) { + const model = await chooseModel(client); + console.log(`使用模型:${model}`); + + const environment = await client.environments.create({ name: name('env'), config: { type: 'cloud' } }); + const identity = await client.identities.create({ external_id: name('identity'), name: 'SDK 示例用户' }); + const template = await client.templates.create({ + name: name('template'), model, system: '你是一个 SDK 示例助手。', + environment_id: environment.id, tools: [{ type: 'agent_toolset_20260401' }], }); - suite.step('设置 Identity 的环境变量,覆盖模板中的默认值'); - await suite.client.identities.configs.upsert(identityID, templateID, { - identity_config: { environment_variables: { SDK_EXAMPLE_VALUE: { op: 'set', value: envToken } } }, - }, suite.options); - const sessionID = await suite.createSession({ identity_id: identityID, template_id: templateID, - resources: [{ type: 'file', file_id: file.id, mount_path: '/data/workspace/sdk-example.txt' }] }); - await suite.turn(sessionID, '请使用工具读取 /data/workspace/sdk-example.txt 的内容和 SDK_EXAMPLE_VALUE 环境变量,分别返回这两个示例值。', [fileToken, envToken], true); - await suite.turn(sessionID, `请在本轮实际调用 Read(或 Bash)工具,定位并重新读取技能 ${skillName} 的 SKILL.md 正文,再从刚读取的内容提取 EXAMPLE_SKILL_CODE 并返回这个示例校验码。即使之前已读取过该技能,也请本轮重新读取,不要只依据先前上下文回答。`, [skillToken], true); -} + const session = await client.sessions.create({ identity_id: identity.id, template_id: template.id }); + console.log(`已创建会话:${session.id}`); -async function memory(suite, run) { - const environmentID = await suite.createEnvironment(); - const identityID = await suite.createIdentity({ memory: true }); - const memory = new ProjectMemory(); - suite.step('创建记忆库(Memory Store)'); - const store = await suite.client.memoryStores.create({ name: exampleName('memory'), idempotency_key: exampleName('memory-key') }, suite.options); - suite.track('memory_store', store.id, (options) => suite.client.memoryStores.delete(store.id, options)); - suite.step('通过 SDK 写入项目背景和发布约定'); - const entry = await suite.client.memoryStores.memories.create(store.id, { path: PROJECT_MEMORY_PATH, content: memory.content() }, suite.options); - suite.record('memory_entry', { id: entry.id, path: PROJECT_MEMORY_PATH }); - suite.log(`记忆正文:${memory.content()}`); - suite.step('写入 MEMORY.md 索引,供新会话发现项目记忆'); - const index = await suite.client.memoryStores.memories.create(store.id, { path: 'MEMORY.md', content: memory.index() }, suite.options); - suite.record('memory_index', { id: index.id, path: 'MEMORY.md' }); - suite.log('索引只包含正文入口,不包含发布时间、联系人和回滚版本'); - suite.step('通过 SDK 重新读取,确认记忆正文和索引已保存'); - assert.equal((await suite.client.memoryStores.memories.retrieve(store.id, entry.id, suite.options)).content, memory.content(), '读取的记忆内容与写入内容不一致'); - assert.equal((await suite.client.memoryStores.memories.retrieve(store.id, index.id, suite.options)).content, memory.index(), '读取的记忆索引与写入内容不一致'); - suite.check('记忆正文和索引已保存,内容与写入一致'); - const templateID = await suite.createTemplate({ environment_id: environmentID }); - suite.step('将记忆库绑定到 Identity 和 Template'); - await suite.client.identities.memoryStores.mount(identityID, templateID, { memory_store_id: store.id }, suite.options); - suite.track('memory_mount', store.id, (options) => suite.client.identities.memoryStores.detach(identityID, templateID, store.id, options)); - suite.step('查询绑定,确认记忆库已关联'); - const mounts = await suite.client.identities.memoryStores.list(identityID, templateID, suite.options); - assert.ok(mounts.data.some((mount) => mount.memory_store_id === store.id), 'memory mount was not persisted'); - suite.check(`已查到绑定的记忆库:${store.id}`); - suite.log('下面创建全新会话;项目约定只保存在记忆库中,不加入系统指令或聊天历史'); - const sessionID = await suite.createSession({ identity_id: identityID, template_id: templateID }); - suite.step('请助手结合记忆拟定上线安排'); - suite.log(`用户:${memory.prompt()}`); - const after = await suite.sendTurn(sessionID, memory.prompt()); - const reply = await suite.waitReply(sessionID, after); - memory.verify(reply, run ?? suite.run); -} + const sent = await client.sessions.events.send(session.id, { + events: [{ type: 'user.message', content: [{ type: 'text', text: '请用一句话介绍你能提供什么帮助。' }] }], + }); + const lastEventID = sent.data[0]?.id; -async function schedule(suite) { - const environmentID = await suite.createEnvironment(); - const identityID = await suite.createIdentity(); - const templateID = await suite.createTemplate({ environment_id: environmentID }); - const marker = exampleMarker(); - suite.log(`本次运行的初始消息:Reply with exactly ${marker}`); - suite.step('创建只手动触发的 Schedule'); - const schedule = await suite.client.schedules.create({ identity_id: identityID, template_id: templateID, environment_id: environmentID, - name: exampleName('schedule'), initial_events: [{ type: 'user.message', content: `Reply with exactly ${marker}` }], - trigger_policy: { type: 'manual' }, execution: { max_attempts: 1, max_concurrent_runs: 1 } }, suite.options); - suite.track('schedule', schedule.id, (options) => suite.client.schedules.archive(schedule.id, {}, options)); - suite.step('手动触发一次 Schedule 运行'); - const createdRun = await suite.client.schedules.run(schedule.id, { idempotency_key: exampleName('run') }, suite.options); - const runID = createdRun.id; - suite.track('schedule_run', runID, (options) => suite.finishScheduleRun(runID, identityID, options)); - suite.step('等待 Schedule Run 完成'); - let run, previous; - for (;;) { - run = await suite.client.scheduleRuns.retrieve(runID, { identity_id: identityID }, suite.options); - if (run.status !== previous) { suite.log(`Schedule Run 状态:${run.status}`); previous = run.status; } - if (run.status === 'completed') break; - assert.ok(!['failed', 'skipped'].includes(run.status), `run=${runID} status=${run.status}`); - await suite.pause(); + // 流式接收本轮事件,打印助手文本;见到会话空闲即结束。 + const stream = await client.sessions.events.streamEvents(session.id, { last_event_id: lastEventID }); + try { + for await (const event of stream) { + if (event.type === 'agent.message') { + const text = (event.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join(''); + if (text) console.log(`助手:${text}`); + } else if (event.type === 'session.status_idle') { + break; + } + } + } finally { + stream.controller.abort(); } - assert.ok(run.session_id, `completed run ${runID} has no session`); - suite.record('schedule_session', run.session_id); - await suite.verifyTurn(run.session_id, '', [marker]); + + // 清理本次演示创建的资源。 + await client.templates.archive(template.id, {}); + await client.identities.delete(identity.id); + await client.environments.archive(environment.id); + console.log('已清理示例资源。'); } -async function batch(suite) { - const environmentID = await suite.createEnvironment(); - const identityID = await suite.createIdentity(); - const templateID = await suite.createTemplate({ environment_id: environmentID }); - const marker = exampleMarker(), customID = exampleName('task'); - suite.log(`批处理任务的输入消息:Reply with exactly ${marker}`); - const line = JSON.stringify({ custom_id: customID, template_id: templateID, identity_id: identityID, body: { input: `Reply with exactly ${marker}` } }); - suite.step('上传包含一个任务的 JSONL 输入文件'); - const input = await suite.client.files.upload({ file: await toFile(`${line}\n`, 'sdk-example-input.jsonl'), purpose: 'session_resource' }, suite.options); - suite.track('input_file', input.id, (options) => suite.client.files.delete(input.id, options)); - suite.step('提交 Batch,交由服务端调度执行'); - let batch = await suite.client.batches.create({ input_file_id: input.id, completion_window: '24h', idempotency_key: exampleName('batch') }, suite.options); - const batchID = batch.id; - suite.track('batch', batchID, (options) => suite.finishBatch(batchID, customID, identityID, templateID, options)); - suite.step('等待 Batch 完成(执行时间取决于服务端窗口)'); - let previous; - while (!batchTerminal(batch.status)) { - if (batch.status !== previous) { suite.log(`Batch 状态:${batch.status}`); previous = batch.status; } - try { await suite.pause(); } - catch (error) { throw new Error(`batch=${batchID} status=${previous}; check server execution window`, { cause: error }); } - batch = await suite.client.batches.retrieve(batchID, suite.options); - } - assert.equal(batch.status, 'completed', `batch=${batchID} status=${batch.status}`); - assert.equal(batch.request_counts.completed, 1, 'batch did not complete exactly one request'); - assert.equal(batch.request_counts.failed, 0, 'batch contains failed requests'); - assert.ok(batch.output_file_id, 'completed batch has no output file'); - suite.step('检查 Batch 的任务和输出文件'); - const tasks = await suite.client.batches.tasks.list(batchID, {}, suite.options); - assert.equal(tasks.data.length, 1, 'batch task count differs from input'); - assert.equal(tasks.data[0].custom_id, customID, 'batch task did not round trip'); - const rows = await suite.batchOutput(batchID, suite.options); - assert.equal(rows.length, 1, 'expected one batch output row'); - const row = rows[0]; - assert.equal(row.custom_id, customID, 'batch output custom ID mismatch'); - assert.equal(row.identity_id, identityID, 'batch output identity mismatch'); - assert.equal(row.template_id, templateID, 'batch output template mismatch'); - assert.equal(row.status, 'completed', 'batch output status mismatch'); - assert.ok(row.session_id, 'batch output has no session'); - assert.ok(row.error == null, 'batch output contains an error'); - assert.ok(JSON.stringify(row.response).includes(marker), 'batch result missing expected output'); - suite.record('batch_session', row.session_id); - await suite.verifyTurn(row.session_id, '', [marker]); +async function main() { + const client = makeClient(); + await sessionDemo(client); } -export const forwardExamples = [ - { name: 'models', description: '查询当前账号可用的模型。', run: models }, - { name: 'session', description: '创建 Identity 和 Template,发送一条消息,通过 SSE 接收助手回复。', run: session }, - { name: 'resources', description: '演示文件挂载、Identity 环境变量覆盖和自定义 Skill 的读取。', run: resources }, - { name: 'memory', description: '写入项目发布约定并绑定到 Identity + Template,验证全新会话的上线安排体现这些记忆。', run: memory }, - { name: 'schedule', description: '创建手动 Schedule,触发一次运行并检查助手回复。', run: schedule }, - { name: 'batch', description: '提交一个 JSONL 批处理任务,等待完成后检查结果。', run: batch }, -]; +// 仅在直接运行时执行(被 import 时不触发网络)。 +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/examples/forward-support.mjs b/examples/forward-support.mjs deleted file mode 100644 index 0cd8194..0000000 --- a/examples/forward-support.mjs +++ /dev/null @@ -1,252 +0,0 @@ -import assert from 'node:assert/strict'; -import { randomBytes } from 'node:crypto'; -import { ForwardLiveSuite, CleanupFailure, resourceAlreadyGone, batchTerminal } from '../tests/live/forward-support.mjs'; -import { TurnResult } from './execution.mjs'; - -export const exampleMarker = () => randomBytes(8).toString('hex'); -export const exampleName = (prefix) => `sdk-example-${prefix}-${exampleMarker()}`; -export const EXAMPLE_SYSTEM = '你是一个 SDK 示例助手。帮助用户了解会话、文件、技能与记忆的用法,必要时调用工具。仅使用可实际读取的数据回答问题。'; - -export function chooseForwardModel(configured, enabled) { - if (configured) { - assert.ok(enabled.includes(configured), 'configured model is not enabled for this account'); - return configured; - } - const selected = ['qoder-lite', 'lite', 'qoder-plus', 'plus'].find((name) => enabled.includes(name)) ?? enabled[0]; - assert.ok(selected, 'no enabled models returned'); - return selected; -} - -/** Six example scenarios share this suite; every cleanup gets a fresh deadline. */ -export class ForwardExampleSuite extends ForwardLiveSuite { - constructor(client, config = {}, run = {}) { - const timeout = config.timeout ?? 300_000; - super(client, { timeout: 30_000, scenarioTimeout: timeout, pollInterval: config.pollInterval ?? 2000, fetch: config.fetch }); - this.config = { timeout, cleanupTimeout: config.cleanupTimeout ?? 90_000, ...config }; - this.run = run; - this.options = { signal: run.signal ?? AbortSignal.timeout(timeout), timeout: 30_000 }; - this.resources = []; - this.replies = []; - this.turns = []; - } - - step(text) { this.run.step?.(text); } - log(text) { this.run.log?.(text); } - check(text) { this.run.check?.(text); } - record(key, value) { this.run.record?.(key, value); } - - track(kind, id, fn) { - assert.ok(id, `created ${kind} has no ID`); - this.record(kind, id); - this.resources.push({ kind, id }); - this.record('resources', this.resources.slice()); - this.log(`已创建 ${kind}:${id}`); - this.cleanup(`${kind} ${id}`, fn); - return id; - } - - async close() { - const failures = []; - const actions = this.cleanups.splice(0).reverse(); - const summary = { total: actions.length, passed: 0, failed: 0, items: [] }; - for (const { label, fn } of actions) { - const [kind, id] = label.split(' '); - try { - await fn({ signal: AbortSignal.timeout(this.config.cleanupTimeout), timeout: 30_000 }); - summary.items.push({ kind, id, status: 'cleaned' }); - summary.passed++; - this.log(`已清理 ${label}`); - } catch (error) { - if (resourceAlreadyGone(error)) { - summary.items.push({ kind, id, status: 'already_gone' }); - summary.passed++; - continue; - } - summary.items.push({ kind, id, status: 'failed' }); - summary.failed++; - failures.push(new CleanupFailure(`cleanup ${label} failed`, error)); - } - } - this.record('cleanup', summary); - if (failures.length) throw Object.assign(new AggregateError(failures, 'Forward example cleanup failed'), { cleanup: summary }); - return summary; - } - - async models() { - this.step('查询账号可用的模型'); - const models = await this.client.models.list(this.options); - const enabled = models.data.filter((model) => model.is_enabled).map((model) => model.id); - this.model = chooseForwardModel(this.config.model, enabled); - this.record('model', this.model); - this.log(`已启用 ${enabled.length} 个模型,本次使用:${this.model}`); - return enabled; - } - - async createEnvironment() { - this.step('创建云端执行环境(Environment)'); - const environment = await this.client.environments.create({ name: exampleName('env'), config: { type: 'cloud' } }, this.options); - return this.track('environment', environment.id, (options) => this.client.environments.archive(environment.id, options)); - } - - async createIdentity({ memory = false } = {}) { - this.step(memory ? '创建有展示名的专用 Identity,用于记忆绑定' : '创建用于本次示例的身份(Identity)'); - const identity = await this.client.identities.create({ - external_id: exampleName('identity'), - name: memory ? '记忆示例用户' : 'SDK 示例用户', - metadata: { suite: 'sdk-example' }, - }, this.options); - // Only the Identity created above is cleared. This reclaims the default - // memory store automatically provisioned by its newly created Sessions. - return this.track('identity', identity.id, async (options) => { - const cleared = await this.client.identities.clear(identity.id, { reason: 'SDK example cleanup' }, options); - assert.equal(cleared.status, 'completed', '专用 Identity 的关联资源清理未完成'); - this.record('identity_clear', { identity_id: identity.id, status: cleared.status, summary: cleared.summary }); - this.check('已清理专用 Identity 的关联资源(包含自动生成的默认记忆库)'); - await this.client.identities.delete(identity.id, options); - }); - } - - async createTemplate(params) { - if (!this.model) await this.models(); - this.step('创建助手模板(Template),配置模型和工具'); - const template = await this.client.templates.create({ - ...params, name: exampleName('template'), model: this.model, system: EXAMPLE_SYSTEM, - tools: [{ type: 'agent_toolset_20260401' }], - }, this.options); - return this.track('template', template.id, (options) => this.client.templates.archive(template.id, {}, options)); - } - - async createSession(params) { - this.step('创建会话(Session),关联助手和执行环境'); - const session = await this.client.sessions.create(params, this.options); - return this.track('session', session.id, (options) => this.finishSession(session.id, options)); - } - - async finishBatch(batchID, customID, identityID, templateID, options = { signal: AbortSignal.timeout(this.config.cleanupTimeout), timeout: 30_000 }) { - let current = await this.client.batches.retrieve(batchID, options); - assert.equal(current.id, batchID, 'batch cleanup response ID mismatch'); - if (!batchTerminal(current.status)) await this.client.batches.cancel(batchID, {}, options); - while (!batchTerminal(current.status)) { - current = await this.client.batches.retrieve(batchID, options); - assert.equal(current.id, batchID, 'batch cleanup response ID mismatch'); - if (!batchTerminal(current.status)) await this.pause(options); - } - this.record('batch_final_state', { id: batchID, status: current.status, request_counts: current.request_counts }); - if (!current.output_file_id) { - if (current.request_counts?.total === 0) return; - if (current.status !== 'cancelled') throw new CleanupFailure(`batch=${batchID} has no output for session cleanup`); - // A cancelled queue entry may never produce output. Only a run-exclusive - // Identity + Template can establish which Sessions this batch could own. - for (const [kind, id] of [['batch', batchID], ['identity', identityID], ['template', templateID]]) { - assert.ok(this.resources.some((item) => item.kind === kind && item.id === id), `batch cleanup requires this run's tracked ${kind}`); - } - const query = { identity_ids: [identityID], template_id: templateID, source_type: 'batch', include_archived: true, limit: 100 }; - const evidence = { batch_id: batchID, status: current.status, identity_id: identityID, template_id: templateID, - method: 'exclusive_identity_template_sessions', query, enumerated: false, verified: false, sessions: [] }; - this.record('batch_cleanup_evidence', evidence); - const seen = new Set(); - // Fully enumerate and validate before mutating any Session. PagePromise - // visits every page and propagates listing, cursor, and deadline failures. - try { - for await (const session of this.client.sessions.list(query, options)) { - assert.ok(session.id && !seen.has(session.id), 'batch cleanup returned a missing or repeated session ID'); - assert.equal(session.identity_id, identityID, 'batch cleanup session identity mismatch'); - assert.equal(session.template?.id, templateID, 'batch cleanup session template mismatch'); - assert.equal(session.source_type, 'batch', 'batch cleanup session source mismatch'); - seen.add(session.id); - evidence.sessions.push({ id: session.id, identity_id: session.identity_id, template_id: session.template.id, source_type: session.source_type, cleaned: false }); - } - } catch (error) { - // A 404 while discovering Sessions is not evidence the Batch is gone. - throw new CleanupFailure(`batch=${batchID} session discovery failed: ${error.message}`, error); - } - evidence.enumerated = true; - this.record('batch_cleanup_evidence', evidence); - try { - for (const session of evidence.sessions) { - await this.finishSession(session.id, options); - session.cleaned = true; - this.record('batch_cleanup_evidence', evidence); - } - } catch (error) { - throw new CleanupFailure(`batch=${batchID} session cleanup failed: ${error.message}`, error); - } - evidence.verified = true; - this.record('batch_cleanup_evidence', evidence); - this.check(`已逐页核实本轮专用 Identity 和 Template 的 Batch 会话,${evidence.sessions.length} 个关联会话均已清理`); - return; - } - let rows; - try { rows = await this.batchOutput(batchID, options); } - catch (error) { throw new CleanupFailure(`batch=${batchID} output cleanup failed`, error); } - assert.equal(rows.length, 1, `batch=${batchID} cleanup output does not match test input`); - const row = rows[0]; - assert.equal(row.custom_id, customID); - assert.equal(row.identity_id, identityID); - assert.equal(row.template_id, templateID); - if (row.session_id) await this.finishSession(row.session_id, options); - } - - async waitReply(sessionID, after, streaming = false) { - this.step(streaming ? '通过 SSE 接收助手回复' : '轮询会话事件,等待助手回复'); - const result = new TurnResult(after); - const toolCalls = []; - const observe = (event) => { - const complete = result.observe(event); - if (event.type === 'agent.message' && result.text) { - const reply = { session_id: sessionID, event_id: event.id, text: result.text }; - this.record('assistant_reply', reply); - this.replies.push(reply); - this.record('assistant_replies', this.replies.slice()); - this.log(`助手:${result.text}`); - } else if (['agent.tool_use', 'agent.mcp_tool_use'].includes(event.type)) { - const path = event.input?.file_path ?? event.input?.path ?? event.input?.filePath; - toolCalls.push({ id: event.id, type: event.type, name: event.name || '工具执行', ...(typeof path === 'string' ? { path } : {}) }); - this.log(`工具调用:${event.name || '工具执行'}${typeof path === 'string' ? ` ${path}` : ''}`); - } else if (event.type === 'session.status_idle' && complete) { - this.check('助手已正常结束本轮回复'); - } - return complete; - }; - if (streaming) { - const stream = await this.client.sessions.events.streamEvents(sessionID, { last_event_id: after || undefined, include_tool_calls: true }, { - ...this.options, timeout: this.config.timeout, - }); - try { for await (const event of stream) if (observe(event)) break; } - finally { stream.controller.abort(); } - } else { - while (!result.complete) { - let count = 0; - const events = this.client.sessions.events.list(sessionID, { - order: 'asc', limit: 100, include_tool_calls: true, after_id: result.lastID || undefined, - }, this.options); - for await (const event of events) { - assert.ok(++count <= 2000, 'event polling exceeded 2000 events'); - if (observe(event)) break; - } - if (!result.complete) await this.pause(this.options); - } - } - this.turns.push({ after, lastEventID: result.lastID, assistantText: result.text, toolUsed: result.toolUsed, complete: result.complete, toolCalls }); - this.record('turns', this.turns.slice()); - return result; - } - - async verifyTurn(sessionID, after, expected = [], tool = false, streaming = false) { - const result = await this.waitReply(sessionID, after, streaming); - this.step('校验本轮回复'); - result.verify(expected, tool); - this.check(`回复包含 ${expected.length} 个预期值,且本轮已正常结束`); - if (tool) this.check('已观察到实际工具调用'); - return result; - } - - async turn(sessionID, prompt, expected = [], tool = false, streaming = false) { - this.step('向会话发送消息'); - this.log(`用户:${prompt}`); - const after = await this.sendTurn(sessionID, prompt); - return this.verifyTurn(sessionID, after, expected, tool, streaming); - } -} - -export const createForwardExampleSuite = (client, config, run) => new ForwardExampleSuite(client, config, run); diff --git a/examples/managed-scenarios.mjs b/examples/managed-scenarios.mjs index 7a933d0..e617d9d 100644 --- a/examples/managed-scenarios.mjs +++ b/examples/managed-scenarios.mjs @@ -1,158 +1,69 @@ -import assert from 'node:assert/strict'; -import { managedMarker, managedName, managedMessage } from './managed-support.mjs'; -import { ProjectMemory, PROJECT_MEMORY_PATH } from './memory-proof.mjs'; +// Managed SDK 示例:最小可运行演示,展示 Agent + 会话与流式回复。 +// 仅作代码示例,不承担测试作用;运行:node examples/managed-scenarios.mjs +import { pathToFileURL } from 'node:url'; +import { ManagedClient } from '../dist/managed/index.js'; -export { createManagedExampleSuite } from './managed-support.mjs'; +const name = (kind) => `sdk-example-${kind}-${Math.random().toString(16).slice(2, 10)}`; -/** The six Managed scenarios run by `scenario-all`, including the memory proof. */ -export const managedExamples = [ - { - name: 'models', - description: '查询当前账号可用的模型。', - async run(suite, run) { - const enabled = await suite.models(); - run.log(`模型列表:${enabled.join(', ')}`); - run.check(`可用模型查询成功,本次使用 ${suite.model}`); - }, - }, - { - name: 'session', - description: '创建 Agent 和 Session,发送一条消息,通过 SSE 接收助手回复。', - async run(suite) { - const environment = await suite.environment(); - const agent = await suite.agent(); - const session = await suite.newSession({ environment_id: environment, agent }); - const marker = managedMarker(); - await suite.turn(session, `请用一句话介绍你能提供什么帮助,并在回复末尾原样附上:${marker}`, [marker], false, true); - }, - }, - { - name: 'resources', - description: '演示文件、环境变量和 Skill 在会话中的使用。', - async run(suite, run) { - const client = suite.client; - const environment = await suite.environment(); - const fileToken = managedMarker(), envToken = managedMarker(), skillToken = managedMarker(); - run.step('上传示例文件,供会话中的工具读取'); - const file = await client.files.upload({ file: new File([fileToken], 'sdk-example.txt') }, suite.options()); - suite.track('file', file.id, () => client.files.delete(file.id, {}, suite.options())); - const skillName = managedName('skill'); - run.step('上传自定义 Skill,其中包含一个随机校验值'); - const markdown = `---\nname: ${skillName}\ndescription: Provides a sample verification code for the SDK example.\n---\nThe example verification value EXAMPLE_SKILL_CODE is: ${skillToken}\n`; - const skill = await client.skills.create({ files: [new File([markdown], `${skillName}/SKILL.md`)] }, suite.options()); - suite.track('skill', skill.id, () => client.skills.delete(skill.id, {}, suite.options())); - const agent = await suite.agent({ skills: [{ type: 'custom', skill_id: skill.id, version: skill.latest_version }] }); - const session = await suite.newSession({ environment_id: environment, agent, environment_variables: { SDK_EXAMPLE_VALUE: envToken }, resources: [{ type: 'file', file_id: file.id, mount_path: '/data/workspace/sdk-example.txt' }] }); - await suite.turn(session, '请使用工具读取 /data/workspace/sdk-example.txt 的内容和 SDK_EXAMPLE_VALUE 环境变量,分别返回这两个示例值。', [fileToken, envToken], true); - // The preceding turn may already have loaded the Skill. Require a fresh - // read so the per-turn tool assertion remains meaningful; - // the expected code remains exclusively in the uploaded Skill body. - await suite.turn(session, `请在本轮实际调用 Read(或 Bash)工具,定位并重新读取技能 ${skillName} 的 SKILL.md 正文,再从刚读取的内容提取 EXAMPLE_SKILL_CODE 并返回这个示例校验码。即使之前已读取过该技能,也请本轮重新读取,不要只依据先前上下文回答。`, [skillToken], true); - }, - }, - { - name: 'memory', - description: '写入项目发布约定并挂载到 Session,验证全新会话的上线安排体现这些记忆。', - async run(suite, run) { - const client = suite.client; - const environment = await suite.environment(); - const memory = new ProjectMemory(); - run.record('memoryExpected', { project: memory.project, releaseTime: memory.releaseTime, contact: memory.contact, rollbackVersion: memory.rollbackVersion }); - run.step('创建记忆库(Memory Store)'); - const store = await client.memoryStores.create({ name: managedName('memory') }, suite.options()); - suite.track('memory_store', store.id, () => client.memoryStores.delete(store.id, {}, suite.options())); - run.step('通过 SDK 写入项目背景和发布约定'); - const entry = await client.memoryStores.memories.create(store.id, { path: PROJECT_MEMORY_PATH, content: memory.content() }, suite.options()); - run.log(`记忆正文:\n${memory.content()}`); - run.step('写入 MEMORY.md 索引,供新会话发现项目记忆'); - const index = await client.memoryStores.memories.create(store.id, { path: 'MEMORY.md', content: memory.index() }, suite.options()); - run.log('索引只包含正文入口,不包含发布时间、联系人和回滚版本'); - run.step('通过 SDK 重新读取,确认记忆正文和索引已保存'); - const saved = await client.memoryStores.memories.retrieve(entry.id, { memory_store_id: store.id }, suite.options()); - assert.equal(saved.content, memory.content(), '读取的记忆内容与写入内容不一致'); - const savedIndex = await client.memoryStores.memories.retrieve(index.id, { memory_store_id: store.id }, suite.options()); - assert.equal(savedIndex.content, memory.index(), '读取的记忆索引与写入内容不一致'); - run.check('记忆正文和索引已保存,内容与写入一致'); - const agent = await suite.agent(); - run.log('下面创建全新会话;项目约定只保存在记忆库中,不加入系统指令或聊天历史'); - const session = await suite.newSession({ environment_id: environment, agent, resources: [{ type: 'memory_store', memory_store_id: store.id, access: 'read_only' }] }); - run.step('查询新会话的资源,确认记忆库已挂载'); - let found = false; - for await (const resource of client.sessions.resources.list(session, {}, suite.options())) { - if (resource.type === 'memory_store' && resource.memory_store_id === store.id) found = true; - } - assert(found, '新会话的资源中未找到指定记忆库'); - run.check(`会话已挂载记忆库:${store.id}`); - run.step('请助手结合记忆拟定上线安排'); - run.log(`用户:${memory.prompt()}`); - const after = await suite.send(session, memory.prompt()); - const result = await suite.waitReply(session, after, false); - memory.verify(result, run); - }, - }, - { - name: 'deployment', - description: '创建 Deployment,手动触发一次运行并检查助手回复。', - async run(suite, run) { - const client = suite.client; - const environment = await suite.environment(); - const agent = await suite.agent(); - const marker = managedMarker(); - run.log(`本次运行初始消息:Reply with exactly ${marker}`); - run.step('创建 Deployment,设置运行时的初始消息'); - const deployment = await client.deployments.create({ name: managedName('deployment'), agent, environment_id: environment, initial_events: [managedMessage(`Reply with exactly ${marker}`)] }, suite.options()); - suite.track('deployment', deployment.id, () => client.deployments.archive(deployment.id, {}, suite.options())); - run.step('手动触发一次 Deployment 运行'); - const result = await client.deployments.run(deployment.id, {}, suite.options()); - run.record('deploymentRunID', result.id); - run.log(`本次运行 ID:${result.id}`); - assert(result.session_id, 'Deployment run returned no session'); - suite.track('session', result.session_id, () => suite.finishSession(result.session_id)); - const got = await client.deploymentRuns.retrieve(result.id, {}, suite.options()); - assert.equal(got.session_id, result.session_id, 'Deployment run session ID changed'); - await suite.waitTurn(result.session_id, '', [marker]); - }, - }, - { - name: 'dream', - description: '让 Dream 整理记忆,再读取输出记忆库验证整理结果。', - async run(suite, run) { - const client = suite.client; - if (!suite.model) await suite.models(); - run.step('创建记忆库(Memory Store)'); - const store = await client.memoryStores.create({ name: managedName('dream-input') }, suite.options()); - suite.track('input_memory_store', store.id, () => client.memoryStores.delete(store.id, {}, suite.options())); - const marker = managedMarker(); - run.step('向记忆库写入示例内容和随机校验值'); - await client.memoryStores.memories.create(store.id, { path: 'sdk-example/source.md', content: `Permanent project verification code: ${marker}. Preserve this exact code during consolidation.` }, suite.options()); - run.step('创建 Dream,整理输入记忆库中的内容'); - let dream = await client.dreams.create({ inputs: [{ type: 'memory_store', memory_store_id: store.id }], model: suite.model, instructions: 'Consolidate the supplied memory into sdk-example/consolidated.md. Preserve the exact project verification code. Keep the original source.' }, suite.options()); - const dreamID = dream.id; - suite.track('dream', dreamID, () => suite.finishDream(dreamID, store.id)); - run.step('等待 Dream 整理记忆'); - let previous = ''; - while (['pending', 'running'].includes(dream.status)) { - if (dream.status !== previous) run.log(`Dream 状态:${dream.status}`); - previous = dream.status; - await suite.pause(); - dream = await client.dreams.retrieve(dreamID, {}, suite.options()); - } - run.step('读取 Dream 输出,检查整理后的记忆'); - assert.equal(dream.status, 'completed', `dream=${dreamID} status=${dream.status}`); - assert(dream.outputs?.length, `dream=${dreamID} returned no outputs`); - for (const output of dream.outputs) { - for await (const memory of client.memoryStores.memories.list(output.memory_store_id, {}, suite.options())) { - if (memory.path !== 'sdk-example/consolidated.md') continue; - const got = await client.memoryStores.memories.retrieve(memory.id, { memory_store_id: output.memory_store_id }, suite.options()); - if (got.content.includes(marker)) { - run.record('consolidatedMemory', { id: got.id, memoryStoreID: output.memory_store_id, path: got.path, content: got.content }); - run.check(`输出记忆保留了原始校验值:${got.path}`); - run.log(`整理后的记忆:\n${got.content}`); - return; - } - } +function makeClient() { + const pat = process.env.QODER_PAT ?? process.env.QODER_MANAGED_PAT; + if (!pat) throw new Error('设置 QODER_PAT 后运行本示例'); + return new ManagedClient({ + pat, + baseURL: process.env.QODER_MANAGED_BASE_URL || 'https://api.qoder.com/api/v1/cloud/', + maxRetries: 0, + }); +} + +async function chooseModel(client) { + const models = await client.models.list(); + const enabled = models.data.filter((m) => m.is_enabled).map((m) => m.id); + if (!enabled.length) throw new Error('账号没有已启用的模型'); + return enabled[0]; +} + +// 演示:创建 Agent 与会话,发一轮消息并流式打印助手回复,最后清理。 +async function sessionDemo(client) { + const model = await chooseModel(client); + console.log(`使用模型:${model}`); + + const agent = await client.agents.create({ name: name('agent'), model, system: '你是一个 SDK 示例助手。' }); + const session = await client.sessions.create({ agent_id: agent.id }); + console.log(`已创建会话:${session.id}`); + + const sent = await client.sessions.events.send(session.id, { + events: [{ type: 'user.message', content: [{ type: 'text', text: '请用一句话介绍你能提供什么帮助。' }] }], + }); + const lastEventID = sent.data[0]?.id; + + const stream = await client.sessions.events.streamEvents(session.id, { last_event_id: lastEventID }); + try { + for await (const event of stream) { + if (event.type === 'agent.message') { + const text = (event.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join(''); + if (text) console.log(`助手:${text}`); + } else if (event.type === 'session.status_idle') { + break; } - throw new Error('Dream did not persist consolidated memory with the original verification code'); - }, - }, -]; + } + } finally { + stream.controller.abort(); + } + + await client.sessions.delete(session.id); + await client.agents.archive(agent.id); + console.log('已清理示例资源。'); +} + +async function main() { + const client = makeClient(); + await sessionDemo(client); +} + +// 仅在直接运行时执行(被 import 时不触发网络)。 +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/examples/managed-support.mjs b/examples/managed-support.mjs deleted file mode 100644 index 627475c..0000000 --- a/examples/managed-support.mjs +++ /dev/null @@ -1,242 +0,0 @@ -import assert from 'node:assert/strict'; -import { randomBytes } from 'node:crypto'; -import { setTimeout as delay } from 'node:timers/promises'; -import { TurnResult } from './execution.mjs'; - -export const managedMarker = () => randomBytes(8).toString('hex'); -export const managedName = prefix => `sdk-example-${prefix}-${managedMarker()}`; -export const managedMessage = text => ({ type: 'user.message', content: [{ type: 'text', text }] }); - -/** Shared helpers for the Managed session and dream examples. */ -export function createManagedExampleSuite(client, config, run) { - return new ManagedExampleSuite(client, config, run); -} - -class ManagedExampleSuite { - constructor(client, config = {}, run) { - this.client = client; - this.config = { timeout: 180_000, cleanupTimeout: 90_000, ...config }; - this.run = run; - this.model = ''; - this.cleanups = []; - this.resources = []; - this.cleanupResults = []; - this.turnResults = []; - this.closed = false; - this.signal = run.signal ?? AbortSignal.timeout(this.config.timeout); - } - - options(extra = {}) { return { signal: this.signal, ...extra }; } - pause() { return delay(2000, undefined, { signal: this.signal }); } - - noteResource(kind, id) { - if (!this.resources.some(item => item.kind === kind && item.id === id)) { - this.resources.push({ kind, id }); - this.run.record('resources', this.resources.map(item => ({ ...item }))); - } - } - - track(kind, id, cleanup) { - assert(id, `Created ${kind} returned no ID`); - this.noteResource(kind, id); - this.run.record(kind, id); - this.cleanups.push({ kind, id, cleanup }); - this.run.log(`已创建 ${kind}:${id}`); - } - - async close() { - if (this.closed) return this.cleanupSummary(); - this.closed = true; - const errors = []; - while (this.cleanups.length) { - const { kind, id, cleanup } = this.cleanups.pop(); - // A canceled/expired scenario never cancels its resource cleanup. Every - // tracked resource receives its own independent cleanup deadline. - this.signal = AbortSignal.timeout(this.config.cleanupTimeout); - this.run.log(`清理 ${kind}:${id}`); - try { - await cleanup(); - this.cleanupResults.push({ kind, id, status: 'cleaned' }); - } catch (error) { - this.cleanupResults.push({ kind, id, status: 'failed', error: error.message }); - errors.push(new Error(`清理 ${kind} ${id} 失败`, { cause: error })); - } - this.run.record('cleanup', this.cleanupSummary()); - } - const summary = this.cleanupSummary(); - this.run.record('cleanup', summary); - if (errors.length) throw Object.assign(new AggregateError(errors, `${errors.length} 项 Managed 资源清理失败`), { cleanup: summary }); - this.run.log(`清理完成:${this.cleanupResults.length} 项资源`); - return summary; - } - - cleanupSummary() { - return { - total: this.cleanupResults.length, - passed: this.cleanupResults.filter(item => item.status === 'cleaned').length, - failed: this.cleanupResults.filter(item => item.status === 'failed').length, - items: this.cleanupResults.map(item => ({ ...item })), - }; - } - - async models() { - this.run.step('查询账号可用的模型'); - const models = await this.client.models.list({}, this.options()); - const enabled = models.data.filter(model => model.is_enabled).map(model => model.id); - if (this.config.model) { - assert(enabled.includes(this.config.model), 'Configured model is not enabled for this account'); - this.model = this.config.model; - } else { - this.model = ['qoder-lite', 'lite', 'qoder-plus', 'plus'].find(model => enabled.includes(model)) ?? enabled[0]; - assert(this.model, 'No enabled models returned'); - } - this.run.record('model', this.model); - this.run.record('enabledModels', enabled); - this.run.log(`已启用 ${enabled.length} 个模型,本次使用:${this.model}`); - return enabled; - } - - async environment() { - this.run.step('创建云端执行环境(Environment)'); - const environment = await this.client.environments.create({ name: managedName('env'), config: { type: 'cloud' } }, this.options()); - this.track('environment', environment.id, () => this.client.environments.archive(environment.id, {}, this.options())); - return environment.id; - } - - async agent(params = {}) { - if (!this.model) await this.models(); - this.run.step('创建助手(Agent),配置模型和工具'); - const agent = await this.client.agents.create({ - ...params, - name: managedName('agent'), - model: { id: this.model }, - system: '你是一个 SDK 示例助手。帮助用户了解会话、文件、技能与记忆的用法,必要时调用工具。仅使用可实际读取的数据回答问题。', - tools: [{ type: 'agent_toolset_20260401' }], - }, this.options()); - this.track('agent', agent.id, () => this.client.agents.archive(agent.id, {}, this.options())); - return agent.id; - } - - async newSession(params) { - this.run.step('创建会话(Session),关联助手和执行环境'); - const session = await this.client.sessions.create(params, this.options()); - this.track('session', session.id, () => this.finishSession(session.id)); - return session.id; - } - - async finishSession(id) { - let session = await this.client.sessions.retrieve(id, {}, this.options()); - if (!['idle', 'terminated'].includes(session.status)) { - await this.client.sessions.events.send(id, { events: [{ type: 'user.interrupt' }] }, this.options()); - for (;;) { - session = await this.client.sessions.retrieve(id, {}, this.options()); - if (['idle', 'terminated'].includes(session.status)) break; - await this.pause(); - } - } - await this.client.sessions.delete(id, {}, this.options()); - } - - async send(id, prompt) { - const response = await this.client.sessions.events.send(id, { events: [managedMessage(prompt)] }, this.options({ idempotencyKey: managedName('event') })); - assert.equal(response.data?.length, 1, 'Send did not return one user event'); - assert(response.data[0].id, 'Send did not return one user event ID'); - return response.data[0].id; - } - - async waitReply(id, after, streaming = false) { - this.run.step(streaming ? '通过 SSE 接收助手回复' : '轮询会话事件,等待助手回复'); - const result = new TurnResult(after); - const toolCalls = []; - const observe = event => { - const complete = result.observe(event); - if (['agent.tool_use', 'agent.mcp_tool_use'].includes(event.type)) { - // Retain paths for diagnosing reused Skill content without logging tool - // result bodies, shell commands, or environment variable values. - const path = event.input?.file_path ?? event.input?.path ?? event.input?.filePath; - toolCalls.push({ id: event.id, type: event.type, name: event.name, ...(typeof path === 'string' ? { path } : {}) }); - this.run.log(`工具调用:${event.name ?? event.type}${typeof path === 'string' ? ` · ${path}` : ''}`); - } - if (event.type === 'agent.message') this.run.log(`助手:${result.text.trim()}`); - if (event.type.startsWith('session.status_')) this.run.log(`会话状态:${event.type.slice('session.status_'.length)}`); - return complete; - }; - if (streaming) { - const stream = await this.client.sessions.events.streamEvents(id, {}, this.options({ headers: { 'Last-Event-ID': after }, timeout: this.config.timeout })); - try { for await (const event of stream) if (observe(event)) break; } - finally { await stream.close(); } - } else { - while (!result.complete) { - const params = { order: 'asc', limit: 100, ...(result.lastID ? { after_id: result.lastID } : {}) }; - let count = 0; - for await (const event of this.client.sessions.events.list(id, params, this.options())) { - assert(++count <= 2000, 'Event polling exceeded 2000 events'); - if (observe(event)) break; - } - if (!result.complete) await this.pause(); - } - } - this.run.record('lastEventID', result.lastID); - this.run.record('assistantText', result.text); - this.run.record('toolUsed', result.toolUsed); - this.turnResults.push({ after, lastEventID: result.lastID, assistantText: result.text, toolUsed: result.toolUsed, complete: result.complete, toolCalls }); - this.run.record('turns', this.turnResults.map(turn => ({ ...turn, toolCalls: turn.toolCalls.map(call => ({ ...call })) }))); - return result; - } - - async waitTurn(id, after, expected, tool = false, streaming = false) { - const result = await this.waitReply(id, after, streaming); - this.run.step('校验本轮回复'); - result.verify(expected, tool); - this.run.check(`回复包含 ${expected.length} 个预期值,且本轮已正常结束`); - if (tool) this.run.check('已观察到实际工具调用'); - return result; - } - - async turn(id, prompt, expected, tool = false, streaming = false) { - this.run.step('向会话发送消息'); - this.run.log(`用户:${prompt}`); - const after = await this.send(id, prompt); - return this.waitTurn(id, after, expected, tool, streaming); - } - - async finishDream(id, inputID) { - let current = await this.client.dreams.retrieve(id, {}, this.options()); - if (['pending', 'running'].includes(current.status)) { - await this.client.dreams.cancel(id, {}, this.options()); - while (['pending', 'running'].includes(current.status)) { - await this.pause(); - current = await this.client.dreams.retrieve(id, {}, this.options()); - } - } - const errors = []; - if (current.session_id) { - this.noteResource('dream_session', current.session_id); - this.run.log(`清理 Dream 关联的会话:${current.session_id}`); - try { - await this.finishSession(current.session_id); - this.cleanupResults.push({ kind: 'dream_session', id: current.session_id, status: 'cleaned' }); - } catch (error) { - this.cleanupResults.push({ kind: 'dream_session', id: current.session_id, status: 'failed', error: error.message }); - errors.push(error); - } - } - const seen = new Set([inputID]); - for (const output of current.outputs ?? []) { - if (!output.memory_store_id || seen.has(output.memory_store_id)) continue; - seen.add(output.memory_store_id); - this.noteResource('dream_output_memory_store', output.memory_store_id); - this.run.log(`清理 Dream 输出记忆库:${output.memory_store_id}`); - try { - await this.client.memoryStores.delete(output.memory_store_id, {}, this.options()); - this.cleanupResults.push({ kind: 'dream_output_memory_store', id: output.memory_store_id, status: 'cleaned' }); - } catch (error) { - this.cleanupResults.push({ kind: 'dream_output_memory_store', id: output.memory_store_id, status: 'failed', error: error.message }); - errors.push(error); - } - } - try { await this.client.dreams.archive(id, {}, this.options()); } - catch (error) { errors.push(error); } - if (errors.length) throw new AggregateError(errors, 'Dream 关联资源清理失败'); - } -} diff --git a/examples/memory-proof.mjs b/examples/memory-proof.mjs deleted file mode 100644 index 5154361..0000000 --- a/examples/memory-proof.mjs +++ /dev/null @@ -1,51 +0,0 @@ -import { randomBytes, randomInt } from 'node:crypto'; - -/** Memory proof: the facts live only in the linked memory entry. */ -export const PROJECT_MEMORY_PATH = 'projects/release-conventions.md'; -const contacts = ['林岚', '陈朔', '叶澄', '苏棠']; - -export class ProjectMemory { - constructor({ project, releaseTime, contact, rollbackVersion } = {}) { - this.project = project ?? `青禾订单-${randomBytes(3).toString('hex')}`; - this.releaseTime = releaseTime ?? `${randomInt(20, 24)}:${String(randomInt(60)).padStart(2, '0')}`; - this.contact = contact ?? contacts[randomInt(contacts.length)]; - this.rollbackVersion = rollbackVersion ?? `v2.${randomInt(100, 1000)}.${randomInt(100, 1000)}`; - for (const field of ['project', 'releaseTime', 'contact', 'rollbackVersion']) { - if (typeof this[field] !== 'string' || !this[field].trim()) throw new TypeError(`ProjectMemory.${field} must be a nonempty string`); - } - } - - content() { - return `---\nname: release-conventions\ndescription: ${this.project} 的项目发布约定\nmetadata:\n type: project\n---\n\n# ${this.project} 的发布约定\n\n- 这是一个订单服务项目。\n- 团队约定在北京时间 ${this.releaseTime} 开始发布。\n- 发布异常时先联系值班负责人${this.contact}。\n- 如果需要回滚,使用已验证的稳定版本 ${this.rollbackVersion}。\n\n**Why:** 团队需要在值班人员在岗的窗口发布,并使用验证过的版本恢复服务。\n**How to apply:** 为这个项目拟定发布计划时,遵循以上团队约定。\n`; - } - - index() { - return `- [${this.project} 发布约定](${PROJECT_MEMORY_PATH}) — 项目的发布窗口、异常联系人与回滚约定。\n`; - } - - prompt() { - return `请根据你记得的项目约定,为「${this.project}」拟一份简短的上线安排,涵盖开始时间、异常联系和回滚处理。只需给出计划,不要执行发布;如果缺少信息,请明确说明。`; - } - - verify(result, run) { - run?.step?.('检查上线安排是否用到了预先写入的记忆'); - // Memory can be provided in context; successful recall does not require a tool call. - result.verify([], false); - const checked = message => { if (run?.check) run.check(message); else run?.log?.(message); }; - const missing = []; - for (const [label, value] of [ - ['发布开始时间', this.releaseTime], - ['异常联系人', this.contact], - ['回滚版本', this.rollbackVersion], - ]) { - if (result.text.includes(value)) checked(`${label}:${value}`); - else { - missing.push(label); - run?.log?.(`回复未体现${label}(记忆中的值:${value})`); - } - } - if (missing.length) throw new Error(`助手的最终回复未体现以下记忆:${missing.join('、')};last_event_id=${result.lastID}`); - checked('全新会话的回答体现了 3 项记忆;这些值只通过 Memory Store 提供'); - return result; - } -} diff --git a/examples/run.mjs b/examples/run.mjs deleted file mode 100644 index 667ac7d..0000000 --- a/examples/run.mjs +++ /dev/null @@ -1,183 +0,0 @@ -import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { parseEnv } from 'node:util'; -import { pathToFileURL } from 'node:url'; -import { ForwardClient, ManagedClient } from '../dist/index.js'; - -export function parseArguments(argv) { - const config = { mode: 'both', scenario: 'models', region: 'cn', env: '.env.live', timeout: 300_000, cleanupTimeout: 90_000, output: 'text', report: '' }; - for (let i = 0; i < argv.length; i++) { - const [flag, inline] = argv[i].replace(/^--?/, '').split(/=(.*)/s); - if (flag === 'help' || flag === 'h') { config.help = true; continue; } - if (!['mode', 'scenario', 'region', 'env', 'timeout', 'cleanup-timeout', 'output', 'report', 'model'].includes(flag)) throw new Error(`Unknown option: ${argv[i]}`); - const value = inline ?? argv[++i]; - if (!value || value.startsWith('-')) throw new Error(`Missing value for ${flag}`); - if (flag === 'timeout' || flag === 'cleanup-timeout') { - const match = /^(\d+(?:\.\d+)?)(ms|s|m)?$/.exec(value); - const ms = match ? Number(match[1]) * ({ ms: 1, s: 1000, m: 60000 }[match[2] ?? 's']) : NaN; - if (!Number.isFinite(ms) || ms <= 0 || ms > 1_800_000) throw new Error(`${flag} must be between 1ms and 30m`); - config[flag === 'timeout' ? 'timeout' : 'cleanupTimeout'] = ms; - } else config[flag] = value; - } - if (!['forward', 'managed', 'both'].includes(config.mode)) throw new Error('mode must be forward, managed or both'); - if (!['cn', 'international'].includes(config.region)) throw new Error('region must be cn or international'); - if (!['text', 'json'].includes(config.output)) throw new Error('output must be text or json'); - return config; -} - -export function loadConfiguration(options, environment = process.env) { - const file = resolve(options.env); - if (!existsSync(file)) throw new Error(`Configuration file does not exist: ${file}`); - const contents = readFileSync(file, 'utf8'); - // parseEnv tolerates unterminated quotes; reject malformed credentials first. - const lines = contents.split(/\r?\n/); - for (let line = 0; line < lines.length; line++) { - const input = lines[line].trim(); - if (!input || input.startsWith('#')) continue; - const assignment = /^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.*)$/.exec(input); - if (!assignment) throw new Error(`Invalid env assignment at line ${line + 1}`); - const value = assignment[1]; - if (value.startsWith('"') || value.startsWith("'")) { - const quote = value[0]; - const started = line + 1; - let remainder = value.slice(1); - while (!remainder.includes(quote) && line + 1 < lines.length) remainder += '\n' + lines[++line]; - if (!remainder.includes(quote)) throw new Error(`Unterminated env quote at line ${started}`); - } - } - const values = { ...parseEnv(contents), ...environment }; - return (options.mode === 'both' ? ['forward', 'managed'] : [options.mode]).map(mode => { - const prefix = `QODER_${mode.toUpperCase()}_`; - const pat = values[`${prefix}PAT`] || values.QODER_PAT; - if (!pat) throw new Error(`${prefix}PAT or QODER_PAT is required`); - const base = new URL(values[`${prefix}BASE_URL`] || `https://api.qoder.com.cn/api/v1/${mode === 'forward' ? 'forward' : 'cloud'}`); - if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash || !['api.qoder.com', 'api.qoder.com.cn'].includes(base.host)) throw new Error('Examples require an HTTPS Qoder API URL without credentials or query'); - base.host = options.region === 'cn' ? 'api.qoder.com.cn' : 'api.qoder.com'; - return { ...options, mode, envFile: file, pat, baseURL: base.href.replace(/\/$/, ''), model: options.model || values[`${prefix}MODEL`] || undefined }; - }); -} - -export function createSanitizer(tokens = []) { - const text = input => { - let value = String(input); - for (const token of tokens) if (token) value = value.split(token).join('[REDACTED]'); - return value.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '\uFFFD').replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]').replace(/https?:\/\/[^\s"'<>]+/g, match => { - try { const u = new URL(match); return `${u.origin}${u.pathname}${u.search ? '?[REDACTED]' : ''}`; } catch { return '[URL REDACTED]'; } - }); - }; - const data = (value, seen = new WeakSet()) => { - if (typeof value === 'string') return text(value); - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return '[Circular]'; - seen.add(value); - if (Array.isArray(value)) return value.map(item => data(item, seen)); - const result = {}; - for (const [key, item] of Object.entries(value)) result[key] = /^(pat|accessToken|authorization|password|secret)$/i.test(key) ? '[REDACTED]' : data(item, seen); - return result; - }; - return { text, data }; -} - -function errorDetails(error, safe) { - const result = { name: error?.name ?? 'Error', message: safe.text(error?.message ?? error) }; - for (const key of ['status', 'code', 'type', 'request_id']) if (error?.[key] !== undefined) result[key] = error[key]; - if (error?.cause) result.cause = errorDetails(error.cause, safe); - if (error instanceof AggregateError) result.errors = error.errors.map(item => errorDetails(item, safe)); - return result; -} - -export async function main(argv = process.argv.slice(2)) { - const options = parseArguments(argv); - if (options.help) { - console.log('Usage: npm run example -- -mode forward|managed|both -scenario all|models|session|resources|memory|schedule|batch|deployment|dream -region cn|international [-env .env.live] [-model auto] [-timeout 5m] [-output text|json] [-report result.json]'); - return 0; - } - const configs = loadConfiguration(options); - const safe = createSanitizer(configs.map(c => c.pat)); - const definitions = {}; - for (const config of configs) { - const module = await import(`./${config.mode}-scenarios.mjs`); - definitions[config.mode] = { scenarios: module[`${config.mode}Examples`], factory: module[`create${config.mode === 'forward' ? 'Forward' : 'Managed'}ExampleSuite`] }; - if (typeof definitions[config.mode].factory !== 'function') throw new Error(`${config.mode}: scenario suite factory is missing`); - if (options.scenario !== 'all' && !definitions[config.mode].scenarios.some(s => s.name === options.scenario)) throw new Error(`${config.mode}: unknown scenario ${options.scenario}`); - } - const reportPath = resolve(options.report || `build/example-results/${new Date().toISOString().replace(/[:.]/g, '-')}-${options.mode}-${options.scenario}.json`); - mkdirSync(resolve(reportPath, '..'), { recursive: true }); - const report = { startedAt: new Date().toISOString(), region: options.region, scenario: options.scenario, configuration: configs.map(({ mode, envFile, baseURL, model }) => ({ mode, envFile, baseURL, model })), scenarios: [] }; - const save = () => writeFileSync(reportPath, JSON.stringify(safe.data(report), null, 2) + '\n'); - const emit = (kind, payload) => { - const clean = safe.data(payload); - if (options.output === 'json') console.log(JSON.stringify({ time: new Date().toISOString(), kind, ...clean })); - else console.log(clean.message ?? JSON.stringify(clean)); - }; - const interruption = new AbortController(); - const interrupt = () => { if (!interruption.signal.aborted) { emit('interrupt', { message: '收到中断,结束当前操作并清理测试资源。' }); interruption.abort(); } }; - process.on('SIGINT', interrupt); process.on('SIGTERM', interrupt); - try { - for (const config of configs) { - let active; - const Client = config.mode === 'forward' ? ForwardClient : ManagedClient; - const client = new Client({ pat: config.pat, baseURL: config.baseURL, maxRetries: 0, timeout: 30_000, fetch: async (input, init) => { - const start = Date.now(); - const url = new URL(input instanceof Request ? input.url : String(input)); - try { - const response = await fetch(input, init); - const request = { method: init?.method ?? (input instanceof Request ? input.method : 'GET'), path: url.pathname, status: response.status, request_id: response.headers.get('x-request-id') ?? response.headers.get('request-id'), durationMs: Date.now() - start }; - active?.requests.push(request); - if (!response.ok) emit('http_error', { mode: config.mode, scenario: active?.name, ...request, message: `HTTP ${request.status} ${request.method} ${request.path} request_id=${request.request_id}` }); - return response; - } catch (error) { - active?.requests.push({ method: init?.method ?? 'GET', path: url.pathname, error: error?.name, durationMs: Date.now() - start }); - throw error; - } - } }); - emit('mode', { message: `${config.mode} — ${config.baseURL} — model=${config.model ?? '自动选择'} — 每场景${config.timeout / 1000}s` }); - for (const scene of definitions[config.mode].scenarios) { - if (options.scenario !== 'all' && options.scenario !== scene.name) continue; - if (interruption.signal.aborted) break; - active = { mode: config.mode, name: scene.name, description: scene.description, startedAt: new Date().toISOString(), status: 'running', records: {}, requests: [] }; - report.scenarios.push(active); save(); - const started = Date.now(); - let action = scene.description ?? scene.name; - const run = { - signal: AbortSignal.any([interruption.signal, AbortSignal.timeout(config.timeout)]), - step(message) { action = message; emit('step', { mode: config.mode, scenario: scene.name, message: `[${config.mode}/${scene.name}] ${message}` }); }, - log(message) { emit('log', { mode: config.mode, scenario: scene.name, message: String(message) }); }, - check(message) { emit('checked', { mode: config.mode, scenario: scene.name, message: `✓ ${message}` }); }, - record(key, value) { active.records[key] = safe.data(value); save(); }, - }; - const heartbeat = setInterval(() => emit('waiting', { mode: config.mode, scenario: scene.name, message: `[${config.mode}/${scene.name}] 等待中:${action}(${Math.round((Date.now() - started) / 1000)}s)` }), 15_000); - let suite; - try { - suite = definitions[config.mode].factory(client, config, run); - await scene.run(suite, run); - active.status = 'passed'; - } catch (error) { - active.status = 'failed'; active.error = errorDetails(error, safe); - emit('failed', { mode: config.mode, scenario: scene.name, message: `[${config.mode}/${scene.name}] 失败:${JSON.stringify(active.error)}` }); - } finally { - action = '清理本次创建的资源'; - if (suite) { - try { const cleanup = await suite.close(); if (cleanup) active.records.cleanup = safe.data(cleanup); } - catch (error) { active.status = 'failed'; active.cleanupError = errorDetails(error, safe); emit('cleanup_failed', { message: `[${config.mode}/${scene.name}] 清理失败:${JSON.stringify(active.cleanupError)}` }); } - } - clearInterval(heartbeat); - active.durationMs = Date.now() - started; active.finishedAt = new Date().toISOString(); - emit('scenario_result', { ...active, requests: undefined, records: undefined, message: `[${config.mode}/${scene.name}] ${active.status.toUpperCase()} ${(active.durationMs / 1000).toFixed(1)}s` }); - save(); - } - } - if (interruption.signal.aborted) break; - } - } finally { process.off('SIGINT', interrupt); process.off('SIGTERM', interrupt); } - report.finishedAt = new Date().toISOString(); - report.summary = { total: report.scenarios.length, passed: report.scenarios.filter(s => s.status === 'passed').length, failed: report.scenarios.filter(s => s.status !== 'passed').length, interrupted: interruption.signal.aborted }; - save(); - emit('summary', { ...report.summary, report: reportPath, message: `完成:${report.summary.passed}/${report.summary.total} 通过,${report.summary.failed} 失败。报告:${reportPath}` }); - return report.summary.failed || report.summary.interrupted ? 1 : 0; -} - -if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { - try { process.exitCode = await main(); } - catch (error) { console.error(`配置错误:${error.message}`); process.exitCode = 1; } -} diff --git a/package.json b/package.json index 771bc8b..a95e917 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "prepack": "npm run build", "test:types": "tsc -p tsconfig.types.json", "test:e2e": "npm test && node scripts/run-live.mjs e2e", - "example": "npm run build && node examples/run.mjs", + "example": "npm run build && node examples/forward-scenarios.mjs", "docs": "node scripts/generate-docs.mjs", "docs:check": "node scripts/docs-check.mjs" }, diff --git a/tests/examples-batch-cleanup.test.mjs b/tests/examples-batch-cleanup.test.mjs deleted file mode 100644 index b2b5460..0000000 --- a/tests/examples-batch-cleanup.test.mjs +++ /dev/null @@ -1,169 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { ForwardExampleSuite } from '../examples/forward-support.mjs'; -import { testClient, response } from './helpers.mjs'; -import { main } from '../examples/run.mjs'; - -const ownSession = (id, extra = {}) => ({ id, identity_id: 'identity-own', template: { id: 'template-own' }, source_type: 'batch', ...extra }); -const batch = (extra = {}) => ({ id: 'batch-own', status: 'cancelled', request_counts: { total: 1 }, ...extra }); -function makeSuite(list, { state, sessionResponse, owned = ['batch','identity','template'] } = {}) { - const requests = [], evidence = [], records = {}; - let page = 0; - const client = testClient('forward', async req => { - const url = new URL(req.url); const path = url.pathname.replace('/api/v1/forward',''); - requests.push({ method: req.method, path, query: Object.fromEntries(url.searchParams) }); - if (path === '/batches/batch-own') return response(batch(state)); - if (path === '/sessions') { - assert.deepEqual(url.searchParams.getAll('identity_ids'), ['identity-own']); - assert.equal(url.searchParams.get('template_id'), 'template-own'); - assert.equal(url.searchParams.get('source_type'), 'batch'); - assert.equal(url.searchParams.get('include_archived'), 'true'); - assert.equal(url.searchParams.get('limit'), '100'); - return list(++page, req, url); - } - if (sessionResponse && path.startsWith('/sessions/')) { const res = await sessionResponse(req,path); if (res) return res; } - if (req.method === 'GET' && /^\/sessions\/session-/.test(path)) return response({ id: path.split('/')[2], status: 'idle' }); - if (req.method === 'POST' && /^\/sessions\/session-[^/]+\/archive$/.test(path)) return response({ id: path.split('/')[2] }); - assert.fail(`unexpected request ${req.method} ${path}`); - }); - const suite = new ForwardExampleSuite(client, { timeout: 1000, cleanupTimeout: 1000, pollInterval: 1 }, { - step() {}, log() {}, check() {}, record(key, value) { - records[key] = structuredClone(value); - if (key === 'batch_cleanup_evidence') evidence.push(structuredClone(value)); - }, - }); - for (const kind of owned) suite.track(kind, `${kind}-own`, () => {}); - return { suite, requests, evidence, records }; -} -const finish = (s, options) => s.finishBatch('batch-own', 'task-own', 'identity-own', 'template-own', options); -const mutations = requests => requests.filter(r => r.path.startsWith('/sessions/') && r.method !== 'GET'); - -test('cancelled batch without output validates every filtered page before cleaning owned sessions', async () => { - const { suite, requests, evidence, records } = makeSuite((page, req, url) => { - if (page === 1) return response({ data: [ownSession('session-one')], last_id: 'session-one', has_more: true }); - assert.equal(page, 2); assert.equal(url.searchParams.get('after_id'), 'session-one'); - return response({ data: [ownSession('session-two')], has_more: false }); - }); - await finish(suite); - assert.deepEqual(requests.map(r => `${r.method} ${r.path}`), [ - 'GET /batches/batch-own','GET /sessions','GET /sessions', - 'GET /sessions/session-one','POST /sessions/session-one/archive', - 'GET /sessions/session-two','POST /sessions/session-two/archive', - ]); - assert.equal(evidence[0].verified, false); assert.equal(records.batch_cleanup_evidence.verified, true); - assert.deepEqual(records.batch_cleanup_evidence.sessions.map(s => [s.id,s.cleaned]), [['session-one',true],['session-two',true]]); -}); -test('cancelled queued batch can prove zero owned sessions without a public output file', async () => { - const { suite, requests, records } = makeSuite(() => response({ data: [], has_more: false })); - await finish(suite); - assert.deepEqual(requests.map(r => r.path), ['/batches/batch-own','/sessions']); - assert.equal(records.batch_cleanup_evidence.verified, true); assert.deepEqual(records.batch_cleanup_evidence.sessions, []); -}); -for (const [name, foreign] of [ - ['identity', { identity_id: 'foreign' }], ['template', { template: { id: 'foreign' } }], ['source', { source_type: 'interactive' }], - ['missing ID', { id: '' }], ['duplicate ID', { id: 'session-one' }], -]) test(`batch cleanup refuses ${name} on a later page before touching any session`, async () => { - const { suite, requests, records } = makeSuite(page => page === 1 - ? response({ data: [ownSession('session-one')], last_id: 'session-one', has_more: true }) - : response({ data: [ownSession('session-two', foreign)], has_more: false })); - await assert.rejects(() => finish(suite)); - assert.deepEqual(mutations(requests), []); - assert.equal(requests.some(r => r.path.startsWith('/sessions/')), false); - assert.equal(records.batch_cleanup_evidence.verified, false); -}); -for (const kind of ['batch','identity','template']) test(`batch fallback refuses missing tracked ${kind} ownership`, async () => { - const { suite, requests } = makeSuite(() => assert.fail('unowned scope must not list sessions'), { owned: ['batch','identity','template'].filter(k => k !== kind) }); - await assert.rejects(() => finish(suite), new RegExp(`tracked ${kind}`)); - assert.deepEqual(requests.map(r => r.path), ['/batches/batch-own']); -}); -for (const status of ['completed','failed','expired']) test(`missing output for ${status} batch remains a cleanup error`, async () => { - const { suite, requests } = makeSuite(() => assert.fail('fallback only applies to cancelled batch'), { state: { status } }); - await assert.rejects(() => finish(suite), /no output/); - assert.deepEqual(requests.map(r => r.path), ['/batches/batch-own']); -}); -test('batch cleanup rejects a response for a different batch ID', async () => { - const { suite, requests } = makeSuite(() => assert.fail('wrong batch must not list sessions'), { state: { id: 'other-batch' } }); - await assert.rejects(() => finish(suite), /response ID mismatch/); - assert.deepEqual(mutations(requests), []); -}); -for (const status of [404, 500]) test(`session discovery HTTP ${status} cannot be swallowed as successful cleanup`, async () => { - const { suite, requests, records } = makeSuite(() => response({ error: { message: 'discovery failed' } }, status)); - suite.cleanup('batch batch-own', options => finish(suite, options)); - await assert.rejects(() => suite.close(), AggregateError); - assert.deepEqual(mutations(requests), []); - assert.equal(records.batch_cleanup_evidence.verified, false); - assert.equal(records.cleanup.failed, 1); -}); -test('canceling during session discovery propagates without mutation or verified evidence', async () => { - const controller = new AbortController(); - const { suite, requests, records } = makeSuite(() => { controller.abort(); throw controller.signal.reason; }); - await assert.rejects(() => finish(suite, { signal: controller.signal })); - assert.deepEqual(mutations(requests), []); assert.equal(records.batch_cleanup_evidence.verified, false); -}); - -test('CLI still reports failed queued batch even when owned-scope fallback proves cleanup complete', async t => { - const directory = mkdtempSync(join(tmpdir(), 'qoder-batch-cli-')); - t.after(() => rmSync(directory, { recursive: true, force: true })); - const envFile = join(directory,'config.env'), reportFile = join(directory,'report.json'); - writeFileSync(envFile, 'QODER_FORWARD_PAT=test-token\n'); - const savedFetch = globalThis.fetch, savedLog = console.log; - const keys = ['QODER_FORWARD_PAT','QODER_FORWARD_BASE_URL','QODER_FORWARD_MODEL','QODER_PAT']; - const savedEnv = Object.fromEntries(keys.map(k => [k,process.env[k]])); - process.env.QODER_FORWARD_PAT = 'test-token'; - delete process.env.QODER_FORWARD_BASE_URL; delete process.env.QODER_FORWARD_MODEL; delete process.env.QODER_PAT; - const requests = []; let canceled = false; - globalThis.fetch = async (input, init) => { - const req = new Request(input,init), url = new URL(req.url), path = url.pathname.replace('/api/v1/forward',''); - requests.push(`${req.method} ${path}`); - switch (`${req.method} ${path}`) { - case 'GET /models': return response({ data: [{ id: 'auto', is_enabled: true }] }); - case 'POST /environments': return response({ id: 'env-own' }); - case 'POST /identities': return response({ id: 'identity-own' }); - case 'POST /templates': return response({ id: 'template-own' }); - case 'POST /files': return response({ id: 'file-own' }); - case 'POST /batches': return response(batch({ status: 'queued' })); - case 'GET /batches/batch-own': return response(batch({ status: canceled ? 'cancelled' : 'queued' })); - case 'POST /batches/batch-own/cancel': canceled = true; return response(batch()); - case 'GET /sessions': - assert.equal(url.searchParams.get('identity_ids'),'identity-own'); assert.equal(url.searchParams.get('template_id'),'template-own'); assert.equal(url.searchParams.get('source_type'),'batch'); - return response({ data: [], has_more: false }); - case 'POST /identities/identity-own/clear': return response({ status: 'completed' }); - case 'DELETE /identities/identity-own': - case 'DELETE /files/file-own': - case 'POST /templates/template-own/archive': - case 'POST /environments/env-own/archive': return response({}); - default: assert.fail(`unexpected offline request ${req.method} ${path}`); - } - }; - console.log = () => {}; - try { - const code = await main(['-mode','forward','-scenario','batch','-region','international','-model','auto','-env',envFile,'-report',reportFile,'-timeout','100ms','-cleanup-timeout','1s']); - const report = JSON.parse(readFileSync(reportFile,'utf8')); - assert.equal(code, 1); assert.equal(report.scenarios[0].status, 'failed'); - assert.equal(report.scenarios[0].records.batch_cleanup_evidence.verified, true); - assert.equal(report.scenarios[0].records.cleanup.failed, 0); - assert.equal(report.summary.passed, 0); assert.equal(report.summary.failed, 1); - assert.ok(requests.includes('POST /batches/batch-own/cancel')); - assert.ok(requests.includes('DELETE /identities/identity-own')); - } finally { - globalThis.fetch = savedFetch; console.log = savedLog; - for (const key of keys) { if (savedEnv[key] === undefined) delete process.env[key]; else process.env[key] = savedEnv[key]; } - } -}); - - -test('session cleanup 404 retains partial evidence and cannot mark the entire batch already gone', async () => { - const { suite, records, requests } = makeSuite(() => response({ data: [ownSession('session-one'),ownSession('session-two')], has_more: false }), { - sessionResponse: (req,path) => path === '/sessions/session-two' ? response({ error: { message: 'session disappeared during cleanup' } },404) : undefined, - }); - suite.cleanup('batch batch-own', options => finish(suite, options)); - await assert.rejects(() => suite.close(), AggregateError); - assert.equal(records.cleanup.failed,1); - assert.equal(records.batch_cleanup_evidence.enumerated,true); - assert.equal(records.batch_cleanup_evidence.verified,false); - assert.deepEqual(records.batch_cleanup_evidence.sessions.map(s => [s.id,s.cleaned]), [['session-one',true],['session-two',false]]); - assert.deepEqual(mutations(requests).map(r => r.path), ['/sessions/session-one/archive']); -}); diff --git a/tests/examples-cli.test.mjs b/tests/examples-cli.test.mjs deleted file mode 100644 index fb96748..0000000 --- a/tests/examples-cli.test.mjs +++ /dev/null @@ -1,119 +0,0 @@ -// Covers the example CLI's config parsing and output redaction without network access. -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { parseArguments, loadConfiguration, createSanitizer } from '../examples/run.mjs'; -import * as forwardModule from '../examples/forward-scenarios.mjs'; -import * as managedModule from '../examples/managed-scenarios.mjs'; -const { forwardExamples } = forwardModule; -const { managedExamples } = managedModule; - -function configFile(t, content) { - const directory = mkdtempSync(join(tmpdir(), 'qoder-example-config-')); - t.after(() => rmSync(directory, { recursive: true, force: true })); - const path = join(directory, 'config.env'); writeFileSync(path, content, { mode: 0o600 }); - return { path, directory }; -} - -test('example CLI defaults to models, CN and five-minute scenario with independent cleanup deadline', () => { - const args = parseArguments([]); - assert.equal(args.scenario, 'models'); assert.equal(args.region, 'cn'); - assert.equal(args.timeout, 300_000); assert.equal(args.cleanupTimeout, 90_000); - assert.equal(args.mode, 'both'); assert.equal(args.output, 'text'); -}); -for (const [flag, value] of [['mode','invalid'], ['region','invalid'], ['output','xml'], ['timeout','0'], ['timeout','-1s'], ['timeout','31m'], ['cleanup-timeout','0ms']]) { - test(`example CLI rejects invalid ${flag}=${value}`, () => assert.throws(() => parseArguments([`-${flag}`, value]))); -} -test('example CLI accepts single-dash and long flags with duration units', () => { - const args = parseArguments(['--mode=managed', '-scenario','all', '-region','international', '--timeout=5m', '--cleanup-timeout=90s', '-model','auto']); - assert.equal(args.mode, 'managed'); assert.equal(args.scenario, 'all'); assert.equal(args.region, 'international'); - assert.equal(args.timeout, 300_000); assert.equal(args.cleanupTimeout, 90_000); assert.equal(args.model, 'auto'); -}); -test('example CLI rejects unknown flags and missing values', () => { - assert.throws(() => parseArguments(['--bogus','1']), /Unknown option/); - assert.throws(() => parseArguments(['--model']), /Missing value/); -}); - -test('mode PAT isolation and environment precedence', t => { - const file = configFile(t, 'QODER_MANAGED_BASE_URL=https://api.qoder.com/api/v1/cloud\nQODER_MANAGED_PAT=managed-value\nQODER_FORWARD_PAT=forward-value\nQODER_MANAGED_MODEL=file-model\nQODER_PAT=shared-value\n'); - const configs = loadConfiguration(parseArguments(['-env',file.path]), { QODER_MANAGED_PAT: 'managed-override', QODER_MANAGED_MODEL: 'env-model' }); - assert.deepEqual(configs.map(c => [c.mode,c.pat,c.baseURL,c.model]), [ - ['forward','forward-value','https://api.qoder.com.cn/api/v1/forward',undefined], - ['managed','managed-override','https://api.qoder.com.cn/api/v1/cloud','env-model'], - ]); - assert.ok(configs.every(c => c.scenario === 'models')); -}); -test('mode-specific PAT can fall back to shared PAT without borrowing the other mode credential', t => { - const file = configFile(t, 'QODER_MANAGED_PAT=managed-only\nQODER_PAT=shared\n'); - const args = parseArguments(['-env',file.path,'-region','international']); - const configs = loadConfiguration(args, {}); - assert.deepEqual(configs.map(c => [c.mode,c.pat,c.baseURL]), [ - ['forward','shared','https://api.qoder.com/api/v1/forward'], ['managed','managed-only','https://api.qoder.com/api/v1/cloud'], - ]); - writeFileSync(file.path, 'QODER_MANAGED_PAT=managed-only\n'); - assert.throws(() => loadConfiguration(args, {}), /QODER_FORWARD_PAT or QODER_PAT is required/); -}); -test('explicit model flag overrides per-mode model in environment and file', t => { - const file = configFile(t, 'QODER_PAT=test\nQODER_MANAGED_MODEL=file-model\n'); - const [config] = loadConfiguration(parseArguments(['-env',file.path,'-mode','managed','-model','flag-model']), { QODER_MANAGED_MODEL: 'env-model' }); - assert.equal(config.model, 'flag-model'); -}); - -test('config is data: quoted spaces and shell expressions stay literal', t => { - const file = configFile(t, 'QODER_PAT=test\n'); - const marker = join(file.directory, 'must-not-exist'); - writeFileSync(file.path, `export QODER_PAT='with # space'\nQODER_FORWARD_MODEL="$(touch ${marker})"\nQODER_MANAGED_MODEL=unquoted # comment\n`); - const configs = loadConfiguration(parseArguments(['-env',file.path]), {}); - assert.equal(configs[0].pat, 'with # space'); - assert.equal(configs[0].model, `$(touch ${marker})`); - assert.equal(configs[1].model, 'unquoted'); assert.equal(existsSync(marker), false); -}); -test('env parsing rejects unterminated quoted assignments', t => { - const file = configFile(t, "QODER_PAT='unterminated\n"); - assert.throws(() => loadConfiguration(parseArguments(['-env',file.path]), {}), /quote|invalid|unterminated/i); -}); -for (const url of ['http://api.qoder.com/api/v1/forward', 'https://elsewhere.test/api/v1/forward', 'https://user:pass@api.qoder.com/api/v1/forward', 'https://api.qoder.com/api/v1/forward?signature=private', 'https://api.qoder.com/api/v1/forward#fragment']) { - test(`config rejects unsafe API URL ${url}`, t => { - const file = configFile(t, `QODER_PAT=test\nQODER_FORWARD_BASE_URL="${url}"\n`); - assert.throws(() => loadConfiguration(parseArguments(['-env',file.path,'-mode','forward']), {})); - }); -} - -test('explain output redacts all configured PATs, bearer values and signed object-store queries', () => { - const safe = createSanitizer(['forward-private-pat','managed-private-pat']); - const value = safe.text('forward-private-pat managed-private-pat Bearer other-secret https://storage.test/key?signature=private&expires=100 request-id'); - for (const secret of ['forward-private-pat','managed-private-pat','other-secret','signature=','expires=']) assert.equal(value.includes(secret), false); - assert.ok(value.includes('request-id')); -}); -test('safe structured report redacts credentials at any nesting depth', () => { - const safe = createSanitizer(['private-pat']); - const result = safe.data({ accessToken: 'private-pat', nested: { authorization: 'Bearer other', password: 'p', secret: 's', text: 'private-pat' }, children: [{ url: 'https://storage.test/file?signature=private' }] }); - assert.equal(result.accessToken, '[REDACTED]'); - assert.deepEqual(result.nested, { authorization: '[REDACTED]', password: '[REDACTED]', secret: '[REDACTED]', text: '[REDACTED]' }); - assert.equal(JSON.stringify(result).includes('signature='), false); -}); -test('reply redaction runs before truncation and prevents terminal control output', () => { - const result = createSanitizer(['private-test-token']).text('\x1b[2Jhttps://storage.test/file?signature=private\n'+'x'.repeat(3980)+'private-test-token'); - for (const forbidden of ['\x1b','signature=','private-test']) assert.equal(result.includes(forbidden), false); -}); - -test('-scenario all maps exactly the six Forward and six Managed example scenarios', () => { - assert.equal(parseArguments(['-scenario','all']).scenario, 'all'); - assert.equal(typeof forwardModule.createForwardExampleSuite, 'function', 'Forward factory must be exported for dynamic CLI loading'); - assert.equal(typeof managedModule.createManagedExampleSuite, 'function', 'Managed factory must be exported for dynamic CLI loading'); - assert.deepEqual(forwardExamples.map(s => s.name), ['models','session','resources','memory','schedule','batch']); - assert.deepEqual(managedExamples.map(s => s.name), ['models','session','resources','memory','deployment','dream']); - const ids = [...forwardExamples.map(s => `forward/${s.name}`), ...managedExamples.map(s => `managed/${s.name}`)]; - assert.equal(ids.length, 12); assert.equal(new Set(ids).size, 12); - for (const scenario of [...forwardExamples,...managedExamples]) { assert.equal(typeof scenario.run, 'function'); assert.ok(scenario.description); } -}); - - -test('environment loader accepts quoted multiline data and preserves quoted comments', t => { - const file = configFile(t, 'QODER_PAT=test\nQODER_FORWARD_MODEL="line one\nline two # literal" # ignored comment\nQODER_MANAGED_MODEL=auto # ignored comment\n'); - const configs = loadConfiguration(parseArguments(['-env',file.path]), {}); - assert.equal(configs[0].model, 'line one\nline two # literal'); - assert.equal(configs[1].model, 'auto'); -}); diff --git a/tests/examples.test.mjs b/tests/examples.test.mjs deleted file mode 100644 index c49a59c..0000000 --- a/tests/examples.test.mjs +++ /dev/null @@ -1,97 +0,0 @@ -// Example memory and turn assertions, independent of live model behavior. -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { ProjectMemory, PROJECT_MEMORY_PATH } from '../examples/memory-proof.mjs'; -import { TurnResult } from '../examples/execution.mjs'; - -const memory = () => new ProjectMemory({ project: '青禾订单', releaseTime: '21:47', contact: '林岚', rollbackVersion: 'v2.315.806' }); -const answer = '北京时间 21:47 开始发布,异常时联系林岚,需要回滚时使用 v2.315.806。'; -const agentMessage = (text = answer, id = 'agent-final') => ({ id, type: 'agent.message', content: [{ type: 'text', text }] }); -const idle = { id: 'idle-final', type: 'session.status_idle', stop_reason: { type: 'end_turn' } }; -const observe = events => { const result = new TurnResult(); for (const event of events) result.observe(event); return result; }; - -test('project memory withholds facts from the question: it identifies the project without an answer or file hint', () => { - const m = memory(); - assert.equal(m.prompt(), '请根据你记得的项目约定,为「青禾订单」拟一份简短的上线安排,涵盖开始时间、异常联系和回滚处理。只需给出计划,不要执行发布;如果缺少信息,请明确说明。'); - assert.ok(m.prompt().includes(m.project)); - for (const value of [m.releaseTime, m.contact, m.rollbackVersion]) { - assert.ok(m.content().includes(value), `memory must contain ${value}`); - assert.equal(m.prompt().includes(value), false, `question must not leak ${value}`); - assert.equal(m.index().includes(value), false, `index must not leak ${value}`); - } - assert.equal(m.prompt().includes('.md'), false); - assert.equal(m.prompt().includes('MEMORY'), false); -}); - -test('MEMORY.md index contains only a pointer to the actual project entry', () => { - const m = memory(); - assert.equal(PROJECT_MEMORY_PATH, 'projects/release-conventions.md'); - assert.equal(m.index(), '- [青禾订单 发布约定](projects/release-conventions.md) — 项目的发布窗口、异常联系人与回滚约定。\n'); - assert.deepEqual([...m.index().matchAll(/\]\(([^)]+)\)/g)].map(match => match[1]), [PROJECT_MEMORY_PATH]); - assert.match(m.content(), /^---\nname: release-conventions\ndescription: 青禾订单 的项目发布约定\nmetadata:\n type: project\n---\n/); -}); - -test('randomized project memory varies facts on each run without putting them into question or index', () => { - const projects = new Set(), versions = new Set(); - for (let i = 0; i < 32; i++) { - const m = new ProjectMemory(); projects.add(m.project); versions.add(m.rollbackVersion); - assert.match(m.project, /^青禾订单-[0-9a-f]{6}$/); - assert.match(m.releaseTime, /^(20|21|22|23):[0-5][0-9]$/); - assert.ok(['林岚','陈朔','叶澄','苏棠'].includes(m.contact)); - assert.match(m.rollbackVersion, /^v2\.[1-9][0-9]{2}\.[1-9][0-9]{2}$/); - for (const fact of [m.releaseTime, m.contact, m.rollbackVersion]) { - assert.ok(m.content().includes(fact)); assert.equal(m.prompt().includes(fact), false); assert.equal(m.index().includes(fact), false); - } - } - // This checks generation is not a fixed stock answer, without requiring every draw to differ. - assert.ok(projects.size > 1); assert.ok(versions.size > 1); -}); - -test('project memory requires all facts in a completed answer: recall succeeds without a tool event', () => { - const result = observe([agentMessage(), idle]); - assert.equal(result.toolUsed, false); - assert.equal(memory().verify(result), result); - assert.equal(result.lastID, 'idle-final'); -}); - -for (const [label, value] of [['发布开始时间','21:47'], ['异常联系人','林岚'], ['回滚版本','v2.315.806']]) { - test(`memory proof reports the missing fact: ${label}`, () => { - const result = observe([agentMessage(answer.replace(value, '未知')), idle]); - assert.throws(() => memory().verify(result), error => error.message.includes(label) && error.message.includes('last_event_id=idle-final')); - }); -} - -test('memory proof reports all missing facts together rather than accepting a plausible plan', () => { - const result = observe([agentMessage('建议下午两点发布,通知相关人员,必要时回滚。'), idle]); - assert.throws(() => memory().verify(result), /发布开始时间、异常联系人、回滚版本/); -}); - -for (const [name, events] of [ - ['assistant answer without terminal idle', [agentMessage()]], - ['idle before assistant answer', [idle, agentMessage()]], - ['user echo', [{ type: 'user.message', content: [{ type: 'text', text: answer }] }, idle]], - ['tool result', [{ type: 'agent.tool_result', content: [{ type: 'text', text: answer }] }, idle]], - ['thinking', [{ type: 'agent.thinking', content: [{ type: 'text', text: answer }] }, idle]], - ['earlier answer followed by a different final answer', [agentMessage(answer, 'earlier'), agentMessage('缺少信息'), idle]], - ['tool proof followed by a different assistant answer', [{ type: 'agent.tool_result', content: [{ type: 'text', text: answer }] }, agentMessage('我不知道'), idle]], -]) test(`memory proof cannot use ${name}`, () => assert.throws(() => memory().verify(observe(events)))); - -for (const failed of [ - { type: 'session.error' }, { type: 'session.status_terminated' }, - { type: 'session.status_idle', stop_reason: 'max_tokens' }, - { type: 'session.status_idle', stop_reason: { type: 'max_tokens' } }, -]) test(`memory proof rejects execution failure ${JSON.stringify(failed)}`, () => { - assert.throws(() => observe([agentMessage(), failed]), /execution failed|without completing/); -}); - -test('memory verification emits a check step without requiring a particular CLI renderer', () => { - const messages = []; - memory().verify(observe([agentMessage(), idle]), { step: text => messages.push(['step', text]), check: text => messages.push(['checked', text]) }); - assert.deepEqual(messages, [ - ['step', '检查上线安排是否用到了预先写入的记忆'], - ['checked', '发布开始时间:21:47'], - ['checked', '异常联系人:林岚'], - ['checked', '回滚版本:v2.315.806'], - ['checked', '全新会话的回答体现了 3 项记忆;这些值只通过 Memory Store 提供'], - ]); -}); diff --git a/examples/execution.mjs b/tests/execution.mjs similarity index 100% rename from examples/execution.mjs rename to tests/execution.mjs diff --git a/tests/live/forward-support.mjs b/tests/live/forward-support.mjs index f7117c6..9798a5f 100644 --- a/tests/live/forward-support.mjs +++ b/tests/live/forward-support.mjs @@ -7,8 +7,8 @@ export const liveName = (prefix) => `sdk-${prefix}-${Date.now()}-${randomBytes(4 export const marker = () => randomBytes(12).toString('hex'); export const batchTerminal = (status) => ['completed', 'failed', 'cancelled', 'expired'].includes(status); -export { TurnResult as ForwardTurnResult } from '../../examples/execution.mjs'; -import { TurnResult as ForwardTurnResult } from '../../examples/execution.mjs'; +export { TurnResult as ForwardTurnResult } from '../execution.mjs'; +import { TurnResult as ForwardTurnResult } from '../execution.mjs'; export class CleanupFailure extends Error { constructor(message, cause) { super(message, { cause }); this.name = 'CleanupFailure'; } diff --git a/tests/live/managed-support.mjs b/tests/live/managed-support.mjs index fb36070..47321ad 100644 --- a/tests/live/managed-support.mjs +++ b/tests/live/managed-support.mjs @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import { randomBytes } from 'node:crypto'; import { setTimeout as delay } from 'node:timers/promises'; -import { TurnResult } from '../../examples/execution.mjs'; -export { TurnResult } from '../../examples/execution.mjs'; +import { TurnResult } from '../execution.mjs'; +export { TurnResult } from '../execution.mjs'; export const unique = (prefix) => `sdk-${prefix}-${Date.now()}-${randomBytes(4).toString('hex')}`; export const marker = () => `SDK_PROOF_${randomBytes(16).toString('hex')}`; diff --git a/tests/scenarios/execution-assertions.test.mjs b/tests/scenarios/execution-assertions.test.mjs index b9a2dd5..8199228 100644 --- a/tests/scenarios/execution-assertions.test.mjs +++ b/tests/scenarios/execution-assertions.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { TurnResult } from '../../examples/execution.mjs'; +import { TurnResult } from '../execution.mjs'; const message = text => ({ type: 'agent.message', content: [{ type: 'text', text }] }); for (const c of [ { name: 'user echo is not a result', events: [{ type: 'user.message', content: [{ type: 'text', text: 'marker' }] }, { type: 'session.status_idle' }], valid: false }, @@ -9,11 +9,12 @@ for (const c of [ { name: 'different answer', events: [message('wrong'), { type: 'session.status_idle', stop_reason: 'end_turn' }], valid: false }, { name: 'earlier answer is not final', events: [message('marker'), message('wrong'), { type: 'session.status_idle', stop_reason: 'end_turn' }], valid: false }, { name: 'completed', events: [message('marker'), { type: 'session.status_idle', stop_reason: { type: 'end_turn' } }], valid: true }, + { name: 'thinking block is not the answer', events: [{ type: 'agent.thinking', content: [{ type: 'text', text: 'marker' }] }, { type: 'session.status_idle', stop_reason: { type: 'end_turn' } }], valid: false }, ]) test(`execution proof: ${c.name}`, () => { const result = new TurnResult(); for (const event of c.events) result.observe(JSON.stringify(event)); if (c.valid) result.verify(['marker']); else assert.throws(() => result.verify(['marker'])); }); -for (const raw of ['{"type":"session.error"}', '{"type":"session.status_terminated"}', '{"type":"session.status_idle","stop_reason":{"type":"max_tokens"}}', '{broken']) test(`execution rejects failure ${raw}`, () => assert.throws(() => new TurnResult().observe(raw))); +for (const raw of ['{"type":"session.error"}', '{"type":"session.status_terminated"}', '{"type":"session.status_idle","stop_reason":{"type":"max_tokens"}}', '{"type":"session.status_idle","stop_reason":"max_tokens"}', '{broken']) test(`execution rejects failure ${raw}`, () => assert.throws(() => new TurnResult().observe(raw))); test('tool execution must be observed when a scenario requires tools', () => { const result = new TurnResult(); result.observe(message('marker')); result.observe({ type: 'session.status_idle' }); assert.throws(() => result.verify(['marker'], true), /no tool execution/); From 890dab3af22ad6c771a34eb86e1a905b184bcd53 Mon Sep 17 00:00:00 2001 From: moonyue-w <300878504+moonyue-w@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:13:37 +0800 Subject: [PATCH 2/3] docs(examples): restore full topic set to match Go SDK parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 18 个瘦身纯演示(forward 9 + managed 9)+ examples 侧共享 helper(examples/lib),镜像 Go 示例集;不 import tests、import-guarded main。取代之前 gut 的 2 个精简脚本。 Task: 1789906942 --- examples/forward-scenarios.mjs | 75 --------- examples/forward/batch.mjs | 77 ++++++++++ examples/forward/conversation.mjs | 33 ++++ examples/forward/identity-config.mjs | 36 +++++ examples/forward/memory.mjs | 56 +++++++ examples/forward/models.mjs | 18 +++ examples/forward/resources.mjs | 50 ++++++ examples/forward/schedule.mjs | 47 ++++++ examples/forward/session.mjs | 17 ++ examples/forward/streaming-deltas.mjs | 50 ++++++ examples/lib/forward.mjs | 213 ++++++++++++++++++++++++++ examples/lib/managed.mjs | 195 +++++++++++++++++++++++ examples/managed-scenarios.mjs | 69 --------- examples/managed/conversation.mjs | 32 ++++ examples/managed/custom-tools.mjs | 96 ++++++++++++ examples/managed/deployment.mjs | 34 ++++ examples/managed/dream.mjs | 52 +++++++ examples/managed/memory.mjs | 58 +++++++ examples/managed/models.mjs | 18 +++ examples/managed/resources.mjs | 41 +++++ examples/managed/session.mjs | 16 ++ examples/managed/streaming-deltas.mjs | 49 ++++++ package.json | 2 +- 23 files changed, 1189 insertions(+), 145 deletions(-) delete mode 100644 examples/forward-scenarios.mjs create mode 100644 examples/forward/batch.mjs create mode 100644 examples/forward/conversation.mjs create mode 100644 examples/forward/identity-config.mjs create mode 100644 examples/forward/memory.mjs create mode 100644 examples/forward/models.mjs create mode 100644 examples/forward/resources.mjs create mode 100644 examples/forward/schedule.mjs create mode 100644 examples/forward/session.mjs create mode 100644 examples/forward/streaming-deltas.mjs create mode 100644 examples/lib/forward.mjs create mode 100644 examples/lib/managed.mjs delete mode 100644 examples/managed-scenarios.mjs create mode 100644 examples/managed/conversation.mjs create mode 100644 examples/managed/custom-tools.mjs create mode 100644 examples/managed/deployment.mjs create mode 100644 examples/managed/dream.mjs create mode 100644 examples/managed/memory.mjs create mode 100644 examples/managed/models.mjs create mode 100644 examples/managed/resources.mjs create mode 100644 examples/managed/session.mjs create mode 100644 examples/managed/streaming-deltas.mjs diff --git a/examples/forward-scenarios.mjs b/examples/forward-scenarios.mjs deleted file mode 100644 index 8657e3e..0000000 --- a/examples/forward-scenarios.mjs +++ /dev/null @@ -1,75 +0,0 @@ -// Forward SDK 示例:最小可运行演示,展示会话生命周期与流式回复。 -// 仅作代码示例,不承担测试作用;运行:node examples/forward-scenarios.mjs -import { pathToFileURL } from 'node:url'; -import { ForwardClient } from '../dist/forward/index.js'; - -const name = (kind) => `sdk-example-${kind}-${Math.random().toString(16).slice(2, 10)}`; - -// 从公开 API 构造客户端;凭据来自环境变量。 -function makeClient() { - const pat = process.env.QODER_FORWARD_PAT ?? process.env.QODER_PAT; - if (!pat) throw new Error('设置 QODER_FORWARD_PAT 或 QODER_PAT 后运行本示例'); - return new ForwardClient({ pat, baseURL: process.env.QODER_FORWARD_BASE_URL, maxRetries: 0 }); -} - -// 选择一个已启用的模型。 -async function chooseModel(client) { - const models = await client.models.list(); - const enabled = models.data.filter((m) => m.is_enabled).map((m) => m.id); - if (!enabled.length) throw new Error('账号没有已启用的模型'); - return enabled[0]; -} - -// 演示:创建环境 / 身份 / 模板 / 会话,发一轮消息并流式打印助手回复,最后清理。 -async function sessionDemo(client) { - const model = await chooseModel(client); - console.log(`使用模型:${model}`); - - const environment = await client.environments.create({ name: name('env'), config: { type: 'cloud' } }); - const identity = await client.identities.create({ external_id: name('identity'), name: 'SDK 示例用户' }); - const template = await client.templates.create({ - name: name('template'), model, system: '你是一个 SDK 示例助手。', - environment_id: environment.id, tools: [{ type: 'agent_toolset_20260401' }], - }); - const session = await client.sessions.create({ identity_id: identity.id, template_id: template.id }); - console.log(`已创建会话:${session.id}`); - - const sent = await client.sessions.events.send(session.id, { - events: [{ type: 'user.message', content: [{ type: 'text', text: '请用一句话介绍你能提供什么帮助。' }] }], - }); - const lastEventID = sent.data[0]?.id; - - // 流式接收本轮事件,打印助手文本;见到会话空闲即结束。 - const stream = await client.sessions.events.streamEvents(session.id, { last_event_id: lastEventID }); - try { - for await (const event of stream) { - if (event.type === 'agent.message') { - const text = (event.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join(''); - if (text) console.log(`助手:${text}`); - } else if (event.type === 'session.status_idle') { - break; - } - } - } finally { - stream.controller.abort(); - } - - // 清理本次演示创建的资源。 - await client.templates.archive(template.id, {}); - await client.identities.delete(identity.id); - await client.environments.archive(environment.id); - console.log('已清理示例资源。'); -} - -async function main() { - const client = makeClient(); - await sessionDemo(client); -} - -// 仅在直接运行时执行(被 import 时不触发网络)。 -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main().catch((error) => { - console.error(error); - process.exitCode = 1; - }); -} diff --git a/examples/forward/batch.mjs b/examples/forward/batch.mjs new file mode 100644 index 0000000..fb15de8 --- /dev/null +++ b/examples/forward/batch.mjs @@ -0,0 +1,77 @@ +// Forward · batch:上传一个只含一条任务的 JSONL 输入文件,提交 Batch(跳过闲时窗口), +// 等待完成后读取任务列表与输出文件,并回读生成会话的助手回复。 +// 运行:node examples/forward/batch.mjs(需 QODER_FORWARD_PAT 或 QODER_PAT)。 +import { setTimeout as delay } from 'node:timers/promises'; +import { isMain, marker, name, runForwardExample, toFile } from '../lib/forward.mjs'; + +const terminal = (status) => ['completed', 'failed', 'cancelled', 'expired'].includes(status); + +// 下载并解析 Batch 的 JSONL 输出文件(每行一个任务结果)。 +async function readBatchOutput(client, batchID) { + const link = await client.batches.getOutput(batchID); + const response = await fetch(link.url); + if (!response.ok) throw new Error(`batch output HTTP ${response.status}`); + const text = await response.text(); + return text.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); +} + +async function main() { + await runForwardExample(async (s) => { + const env = await s.environment(); + const identity = await s.identity(); + const template = await s.template({ environment_id: env.id }); + + const echo = marker(); + const customID = name('task'); + const line = JSON.stringify({ + custom_id: customID, + template_id: template.id, + identity_id: identity.id, + body: { input: `Reply with exactly ${echo}` }, + }); + + s.step('上传包含一个任务的 JSONL 输入文件'); + const input = await s.client.files.upload({ file: await toFile(`${line}\n`, 'sdk-example-input.jsonl'), purpose: 'session_resource' }); + s.track(`input file ${input.id}`, () => s.client.files.delete(input.id)); + + s.step('提交 Batch,跳过闲时窗口限制'); + const batch = await s.client.batches.create({ + input_file_id: input.id, + completion_window: '24h', + idempotency_key: name('batch'), + ignore_idle_window: true, + }); + s.track(`batch ${batch.id}`, async () => { + const current = await s.client.batches.retrieve(batch.id); + if (!terminal(current.status)) await s.client.batches.cancel(batch.id, {}); + }); + + s.step('等待 Batch 完成'); + let current = batch; + let previous = ''; + while (!terminal(current.status)) { + if (current.status !== previous) { s.info(`Batch 状态:${current.status}`); previous = current.status; } + await delay(2000); + current = await s.client.batches.retrieve(batch.id); + } + s.info(`Batch 最终状态:${current.status}`); + + s.step('检查 Batch 的任务和输出文件'); + const tasks = await s.client.batches.tasks.list(batch.id, {}); + s.info(`任务数量:${tasks.data.length},首个 custom_id:${tasks.data[0]?.custom_id ?? '(无)'}`); + + if (current.output_file_id) { + const rows = await readBatchOutput(s.client, batch.id); + const row = rows[0]; + if (row) { + s.info(`输出行状态:${row.status},会话:${row.session_id}`); + if (row.session_id) { + s.track(`batch session ${row.session_id}`, () => s.finishSession(row.session_id)); + await s.listReply(row.session_id); + } + } + } + }); +} + +if (isMain(import.meta.url)) main(); diff --git a/examples/forward/conversation.mjs b/examples/forward/conversation.mjs new file mode 100644 index 0000000..08a8bcf --- /dev/null +++ b/examples/forward/conversation.mjs @@ -0,0 +1,33 @@ +// Forward · conversation:复用同一个 Session 进行多轮对话;重建 SDK 客户端后仍能通过 +// Session ID 读取服务端保存的历史,再追问上文约定的信息。 +// 运行:node examples/forward/conversation.mjs(需 QODER_FORWARD_PAT 或 QODER_PAT)。 +import { historyMessage, isMain, makeForwardClient, marker, runForwardExample } from '../lib/forward.mjs'; + +async function main() { + await runForwardExample(async (s) => { + const env = await s.environment(); + const identity = await s.identity(); + const template = await s.template({ environment_id: env.id }); + const session = await s.session({ identity_id: identity.id, template_id: template.id }); + + const code = `project-${marker()}`; + await s.turn(session.id, `这次项目代号是 ${code}。请在本次对话中记住它,不要使用工具或写入记忆库。现在只回复:已记住。`); + + s.info(`Session ID:${session.id}`); + s.step('重新创建 SDK 客户端,通过 Session ID 读取服务端会话'); + // 新客户端没有任何本地聊天历史;服务端仍以 sessionID 保存整段会话。 + s.client = makeForwardClient(); + const current = await s.client.sessions.retrieve(session.id); + s.info(`会话状态:${current.status}`); + + s.step('分页读取已有的用户消息和助手回复'); + for await (const event of s.client.sessions.events.list(session.id, { order: 'asc', limit: 100 })) { + const message = historyMessage(event); + if (message) s.info(`历史 · ${message.role}:${message.text}`); + } + + await s.turn(session.id, '只根据本次会话上文,告诉我刚才约定的项目代号。只回复代号,不要使用工具。'); + }); +} + +if (isMain(import.meta.url)) main(); diff --git a/examples/forward/identity-config.mjs b/examples/forward/identity-config.mjs new file mode 100644 index 0000000..d230210 --- /dev/null +++ b/examples/forward/identity-config.mjs @@ -0,0 +1,36 @@ +// Forward · identity-config:两个 Identity 共用一个 Template,各自覆盖个性化环境变量, +// 读取最终生效配置(默认值继承 + 用户覆盖),再在会话里读回实际值。 +// 运行:node examples/forward/identity-config.mjs(需 QODER_FORWARD_PAT 或 QODER_PAT)。 +import { isMain, marker, runForwardExample } from '../lib/forward.mjs'; + +async function main() { + await runForwardExample(async (s) => { + const env = await s.environment(); + const shared = `shared-${marker()}`; + const baseline = `default-${marker()}`; + const template = await s.template({ + environment_id: env.id, + environment_variables: { SDK_SHARED_VALUE: shared, SDK_PERSONAL_VALUE: baseline }, + }); + + const values = [`alice-${marker()}`, `bob-${marker()}`]; + for (let i = 0; i < values.length; i++) { + const identity = await s.identity(); + + s.step(`为 Identity ${i + 1} 设置个性化环境变量`); + await s.client.identities.configs.upsert(identity.id, template.id, { + identity_config: { environment_variables: { SDK_PERSONAL_VALUE: { op: 'set', value: values[i] } } }, + }); + + s.step('查询最终生效配置,观察默认值继承与用户覆盖'); + const effective = await s.client.identities.configs.getEffective(identity.id, template.id); + const vars = effective.session?.environment_variables ?? {}; + s.info(`Identity ${i + 1} 生效变量:SDK_SHARED_VALUE=${vars.SDK_SHARED_VALUE},SDK_PERSONAL_VALUE=${vars.SDK_PERSONAL_VALUE}`); + + const session = await s.session({ identity_id: identity.id, template_id: template.id }); + await s.turn(session.id, '请使用工具读取 SDK_SHARED_VALUE 和 SDK_PERSONAL_VALUE 两个环境变量,只返回这两个变量的实际值。'); + } + }); +} + +if (isMain(import.meta.url)) main(); diff --git a/examples/forward/memory.mjs b/examples/forward/memory.mjs new file mode 100644 index 0000000..994ca09 --- /dev/null +++ b/examples/forward/memory.mjs @@ -0,0 +1,56 @@ +// Forward · memory:通过 SDK 写入项目发布约定并绑定到 Identity + Template, +// 再创建全新会话——项目信息只存在于记忆库中,不进系统指令,也不进聊天历史。 +// 运行:node examples/forward/memory.mjs(需 QODER_FORWARD_PAT 或 QODER_PAT)。 +import { isMain, marker, name, runForwardExample } from '../lib/forward.mjs'; + +const PROJECT_MEMORY_PATH = 'projects/release-conventions.md'; + +// 构造一份随机化的项目发布约定,避免助手用套话蒙混过关。 +function projectMemory() { + const project = `青禾订单-${marker().slice(0, 6)}`; + const releaseTime = '21:30'; + const contact = '林岚'; + const rollback = `v2.${100 + Math.floor(Math.random() * 900)}.${100 + Math.floor(Math.random() * 900)}`; + return { + project, + content: `---\nname: release-conventions\ndescription: ${project} 的项目发布约定\nmetadata:\n type: project\n---\n\n# ${project} 的发布约定\n\n- 这是一个订单服务项目。\n- 团队约定在北京时间 ${releaseTime} 开始发布。\n- 发布异常时先联系值班负责人${contact}。\n- 如果需要回滚,使用已验证的稳定版本 ${rollback}。\n`, + index: `- [${project} 发布约定](${PROJECT_MEMORY_PATH}) — 项目的发布窗口、异常联系人与回滚约定。\n`, + prompt: `请根据你记得的项目约定,为「${project}」拟一份简短的上线安排,涵盖开始时间、异常联系和回滚处理。只需给出计划,不要执行发布。`, + }; +} + +async function main() { + await runForwardExample(async (s) => { + const env = await s.environment(); + const identity = await s.identity(); + const memory = projectMemory(); + + s.step('创建记忆库(Memory Store)'); + const store = await s.client.memoryStores.create({ name: name('memory'), idempotency_key: name('memory-key') }); + s.track(`memory store ${store.id}`, () => s.client.memoryStores.delete(store.id)); + + s.step('通过 SDK 写入项目背景和发布约定'); + const entry = await s.client.memoryStores.memories.create(store.id, { path: PROJECT_MEMORY_PATH, content: memory.content }); + s.step('写入 MEMORY.md 索引,供新会话发现项目记忆'); + await s.client.memoryStores.memories.create(store.id, { path: 'MEMORY.md', content: memory.index }); + + s.step('通过 SDK 重新读取,确认记忆正文已保存'); + const saved = await s.client.memoryStores.memories.retrieve(store.id, entry.id); + s.info(`已保存记忆:${saved.path}`); + + const template = await s.template({ environment_id: env.id }); + s.step('将记忆库绑定到 Identity 和 Template'); + await s.client.identities.memoryStores.mount(identity.id, template.id, { memory_store_id: store.id }); + s.track(`memory mount ${store.id}`, () => s.client.identities.memoryStores.detach(identity.id, template.id, store.id)); + + s.step('查询绑定,确认记忆库已关联'); + const mounts = await s.client.identities.memoryStores.list(identity.id, template.id); + s.info(`已绑定记忆库数量:${mounts.data?.length ?? 0}`); + + const session = await s.session({ identity_id: identity.id, template_id: template.id }); + s.info('下面创建的全新会话,只能通过记忆库获得项目约定。'); + await s.turn(session.id, memory.prompt); + }); +} + +if (isMain(import.meta.url)) main(); diff --git a/examples/forward/models.mjs b/examples/forward/models.mjs new file mode 100644 index 0000000..49ea784 --- /dev/null +++ b/examples/forward/models.mjs @@ -0,0 +1,18 @@ +// Forward · models:查询当前账号可用的模型。 +// 运行:node examples/forward/models.mjs(需 QODER_FORWARD_PAT 或 QODER_PAT)。 +import { isMain, makeForwardClient } from '../lib/forward.mjs'; + +async function main() { + const client = makeForwardClient(); + const models = await client.models.list(); + const enabled = models.data.filter((m) => m.is_enabled).map((m) => m.id); + console.log(`共 ${models.data.length} 个模型,已启用 ${enabled.length} 个:`); + console.log(` ${enabled.join(', ')}`); +} + +if (isMain(import.meta.url)) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/examples/forward/resources.mjs b/examples/forward/resources.mjs new file mode 100644 index 0000000..44f3f74 --- /dev/null +++ b/examples/forward/resources.mjs @@ -0,0 +1,50 @@ +// Forward · resources:演示文件挂载、Identity 环境变量覆盖和自定义 Skill 的读取。 +// 校验值只存在于文件 / 环境变量 / Skill 中,不出现在用户消息里。 +// 运行:node examples/forward/resources.mjs(需 QODER_FORWARD_PAT 或 QODER_PAT)。 +import { isMain, marker, name, runForwardExample, toFile } from '../lib/forward.mjs'; + +async function main() { + await runForwardExample(async (s) => { + const env = await s.environment(); + const identity = await s.identity(); + const fileToken = marker(); + const envToken = marker(); + const skillToken = marker(); + + s.step('上传示例文件,供会话中的工具读取'); + const file = await s.client.files.upload({ file: await toFile(fileToken, 'sdk-example.txt'), purpose: 'session_resource' }); + s.track(`file ${file.id}`, () => s.client.files.delete(file.id)); + + const skillName = name('skill'); + s.step('上传自定义 Skill,其中包含一个随机校验值'); + const skill = await s.client.skills.create({ + files: [await toFile( + `---\nname: ${skillName}\ndescription: Provides a sample verification code for the SDK example.\n---\nThe example verification value EXAMPLE_SKILL_CODE is: ${skillToken}\n`, + `${skillName}/SKILL.md`, + )], + }); + s.track(`skill ${skill.id}`, () => s.client.skills.delete(skill.id)); + + const template = await s.template({ + environment_id: env.id, + skills: [{ type: 'custom', skill_id: skill.id, version: skill.latest_version }], + environment_variables: { SDK_EXAMPLE_VALUE: 'template-default' }, + }); + + s.step('设置 Identity 的环境变量,覆盖模板中的默认值'); + await s.client.identities.configs.upsert(identity.id, template.id, { + identity_config: { environment_variables: { SDK_EXAMPLE_VALUE: { op: 'set', value: envToken } } }, + }); + + const session = await s.session({ + identity_id: identity.id, + template_id: template.id, + resources: [{ type: 'file', file_id: file.id, mount_path: '/data/workspace/sdk-example.txt' }], + }); + + await s.turn(session.id, '请使用工具读取 /data/workspace/sdk-example.txt 的内容和 SDK_EXAMPLE_VALUE 环境变量,分别返回这两个示例值。'); + await s.turn(session.id, `请使用技能 ${skillName},读取并返回其中的示例校验码 EXAMPLE_SKILL_CODE。`); + }); +} + +if (isMain(import.meta.url)) main(); diff --git a/examples/forward/schedule.mjs b/examples/forward/schedule.mjs new file mode 100644 index 0000000..c0580ef --- /dev/null +++ b/examples/forward/schedule.mjs @@ -0,0 +1,47 @@ +// Forward · schedule:创建只手动触发的 Schedule,触发一次运行,等待完成后回读会话回复。 +// 运行:node examples/forward/schedule.mjs(需 QODER_FORWARD_PAT 或 QODER_PAT)。 +import { setTimeout as delay } from 'node:timers/promises'; +import { isMain, marker, name, runForwardExample } from '../lib/forward.mjs'; + +async function main() { + await runForwardExample(async (s) => { + const env = await s.environment(); + const identity = await s.identity(); + const template = await s.template({ environment_id: env.id }); + const echo = marker(); + + s.step('创建只手动触发的 Schedule'); + const schedule = await s.client.schedules.create({ + identity_id: identity.id, + template_id: template.id, + environment_id: env.id, + name: name('schedule'), + initial_events: [{ type: 'user.message', content: `Reply with exactly ${echo}` }], + trigger_policy: { type: 'manual' }, + execution: { max_attempts: 1, max_concurrent_runs: 1 }, + }); + s.track(`schedule ${schedule.id}`, () => s.client.schedules.archive(schedule.id, {})); + + s.step('手动触发一次 Schedule 运行'); + const created = await s.client.schedules.run(schedule.id, { idempotency_key: name('run') }); + const runID = created.id; + s.track(`schedule run ${runID}`, async () => { + const run = await s.client.scheduleRuns.retrieve(runID, { identity_id: identity.id }); + if (run.session_id) await s.finishSession(run.session_id); + }); + + s.step('轮询 Schedule Run 直到完成'); + let run; + for (;;) { + run = await s.client.scheduleRuns.retrieve(runID, { identity_id: identity.id }); + s.info(`运行状态:${run.status}`); + if (run.status === 'completed') break; + if (['failed', 'skipped'].includes(run.status)) throw new Error(`schedule run=${runID} status=${run.status}`); + await delay(2000); + } + + if (run.session_id) await s.listReply(run.session_id); + }); +} + +if (isMain(import.meta.url)) main(); diff --git a/examples/forward/session.mjs b/examples/forward/session.mjs new file mode 100644 index 0000000..ebef742 --- /dev/null +++ b/examples/forward/session.mjs @@ -0,0 +1,17 @@ +// Forward · session:演示 Identity -> Template -> Session 生命周期,发送一条消息并通过 SSE 接收助手回复。 +// 运行:node examples/forward/session.mjs(需 QODER_FORWARD_PAT 或 QODER_PAT)。 +import { isMain, marker, runForwardExample } from '../lib/forward.mjs'; + +async function main() { + await runForwardExample(async (s) => { + const env = await s.environment(); + const identity = await s.identity(); + const template = await s.template({ environment_id: env.id }); + const session = await s.session({ identity_id: identity.id, template_id: template.id }); + + const echo = marker(); + await s.turn(session.id, `请用一句话介绍你能提供什么帮助,并在回复末尾原样附上:${echo}`); + }); +} + +if (isMain(import.meta.url)) main(); diff --git a/examples/forward/streaming-deltas.mjs b/examples/forward/streaming-deltas.mjs new file mode 100644 index 0000000..a271d81 --- /dev/null +++ b/examples/forward/streaming-deltas.mjs @@ -0,0 +1,50 @@ +// Forward · streaming-deltas:先订阅 SSE 的文本增量(agent.message deltas),随消息生成 +// 逐步刷新预览,最后用完整的 agent.message 替换预览。预览事件不写入历史,故必须先订阅再发消息。 +// 运行:node examples/forward/streaming-deltas.mjs(需 QODER_FORWARD_PAT 或 QODER_PAT)。 +import { isMain, marker, printReply, runForwardExample } from '../lib/forward.mjs'; + +async function main() { + await runForwardExample(async (s) => { + const env = await s.environment(); + const identity = await s.identity(); + const template = await s.template({ environment_id: env.id }); + const session = await s.session({ identity_id: identity.id, template_id: template.id }); + + s.step('先订阅 SSE,开启 agent.message 文本增量'); + const stream = await s.openStream(session.id, { deltas: ['agent.message'] }); + + const echo = marker(); + const prompt = `请分三句话解释为什么多轮对话要复用 Session ID,最后原样附上:${echo}`; + s.step('发送消息'); + s.info(`你:${prompt}`); + await s.send(session.id, prompt); + + s.step('接收增量并在收到完整消息后替换预览'); + const previews = new Map(); // event_id -> Map(blockIndex -> text) + let deltas = 0; + try { + for await (const event of stream) { + if (event.type === 'event_delta' && event.delta?.type === 'content_delta' && event.delta?.content?.type === 'text') { + const id = event.event_id; + const blocks = previews.get(id) ?? new Map(); + previews.set(id, blocks); + // 同一消息可能拆成多个片段共享 event_id,按块索引累加。 + blocks.set(event.delta.index, (blocks.get(event.delta.index) ?? '') + (event.delta.content.text ?? '')); + deltas++; + const text = [...blocks.keys()].sort((a, b) => a - b).map((i) => blocks.get(i)).join('\n'); + console.log(` 预览(${id}):${text}`); + } else if (event.type === 'agent.message') { + s.info(`消息 ${event.id} 收到完整内容,替换该消息的预览。`); + printReply((event.content ?? []).filter((b) => b.type === 'text').map((b) => b.text ?? '').join('')); + } else if (event.type === 'session.status_idle') { + break; + } + } + } finally { + stream.controller.abort(); + } + s.info(deltas ? `共收到 ${deltas} 个文本增量;增量是尽力提供的预览。` : '本次未收到增量,使用最终完整消息。'); + }); +} + +if (isMain(import.meta.url)) main(); diff --git a/examples/lib/forward.mjs b/examples/lib/forward.mjs new file mode 100644 index 0000000..42bb225 --- /dev/null +++ b/examples/lib/forward.mjs @@ -0,0 +1,213 @@ +// Forward SDK 示例共享助手(examples 专用)。 +// +// 仅作代码示例:纯演示、无断言、不 import tests/。这里集中放置各主题共用的 +// 步骤——构造客户端、选模型、创建 Environment/Identity/Template/Session、 +// 发一轮消息并流式打印助手回复、逆序清理资源。等价于 Go 仓 examples/internal +// 下的 forwardutil/live,但去掉了断言与校验逻辑。 +import { pathToFileURL } from 'node:url'; +import { randomBytes } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; +import { ForwardClient, toFile } from '../../dist/forward/index.js'; + +export { toFile }; + +// 生成唯一名称与随机校验值,避免多次运行互相干扰。 +export const name = (kind) => `sdk-example-${kind}-${randomBytes(4).toString('hex')}`; +export const marker = () => randomBytes(12).toString('hex'); + +// 仅当文件被 `node ` 直接运行时返回 true;被 import 时为 false(不触发网络)。 +export const isMain = (moduleURL) => Boolean(process.argv[1]) && moduleURL === pathToFileURL(process.argv[1]).href; + +// 从公开 API 构造 Forward 客户端;凭据与地址来自环境变量。 +export function makeForwardClient() { + const pat = process.env.QODER_FORWARD_PAT ?? process.env.QODER_PAT; + if (!pat) throw new Error('设置 QODER_FORWARD_PAT 或 QODER_PAT 后运行本示例'); + return new ForwardClient({ pat, baseURL: process.env.QODER_FORWARD_BASE_URL, maxRetries: 0 }); +} + +// 打印助手回复(多行缩进)。 +export function printReply(text) { + const trimmed = (text ?? '').trim(); + if (!trimmed) return; + console.log(' 助手:'); + for (const line of trimmed.split('\n')) console.log(` ${line}`); +} + +// 从一条会话事件里抽取 user/assistant 文本,用于打印历史;其它事件返回 null。 +export function historyMessage(event) { + const role = event.type === 'user.message' ? '用户' : event.type === 'agent.message' ? '助手' : ''; + if (!role) return null; + const content = event.content; + const text = typeof content === 'string' + ? content + : Array.isArray(content) ? content.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('\n') : ''; + return text ? { role, text } : null; +} + +// 提取 agent.message 事件里的纯文本。 +function messageText(event) { + return (event.content ?? []).filter((b) => b.type === 'text').map((b) => b.text ?? '').join(''); +} + +// Forward 示例运行器:持有客户端、选中的模型,以及待清理资源(逆序清理)。 +export class ForwardExample { + constructor() { + this.client = makeForwardClient(); + this.model = ''; + this.cleanups = []; + } + + step(message) { console.log(`\n[步骤] ${message}`); } + info(message) { console.log(` ${message}`); } + + // 记录一个待清理资源。清理逆序执行,失败只打印、不抛出(纯演示不做校验)。 + track(label, run) { this.cleanups.push({ label, run }); } + + async cleanup() { + for (const { label, run } of this.cleanups.reverse()) { + try { await run(); console.log(` 已清理:${label}`); } + catch (error) { console.log(` 清理失败(忽略):${label} — ${error?.message ?? error}`); } + } + this.cleanups = []; + } + + // 查询账号可用模型并选一个(优先 lite/plus,其次第一个)。 + async selectModel() { + if (this.model) return this.model; + this.step('查询账号可用的模型'); + const models = await this.client.models.list(); + const enabled = models.data.filter((m) => m.is_enabled).map((m) => m.id); + if (!enabled.length) throw new Error('账号没有已启用的模型'); + this.model = enabled.find((id) => ['qoder-lite', 'lite', 'qoder-plus', 'plus'].includes(id)) ?? enabled[0]; + this.info(`已启用 ${enabled.length} 个模型,本次使用:${this.model}`); + return this.model; + } + + async environment() { + this.step('创建云端执行环境(Environment)'); + const env = await this.client.environments.create({ name: name('env'), config: { type: 'cloud' } }); + this.track(`environment ${env.id}`, () => this.client.environments.archive(env.id)); + return env; + } + + async identity(params = {}) { + this.step('创建用于本次示例的身份(Identity)'); + const identity = await this.client.identities.create({ external_id: name('identity'), metadata: { suite: 'sdk-example' }, ...params }); + this.track(`identity ${identity.id}`, () => this.client.identities.delete(identity.id)); + return identity; + } + + // 创建助手模板,配置模型和工具;extra 可覆盖/追加字段(如 environment_id、skills)。 + async template(extra = {}) { + await this.selectModel(); + this.step('创建助手模板(Template),配置模型和工具'); + const template = await this.client.templates.create({ + name: name('template'), + model: this.model, + system: '你是一个 SDK 示例助手。帮助用户了解会话、文件、技能与记忆的用法,必要时调用工具。仅使用可实际读取的数据回答问题。', + tools: [{ type: 'agent_toolset_20260401' }], + ...extra, + }); + this.track(`template ${template.id}`, () => this.client.templates.archive(template.id, {})); + return template; + } + + async session(params) { + this.step('创建会话(Session),关联助手和执行环境'); + const session = await this.client.sessions.create(params); + this.track(`session ${session.id}`, () => this.finishSession(session.id)); + return session; + } + + // 结束会话:非空闲/终止先取消并等待,再归档。 + async finishSession(id) { + let session = await this.client.sessions.retrieve(id); + if (!['idle', 'terminated'].includes(session.status)) { + await this.client.sessions.cancel(id, {}); + for (;;) { + session = await this.client.sessions.retrieve(id); + if (['idle', 'terminated'].includes(session.status)) break; + await delay(2000); + } + } + await this.client.sessions.archive(id, {}); + } + + // 发送一条用户消息,返回该消息的 event ID(用于定位本轮起点)。 + async send(sessionID, prompt) { + const sent = await this.client.sessions.events.send(sessionID, { + events: [{ type: 'user.message', content: [{ type: 'text', text: prompt }] }], + idempotency_key: name('event'), + }); + return sent.data[0]?.id ?? ''; + } + + // 打开一个 SSE 流。after 为本轮起点 event ID;deltas 订阅文本增量事件类型。 + async openStream(sessionID, { after = '', deltas = [] } = {}) { + const params = { include_tool_calls: true }; + if (after) params.last_event_id = after; + if (deltas.length) params['event_deltas[]'] = deltas; + return this.client.sessions.events.streamEvents(sessionID, params); + } + + // 通过 SSE 接收本轮回复:打印 agent.message 文本与工具调用,见 session.status_idle 即停。 + async streamReply(sessionID, after) { + this.step('通过 SSE 接收助手回复'); + const stream = await this.openStream(sessionID, { after }); + try { + for await (const event of stream) { + if (event.type === 'agent.tool_use' || event.type === 'agent.mcp_tool_use') { + this.info(`→ 调用工具:${event.name ?? '工具执行'}`); + } else if (event.type === 'agent.message') { + printReply(messageText(event)); + } else if (event.type === 'session.status_idle') { + break; + } + } + } finally { + stream.controller.abort(); + } + } + + // 通过轮询事件列表接收本轮回复:用于 Batch/Schedule 等已完成的会话回读。 + async listReply(sessionID, after = '') { + this.step('轮询会话事件,打印助手回复'); + let lastID = after; + let count = 0; + for (;;) { + let idle = false; + for await (const event of this.client.sessions.events.list(sessionID, { + after_id: lastID || undefined, order: 'asc', limit: 100, include_tool_calls: true, + })) { + if (++count > 2000) return; + if (event.id) lastID = event.id; + if (event.type === 'agent.message') printReply(messageText(event)); + else if (event.type === 'session.status_idle') { idle = true; break; } + } + if (idle) break; + await delay(2000); + } + } + + // 发送一条消息并等待本轮回复(默认流式)。 + async turn(sessionID, prompt, { stream = true } = {}) { + this.step('向会话发送消息'); + this.info(`你:${prompt}`); + const after = await this.send(sessionID, prompt); + if (stream) await this.streamReply(sessionID, after); + else await this.listReply(sessionID, after); + } +} + +// 顶层入口封装:运行 demo(example),无论成败都清理资源;失败置退出码。 +export async function runForwardExample(demo) { + const example = new ForwardExample(); + try { + await demo(example); + } catch (error) { + console.error(error); + process.exitCode = 1; + } finally { + await example.cleanup(); + } +} diff --git a/examples/lib/managed.mjs b/examples/lib/managed.mjs new file mode 100644 index 0000000..e178cb7 --- /dev/null +++ b/examples/lib/managed.mjs @@ -0,0 +1,195 @@ +// Managed SDK 示例共享助手(examples 专用)。 +// +// 仅作代码示例:纯演示、无断言、不 import tests/。集中放置各主题共用的步骤—— +// 构造客户端、选模型、创建 Environment/Agent/Session、发一轮消息并流式打印助手 +// 回复、逆序清理资源。等价于 Go 仓 examples/internal 下的 managedutil/live,但 +// 去掉了断言与校验逻辑。 +import { pathToFileURL } from 'node:url'; +import { randomBytes } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; +import { ManagedClient, toFile } from '../../dist/managed/index.js'; + +export { toFile }; + +export const name = (kind) => `sdk-example-${kind}-${randomBytes(4).toString('hex')}`; +export const marker = () => randomBytes(12).toString('hex'); + +// 仅当文件被 `node ` 直接运行时返回 true;被 import 时为 false(不触发网络)。 +export const isMain = (moduleURL) => Boolean(process.argv[1]) && moduleURL === pathToFileURL(process.argv[1]).href; + +// 从公开 API 构造 Managed 客户端;凭据与地址来自环境变量。 +export function makeManagedClient() { + const pat = process.env.QODER_PAT ?? process.env.QODER_MANAGED_PAT; + if (!pat) throw new Error('设置 QODER_PAT 或 QODER_MANAGED_PAT 后运行本示例'); + return new ManagedClient({ pat, baseURL: process.env.QODER_MANAGED_BASE_URL, maxRetries: 0 }); +} + +export function printReply(text) { + const trimmed = (text ?? '').trim(); + if (!trimmed) return; + console.log(' 助手:'); + for (const line of trimmed.split('\n')) console.log(` ${line}`); +} + +export function historyMessage(event) { + const role = event.type === 'user.message' ? '用户' : event.type === 'agent.message' ? '助手' : ''; + if (!role) return null; + const content = event.content; + const text = typeof content === 'string' + ? content + : Array.isArray(content) ? content.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('\n') : ''; + return text ? { role, text } : null; +} + +function messageText(event) { + return (event.content ?? []).filter((b) => b.type === 'text').map((b) => b.text ?? '').join(''); +} + +// Managed 示例运行器:持有客户端、选中的模型,以及待清理资源(逆序清理)。 +export class ManagedExample { + constructor() { + this.client = makeManagedClient(); + this.model = ''; + this.cleanups = []; + } + + step(message) { console.log(`\n[步骤] ${message}`); } + info(message) { console.log(` ${message}`); } + + track(label, run) { this.cleanups.push({ label, run }); } + + async cleanup() { + for (const { label, run } of this.cleanups.reverse()) { + try { await run(); console.log(` 已清理:${label}`); } + catch (error) { console.log(` 清理失败(忽略):${label} — ${error?.message ?? error}`); } + } + this.cleanups = []; + } + + async selectModel() { + if (this.model) return this.model; + this.step('查询账号可用的模型'); + const models = await this.client.models.list({}); + const enabled = models.data.filter((m) => m.is_enabled).map((m) => m.id); + if (!enabled.length) throw new Error('账号没有已启用的模型'); + this.model = enabled.find((id) => ['qoder-lite', 'lite', 'qoder-plus', 'plus'].includes(id)) ?? enabled[0]; + this.info(`已启用 ${enabled.length} 个模型,本次使用:${this.model}`); + return this.model; + } + + async environment() { + this.step('创建云端执行环境(Environment)'); + const env = await this.client.environments.create({ name: name('env'), config: { type: 'cloud' } }); + this.track(`environment ${env.id}`, () => this.client.environments.archive(env.id, {})); + return env; + } + + // 创建助手(Agent),配置模型和工具;extra 可覆盖/追加字段(如 tools、skills)。 + async agent(extra = {}) { + await this.selectModel(); + this.step('创建助手(Agent),配置模型和工具'); + const agent = await this.client.agents.create({ + name: name('agent'), + model: { id: this.model }, + system: '你是一个 SDK 示例助手。帮助用户了解会话、文件、技能与记忆的用法,必要时调用工具。仅使用可实际读取的数据回答问题。', + tools: [{ type: 'agent_toolset_20260401' }], + ...extra, + }); + this.track(`agent ${agent.id}`, () => this.client.agents.archive(agent.id, {})); + return agent; + } + + async session(params) { + this.step('创建会话(Session),关联助手和执行环境'); + const session = await this.client.sessions.create(params); + this.track(`session ${session.id}`, () => this.finishSession(session.id)); + return session; + } + + // 结束会话:非空闲/终止先发送 user.interrupt 并等待,再删除。 + async finishSession(id) { + let session = await this.client.sessions.retrieve(id, {}); + if (!['idle', 'terminated'].includes(session.status)) { + await this.client.sessions.events.send(id, { events: [{ type: 'user.interrupt' }] }); + for (;;) { + session = await this.client.sessions.retrieve(id, {}); + if (['idle', 'terminated'].includes(session.status)) break; + await delay(2000); + } + } + await this.client.sessions.delete(id, {}); + } + + async send(sessionID, prompt) { + const sent = await this.client.sessions.events.send(sessionID, { + events: [{ type: 'user.message', content: [{ type: 'text', text: prompt }] }], + }); + return sent.data[0]?.id ?? ''; + } + + // 打开一个 SSE 流。after 通过 Last-Event-ID 头恢复;deltas 订阅文本增量事件类型。 + async openStream(sessionID, { after = '', deltas = [] } = {}) { + const params = {}; + if (after) params.last_event_id = after; + if (deltas.length) params.event_deltas = deltas; + return this.client.sessions.events.streamEvents(sessionID, params); + } + + async streamReply(sessionID, after) { + this.step('通过 SSE 接收助手回复'); + const stream = await this.openStream(sessionID, { after }); + try { + for await (const event of stream) { + if (event.type === 'agent.tool_use' || event.type === 'agent.mcp_tool_use' || event.type === 'agent.custom_tool_use') { + this.info(`→ 调用工具:${event.name ?? '工具执行'}`); + } else if (event.type === 'agent.message') { + printReply(messageText(event)); + } else if (event.type === 'session.status_idle') { + break; + } + } + } finally { + stream.controller.abort(); + } + } + + async listReply(sessionID, after = '') { + this.step('轮询会话事件,打印助手回复'); + let lastID = after; + let count = 0; + for (;;) { + let idle = false; + for await (const event of this.client.sessions.events.list(sessionID, { + after_id: lastID || undefined, order: 'asc', limit: 100, + })) { + if (++count > 2000) return; + if (event.id) lastID = event.id; + if (event.type === 'agent.message') printReply(messageText(event)); + else if (event.type === 'session.status_idle') { idle = true; break; } + } + if (idle) break; + await delay(2000); + } + } + + async turn(sessionID, prompt, { stream = true } = {}) { + this.step('向会话发送消息'); + this.info(`你:${prompt}`); + const after = await this.send(sessionID, prompt); + if (stream) await this.streamReply(sessionID, after); + else await this.listReply(sessionID, after); + } +} + +// 顶层入口封装:运行 demo(example),无论成败都清理资源;失败置退出码。 +export async function runManagedExample(demo) { + const example = new ManagedExample(); + try { + await demo(example); + } catch (error) { + console.error(error); + process.exitCode = 1; + } finally { + await example.cleanup(); + } +} diff --git a/examples/managed-scenarios.mjs b/examples/managed-scenarios.mjs deleted file mode 100644 index e617d9d..0000000 --- a/examples/managed-scenarios.mjs +++ /dev/null @@ -1,69 +0,0 @@ -// Managed SDK 示例:最小可运行演示,展示 Agent + 会话与流式回复。 -// 仅作代码示例,不承担测试作用;运行:node examples/managed-scenarios.mjs -import { pathToFileURL } from 'node:url'; -import { ManagedClient } from '../dist/managed/index.js'; - -const name = (kind) => `sdk-example-${kind}-${Math.random().toString(16).slice(2, 10)}`; - -function makeClient() { - const pat = process.env.QODER_PAT ?? process.env.QODER_MANAGED_PAT; - if (!pat) throw new Error('设置 QODER_PAT 后运行本示例'); - return new ManagedClient({ - pat, - baseURL: process.env.QODER_MANAGED_BASE_URL || 'https://api.qoder.com/api/v1/cloud/', - maxRetries: 0, - }); -} - -async function chooseModel(client) { - const models = await client.models.list(); - const enabled = models.data.filter((m) => m.is_enabled).map((m) => m.id); - if (!enabled.length) throw new Error('账号没有已启用的模型'); - return enabled[0]; -} - -// 演示:创建 Agent 与会话,发一轮消息并流式打印助手回复,最后清理。 -async function sessionDemo(client) { - const model = await chooseModel(client); - console.log(`使用模型:${model}`); - - const agent = await client.agents.create({ name: name('agent'), model, system: '你是一个 SDK 示例助手。' }); - const session = await client.sessions.create({ agent_id: agent.id }); - console.log(`已创建会话:${session.id}`); - - const sent = await client.sessions.events.send(session.id, { - events: [{ type: 'user.message', content: [{ type: 'text', text: '请用一句话介绍你能提供什么帮助。' }] }], - }); - const lastEventID = sent.data[0]?.id; - - const stream = await client.sessions.events.streamEvents(session.id, { last_event_id: lastEventID }); - try { - for await (const event of stream) { - if (event.type === 'agent.message') { - const text = (event.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join(''); - if (text) console.log(`助手:${text}`); - } else if (event.type === 'session.status_idle') { - break; - } - } - } finally { - stream.controller.abort(); - } - - await client.sessions.delete(session.id); - await client.agents.archive(agent.id); - console.log('已清理示例资源。'); -} - -async function main() { - const client = makeClient(); - await sessionDemo(client); -} - -// 仅在直接运行时执行(被 import 时不触发网络)。 -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main().catch((error) => { - console.error(error); - process.exitCode = 1; - }); -} diff --git a/examples/managed/conversation.mjs b/examples/managed/conversation.mjs new file mode 100644 index 0000000..e6bd55f --- /dev/null +++ b/examples/managed/conversation.mjs @@ -0,0 +1,32 @@ +// Managed · conversation:复用同一个 Session 进行多轮对话;重建 SDK 客户端后仍能通过 +// Session ID 读取服务端保存的历史,再追问上文约定的信息。 +// 运行:node examples/managed/conversation.mjs(需 QODER_PAT 或 QODER_MANAGED_PAT)。 +import { historyMessage, isMain, makeManagedClient, marker, runManagedExample } from '../lib/managed.mjs'; + +async function main() { + await runManagedExample(async (s) => { + const env = await s.environment(); + const agent = await s.agent(); + const session = await s.session({ agent: agent.id, environment_id: env.id }); + + const code = `project-${marker()}`; + await s.turn(session.id, `这次项目代号是 ${code}。请在本次对话中记住它,不要使用工具或写入记忆库。现在只回复:已记住。`); + + s.info(`Session ID:${session.id}`); + s.step('重新创建 SDK 客户端,通过 Session ID 读取服务端会话'); + // 新客户端没有任何本地聊天历史;服务端仍以 sessionID 保存整段会话。 + s.client = makeManagedClient(); + const current = await s.client.sessions.retrieve(session.id, {}); + s.info(`会话状态:${current.status}`); + + s.step('分页读取已有的用户消息和助手回复'); + for await (const event of s.client.sessions.events.list(session.id, { order: 'asc', limit: 100 })) { + const message = historyMessage(event); + if (message) s.info(`历史 · ${message.role}:${message.text}`); + } + + await s.turn(session.id, '只根据本次会话上文,告诉我刚才约定的项目代号。只回复代号,不要使用工具。'); + }); +} + +if (isMain(import.meta.url)) main(); diff --git a/examples/managed/custom-tools.mjs b/examples/managed/custom-tools.mjs new file mode 100644 index 0000000..22d79fe --- /dev/null +++ b/examples/managed/custom-tools.mjs @@ -0,0 +1,96 @@ +// Managed · custom-tools:让 Agent 调用自定义工具 lookup_order;工具在本地 JS 函数里执行, +// 结果通过 user.custom_tool_result 回传,Agent 再据此给出最终回答。运单号只存在于本进程, +// 不出现在模型提示里,因此正确回答必然经过一次真实的工具调用。 +// 运行:node examples/managed/custom-tools.mjs(需 QODER_PAT 或 QODER_MANAGED_PAT)。 +import { isMain, marker, name, printReply, runManagedExample } from '../lib/managed.mjs'; + +// lookupOrder 是业务代码边界:把内存查询替换成你自己的服务或数据库查询即可。 +function lookupOrder(call, order) { + if (call.name !== 'lookup_order') return { text: 'unknown tool; use lookup_order', isError: true }; + const id = call.input?.order_id; + if (typeof id !== 'string' || !id) return { text: 'order_id must be a non-empty string', isError: true }; + if (id !== order.order_id) return { text: 'order not found', isError: true }; + return { text: JSON.stringify(order), isError: false }; +} + +async function main() { + await runManagedExample(async (s) => { + const env = await s.environment(); + const model = await s.selectModel(); + + s.step('创建带 lookup_order 自定义工具的 Agent'); + const agent = await s.client.agents.create({ + name: name('custom-tools'), + model: { id: model }, + system: '查询订单时必须调用 lookup_order。拿到工具返回后,用中文回答订单状态和完整运单号,不得编造。', + tools: [{ + type: 'custom', + name: 'lookup_order', + description: '根据订单 ID 查询订单状态和运单号。', + input_schema: { + type: 'object', + properties: { order_id: { type: 'string', description: '待查询的订单 ID' } }, + required: ['order_id'], + }, + }], + }); + s.track(`agent ${agent.id}`, () => s.client.agents.archive(agent.id, {})); + + const session = await s.session({ agent: agent.id, environment_id: env.id }); + + const order = { order_id: `order-${marker()}`, status: '已发货', tracking_number: `track-${marker()}` }; + const prompt = `请查询订单 ${order.order_id},告诉我订单状态和完整运单号。`; + s.step('发送订单查询消息'); + s.info(`你:${prompt}`); + let after = await s.send(session.id, prompt); + + s.step('等待 Agent 调用本地工具并给出最终回答'); + for (let round = 0; round < 8; round++) { + const stream = await s.openStream(session.id, { after }); + const pending = new Map(); + let requires = null; + let done = false; + let cursor = after; + try { + for await (const event of stream) { + if (event.id) cursor = event.id; + if (event.type === 'agent.custom_tool_use') { + pending.set(event.id, event); + s.info(`→ 调用工具:${event.name}`); + } else if (event.type === 'agent.message') { + printReply((event.content ?? []).filter((b) => b.type === 'text').map((b) => b.text ?? '').join('')); + } else if (event.type === 'session.status_idle') { + // requires_action 表示暂停以等待工具结果,而不是给出了最终答案。 + if (event.stop_reason?.type === 'requires_action') { requires = event.stop_reason.event_ids ?? []; break; } + done = true; + break; + } + } + } finally { + stream.controller.abort(); + } + if (done) return; + if (!requires) { s.info('流在给出最终答案前结束。'); return; } + + const results = []; + for (const id of requires) { + const call = pending.get(id); + if (!call) continue; + const { text, isError } = lookupOrder(call, order); + s.info(`本地执行 ${call.name},回传结果(is_error=${isError})`); + results.push({ + type: 'user.custom_tool_result', + custom_tool_use_id: call.id, + is_error: isError, + content: [{ type: 'text', text }], + }); + } + await s.client.sessions.events.send(session.id, { events: results }); + // 从 idle 事件之后继续,避免遗漏紧随其后的最终回复。 + after = cursor; + } + s.info('自定义工具循环超过 8 轮,未得到最终回答。'); + }); +} + +if (isMain(import.meta.url)) main(); diff --git a/examples/managed/deployment.mjs b/examples/managed/deployment.mjs new file mode 100644 index 0000000..2541a96 --- /dev/null +++ b/examples/managed/deployment.mjs @@ -0,0 +1,34 @@ +// Managed · deployment:创建 Deployment(携带运行时初始消息),手动触发一次运行, +// 等待完成后回读运行生成会话的助手回复。 +// 运行:node examples/managed/deployment.mjs(需 QODER_PAT 或 QODER_MANAGED_PAT)。 +import { isMain, marker, name, runManagedExample } from '../lib/managed.mjs'; + +async function main() { + await runManagedExample(async (s) => { + const env = await s.environment(); + const agent = await s.agent(); + const echo = marker(); + + s.step('创建 Deployment,设置运行时的初始消息'); + const deployment = await s.client.deployments.create({ + name: name('deployment'), + agent: agent.id, + environment_id: env.id, + initial_events: [{ type: 'user.message', content: [{ type: 'text', text: `Reply with exactly ${echo}` }] }], + }); + s.track(`deployment ${deployment.id}`, () => s.client.deployments.archive(deployment.id, {})); + + s.step('手动触发一次 Deployment 运行'); + const run = await s.client.deployments.run(deployment.id, {}); + s.info(`本次运行 ID:${run.id},会话:${run.session_id}`); + if (!run.session_id) throw new Error('deployment run returned no session'); + s.track(`deployment session ${run.session_id}`, () => s.finishSession(run.session_id)); + + const got = await s.client.deploymentRuns.retrieve(run.id, {}); + s.info(`Deployment Run 关联会话:${got.session_id}`); + + await s.listReply(run.session_id); + }); +} + +if (isMain(import.meta.url)) main(); diff --git a/examples/managed/dream.mjs b/examples/managed/dream.mjs new file mode 100644 index 0000000..5a51c32 --- /dev/null +++ b/examples/managed/dream.mjs @@ -0,0 +1,52 @@ +// Managed · dream:让 Dream 异步整理输入记忆库,等待完成后读取输出记忆库,确认整理后的 +// 记忆保留了原始校验值。 +// 运行:node examples/managed/dream.mjs(需 QODER_PAT 或 QODER_MANAGED_PAT)。 +import { setTimeout as delay } from 'node:timers/promises'; +import { isMain, marker, name, printReply, runManagedExample } from '../lib/managed.mjs'; + +async function main() { + await runManagedExample(async (s) => { + const model = await s.selectModel(); + + s.step('创建记忆库(Memory Store)'); + const store = await s.client.memoryStores.create({ name: name('dream-input') }); + s.track(`input memory store ${store.id}`, () => s.client.memoryStores.delete(store.id, {})); + + const echo = marker(); + s.step('向记忆库写入示例内容和随机校验值'); + await s.client.memoryStores.memories.create(store.id, { + path: 'sdk-example/source.md', + content: `Permanent project verification code: ${echo}. Preserve this exact code during consolidation.`, + }); + + s.step('创建 Dream,整理输入记忆库中的内容'); + let dream = await s.client.dreams.create({ + inputs: [{ type: 'memory_store', memory_store_id: store.id }], + model, + instructions: 'Consolidate the supplied memory into sdk-example/consolidated.md. Preserve the exact project verification code. Keep the original source.', + }); + s.track(`dream ${dream.id}`, () => s.client.dreams.archive(dream.id, {})); + + s.step('等待 Dream 整理记忆'); + let previous = ''; + while (['pending', 'running'].includes(dream.status)) { + if (dream.status !== previous) { s.info(`Dream 状态:${dream.status}`); previous = dream.status; } + await delay(2000); + dream = await s.client.dreams.retrieve(dream.id, {}); + } + s.info(`Dream 最终状态:${dream.status}`); + + s.step('读取 Dream 输出,检查整理后的记忆'); + for (const output of dream.outputs ?? []) { + s.track(`dream output store ${output.memory_store_id}`, () => s.client.memoryStores.delete(output.memory_store_id, {})); + for await (const memory of s.client.memoryStores.memories.list(output.memory_store_id, {})) { + if (memory.path !== 'sdk-example/consolidated.md') continue; + const got = await s.client.memoryStores.memories.retrieve(memory.id, { memory_store_id: output.memory_store_id }); + s.info(`整理后的记忆:${got.path}`); + printReply(got.content); + } + } + }); +} + +if (isMain(import.meta.url)) main(); diff --git a/examples/managed/memory.mjs b/examples/managed/memory.mjs new file mode 100644 index 0000000..031ad21 --- /dev/null +++ b/examples/managed/memory.mjs @@ -0,0 +1,58 @@ +// Managed · memory:通过 SDK 写入项目发布约定,并以只读资源挂载到会话——项目信息只存在 +// 于记忆库中,不进系统指令,也不进聊天历史,验证新会话能凭记忆拟定上线安排。 +// 运行:node examples/managed/memory.mjs(需 QODER_PAT 或 QODER_MANAGED_PAT)。 +import { isMain, marker, name, runManagedExample } from '../lib/managed.mjs'; + +const PROJECT_MEMORY_PATH = 'projects/release-conventions.md'; + +function projectMemory() { + const project = `青禾订单-${marker().slice(0, 6)}`; + const releaseTime = '21:30'; + const contact = '林岚'; + const rollback = `v2.${100 + Math.floor(Math.random() * 900)}.${100 + Math.floor(Math.random() * 900)}`; + return { + project, + content: `---\nname: release-conventions\ndescription: ${project} 的项目发布约定\nmetadata:\n type: project\n---\n\n# ${project} 的发布约定\n\n- 这是一个订单服务项目。\n- 团队约定在北京时间 ${releaseTime} 开始发布。\n- 发布异常时先联系值班负责人${contact}。\n- 如果需要回滚,使用已验证的稳定版本 ${rollback}。\n`, + index: `- [${project} 发布约定](${PROJECT_MEMORY_PATH}) — 项目的发布窗口、异常联系人与回滚约定。\n`, + prompt: `请根据你记得的项目约定,为「${project}」拟一份简短的上线安排,涵盖开始时间、异常联系和回滚处理。只需给出计划,不要执行发布。`, + }; +} + +async function main() { + await runManagedExample(async (s) => { + const env = await s.environment(); + const memory = projectMemory(); + + s.step('创建记忆库(Memory Store)'); + const store = await s.client.memoryStores.create({ name: name('memory') }); + s.track(`memory store ${store.id}`, () => s.client.memoryStores.delete(store.id, {})); + + s.step('通过 SDK 写入项目背景和发布约定'); + const entry = await s.client.memoryStores.memories.create(store.id, { path: PROJECT_MEMORY_PATH, content: memory.content }); + s.step('写入 MEMORY.md 索引,供新会话发现项目记忆'); + await s.client.memoryStores.memories.create(store.id, { path: 'MEMORY.md', content: memory.index }); + + s.step('通过 SDK 重新读取,确认记忆已保存'); + const saved = await s.client.memoryStores.memories.retrieve(entry.id, { memory_store_id: store.id }); + s.info(`已保存记忆:${saved.path}`); + + const agent = await s.agent(); + const session = await s.session({ + agent: agent.id, + environment_id: env.id, + resources: [{ type: 'memory_store', memory_store_id: store.id, access: 'read_only' }], + }); + + s.step('查询新会话的资源,确认记忆库已挂载'); + let mounted = 0; + for await (const resource of s.client.sessions.resources.list(session.id, {})) { + if (resource.type === 'memory_store' && resource.memory_store_id === store.id) mounted++; + } + s.info(`会话中匹配的记忆库资源:${mounted}`); + + s.info('下面的对话,项目约定只能来自挂载的记忆库。'); + await s.turn(session.id, memory.prompt); + }); +} + +if (isMain(import.meta.url)) main(); diff --git a/examples/managed/models.mjs b/examples/managed/models.mjs new file mode 100644 index 0000000..75d4c0a --- /dev/null +++ b/examples/managed/models.mjs @@ -0,0 +1,18 @@ +// Managed · models:查询当前账号可用的模型。 +// 运行:node examples/managed/models.mjs(需 QODER_PAT 或 QODER_MANAGED_PAT)。 +import { isMain, makeManagedClient } from '../lib/managed.mjs'; + +async function main() { + const client = makeManagedClient(); + const models = await client.models.list({}); + const enabled = models.data.filter((m) => m.is_enabled).map((m) => m.id); + console.log(`共 ${models.data.length} 个模型,已启用 ${enabled.length} 个:`); + console.log(` ${enabled.join(', ')}`); +} + +if (isMain(import.meta.url)) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/examples/managed/resources.mjs b/examples/managed/resources.mjs new file mode 100644 index 0000000..b3079da --- /dev/null +++ b/examples/managed/resources.mjs @@ -0,0 +1,41 @@ +// Managed · resources:演示文件挂载、Session 环境变量和自定义 Skill 的读取。 +// 校验值只存在于文件 / 环境变量 / Skill 中,不出现在用户消息里。 +// 运行:node examples/managed/resources.mjs(需 QODER_PAT 或 QODER_MANAGED_PAT)。 +import { isMain, marker, name, runManagedExample, toFile } from '../lib/managed.mjs'; + +async function main() { + await runManagedExample(async (s) => { + const env = await s.environment(); + const fileToken = marker(); + const envToken = marker(); + const skillToken = marker(); + + s.step('上传示例文件,供会话中的工具读取'); + const file = await s.client.files.upload({ file: await toFile(fileToken, 'sdk-example.txt') }); + s.track(`file ${file.id}`, () => s.client.files.delete(file.id, {})); + + const skillName = name('skill'); + s.step('上传自定义 Skill,其中包含一个随机校验值'); + const skill = await s.client.skills.create({ + files: [await toFile( + `---\nname: ${skillName}\ndescription: Provides a sample verification code for the SDK example.\n---\nThe example verification value EXAMPLE_SKILL_CODE is: ${skillToken}\n`, + `${skillName}/SKILL.md`, + )], + }); + s.track(`skill ${skill.id}`, () => s.client.skills.delete(skill.id, {})); + + const agent = await s.agent({ skills: [{ type: 'custom', skill_id: skill.id, version: skill.latest_version }] }); + + const session = await s.session({ + agent: agent.id, + environment_id: env.id, + environment_variables: { SDK_EXAMPLE_VALUE: envToken }, + resources: [{ type: 'file', file_id: file.id, mount_path: '/data/workspace/sdk-example.txt' }], + }); + + await s.turn(session.id, '请使用工具读取 /data/workspace/sdk-example.txt 的内容和 SDK_EXAMPLE_VALUE 环境变量,分别返回这两个示例值。'); + await s.turn(session.id, `请使用技能 ${skillName} 读取 EXAMPLE_SKILL_CODE,返回这个示例校验码。`); + }); +} + +if (isMain(import.meta.url)) main(); diff --git a/examples/managed/session.mjs b/examples/managed/session.mjs new file mode 100644 index 0000000..c6ba65c --- /dev/null +++ b/examples/managed/session.mjs @@ -0,0 +1,16 @@ +// Managed · session:演示 Agent -> Session 生命周期,发送一条消息并通过 SSE 接收助手回复。 +// 运行:node examples/managed/session.mjs(需 QODER_PAT 或 QODER_MANAGED_PAT)。 +import { isMain, marker, runManagedExample } from '../lib/managed.mjs'; + +async function main() { + await runManagedExample(async (s) => { + const env = await s.environment(); + const agent = await s.agent(); + const session = await s.session({ agent: agent.id, environment_id: env.id }); + + const echo = marker(); + await s.turn(session.id, `请用一句话介绍你能提供什么帮助,并在回复末尾原样附上:${echo}`); + }); +} + +if (isMain(import.meta.url)) main(); diff --git a/examples/managed/streaming-deltas.mjs b/examples/managed/streaming-deltas.mjs new file mode 100644 index 0000000..e2e5fe9 --- /dev/null +++ b/examples/managed/streaming-deltas.mjs @@ -0,0 +1,49 @@ +// Managed · streaming-deltas:先订阅 SSE 的文本增量(agent.message deltas),随消息生成逐步 +// 刷新预览,最后用完整的 agent.message 替换预览。预览事件不写入历史,故必须先订阅再发消息。 +// 运行:node examples/managed/streaming-deltas.mjs(需 QODER_PAT 或 QODER_MANAGED_PAT)。 +import { isMain, marker, printReply, runManagedExample } from '../lib/managed.mjs'; + +async function main() { + await runManagedExample(async (s) => { + const env = await s.environment(); + const agent = await s.agent(); + const session = await s.session({ agent: agent.id, environment_id: env.id }); + + s.step('先订阅 SSE,开启 agent.message 文本增量'); + const stream = await s.openStream(session.id, { deltas: ['agent.message'] }); + + const echo = marker(); + const prompt = `请分三句话解释为什么多轮对话要复用 Session ID,最后原样附上:${echo}`; + s.step('发送消息'); + s.info(`你:${prompt}`); + await s.send(session.id, prompt); + + s.step('接收增量并在收到完整消息后替换预览'); + const previews = new Map(); // event_id -> Map(blockIndex -> text) + let deltas = 0; + try { + for await (const event of stream) { + if (event.type === 'event_delta' && event.delta?.type === 'content_delta' && event.delta?.content?.type === 'text') { + const id = event.event_id; + const blocks = previews.get(id) ?? new Map(); + previews.set(id, blocks); + // 同一消息可能拆成多个片段共享 event_id,按块索引累加。 + blocks.set(event.delta.index, (blocks.get(event.delta.index) ?? '') + (event.delta.content.text ?? '')); + deltas++; + const text = [...blocks.keys()].sort((a, b) => a - b).map((i) => blocks.get(i)).join('\n'); + console.log(` 预览(${id}):${text}`); + } else if (event.type === 'agent.message') { + s.info(`消息 ${event.id} 收到完整内容,替换该消息的预览。`); + printReply((event.content ?? []).filter((b) => b.type === 'text').map((b) => b.text ?? '').join('')); + } else if (event.type === 'session.status_idle') { + break; + } + } + } finally { + stream.controller.abort(); + } + s.info(deltas ? `共收到 ${deltas} 个文本增量;增量是尽力提供的预览。` : '本次未收到增量,使用最终完整消息。'); + }); +} + +if (isMain(import.meta.url)) main(); diff --git a/package.json b/package.json index a95e917..e8c0790 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "prepack": "npm run build", "test:types": "tsc -p tsconfig.types.json", "test:e2e": "npm test && node scripts/run-live.mjs e2e", - "example": "npm run build && node examples/forward-scenarios.mjs", + "example": "npm run build && node examples/forward/session.mjs", "docs": "node scripts/generate-docs.mjs", "docs:check": "node scripts/docs-check.mjs" }, From 5d88c1a67c0264bbb92bb2dc2c78ea3803a71cee Mon Sep 17 00:00:00 2001 From: moonyue-w <300878504+moonyue-w@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:32:10 +0800 Subject: [PATCH 3/3] test(live): align file and memory setup with API semantics --- tests/live/forward-scenarios.mjs | 10 +++++++--- tests/live/managed-scenarios.mjs | 6 +++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/live/forward-scenarios.mjs b/tests/live/forward-scenarios.mjs index 15940ae..abdf06a 100644 --- a/tests/live/forward-scenarios.mjs +++ b/tests/live/forward-scenarios.mjs @@ -115,8 +115,10 @@ scenario('session_resource_thread_lifecycle', async (s) => { scenario('file_upload_download_lifecycle', async (s) => { const file = await s.file('sdk-live.txt', 'user_upload', 'SDK live file content'); - assert.equal((await s.client.files.getMetadata(file.id, s.options)).id, file.id); - assert.equal(await (await s.client.files.download(file.id, s.options)).text(), 'SDK live file content'); + const meta = await s.client.files.getMetadata(file.id, s.options); + assert.equal(meta.id, file.id); + // CAS forbids downloading user_upload files; only round-trip when the API marks them downloadable. + if (meta.downloadable) assert.equal(await (await s.client.files.download(file.id, s.options)).text(), 'SDK live file content'); }); export function assertNonemptyZip(bytes) { @@ -214,7 +216,9 @@ scenario('validation_only_batch_lifecycle', async (s) => { scenario('execution_e2e', async (s, t) => { const environment = await s.environment(); - const identity = await s.identity(); + const identityName = liveName('identity'); + const identity = await s.client.identities.create({ external_id: identityName, name: identityName, metadata: { suite: 'sdk-live' } }, s.options); + s.cleanup('identity', (options) => s.client.identities.delete(identity.id, options)); const fileToken = marker(), envToken = marker(), skillToken = marker(), memoryToken = marker(); const file = await s.file('sdk-e2e.txt', 'session_resource', fileToken); const skillName = liveName('proof'); diff --git a/tests/live/managed-scenarios.mjs b/tests/live/managed-scenarios.mjs index c113c60..787a41e 100644 --- a/tests/live/managed-scenarios.mjs +++ b/tests/live/managed-scenarios.mjs @@ -70,9 +70,9 @@ export const managedScenarios = [ const content = 'Managed SDK live file\n'; const file = await s.client.files.upload({ file: new File([content], `${unique('file')}.txt`), metadata: { suite: 'sdk-live' } }, s.options()); s.cleanup(`File ${file.id}`, () => s.client.files.delete(file.id, {}, s.options())); - await s.client.files.getMetadata(file.id, {}, s.options()); - const download = await s.client.files.download(file.id, {}, s.options()); - assert.equal(await download.text(), content, 'Download content'); + const meta = await s.client.files.getMetadata(file.id, {}, s.options()); + // Uploaded files default to user_upload, which CAS forbids downloading; gate on downloadable. + if (meta.downloadable) assert.equal(await (await s.client.files.download(file.id, {}, s.options())).text(), content, 'Download content'); }), scenario('deployment_lifecycle', 'write', async (s) => { const environment = await s.createEnvironment();