test(managed-agent): add Hosted no-tool process gates (#12733)

* test(managed-agent): add Hosted no-tool process gates

Refs #12728

* fix(ci): satisfy YAML quoting rules in hosted process gates
This commit is contained in:
Shaojin Wen 2026-09-26 08:33:27 +00:00 • committed by GitHub
parent 66e5903426
commit c7c6def8bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1816 additions and 3 deletions

View file

@ -1645,6 +1645,12 @@ jobs:
run: |-
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Hosted portable process smoke'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
timeout-minutes: 5
run: |-
npm run test:integration:hosted:sandbox:none -- -t 'portable startup'
- name: 'Run tests and generate reports'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
env:
@ -1820,6 +1826,12 @@ jobs:
run: |-
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Hosted portable process smoke'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
timeout-minutes: 5
run: |-
npm run test:integration:hosted:sandbox:none -- -t 'portable startup'
- name: 'Run tests and generate reports'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
env:

View file

@ -8,7 +8,10 @@ on:
paths:
- 'packages/sdk-java/**'
- 'packages/core/src/config/approval-modes.json'
- 'packages/core/src/managed-runtime/contracts/managed-session-store-v1.fixtures.json'
- 'packages/core/src/managed-runtime/**'
- 'packages/cli/src/config/**'
- 'packages/core/src/config/**'
- 'packages/core/src/core/**'
- 'packages/cli/src/commands/serve.ts'
- 'packages/cli/src/serve/**'
- 'packages/cli/src/acp-integration/**'
@ -28,7 +31,10 @@ on:
paths:
- 'packages/sdk-java/**'
- 'packages/core/src/config/approval-modes.json'
- 'packages/core/src/managed-runtime/contracts/managed-session-store-v1.fixtures.json'
- 'packages/core/src/managed-runtime/**'
- 'packages/cli/src/config/**'
- 'packages/core/src/config/**'
- 'packages/core/src/core/**'
- 'packages/cli/src/commands/serve.ts'
- 'packages/cli/src/serve/**'
- 'packages/cli/src/acp-integration/**'
@ -244,6 +250,69 @@ jobs:
-Dmysql.url='jdbc:mysql://127.0.0.1:3306/managed_agent_test?createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true&useSSL=false'
-Dmysql.user=root -Dmysql.password=runtime-broker clean verify checkstyle:check
hosted-harness-mysql:
name: 'Hosted no-tool processes / MySQL 8.4 / Java 21'
runs-on: 'ubuntu-latest'
timeout-minutes: 30
services:
mysql:
image: 'mysql:8.4.6'
env:
MYSQL_DATABASE: 'hosted_harness_test'
MYSQL_ROOT_PASSWORD: 'hosted-fixture'
ports:
- '3306/tcp'
options: >-
--health-cmd "mysqladmin ping -h 127.0.0.1 -phosted-fixture"
--health-interval 5s
--health-timeout 5s
--health-retries 30
steps:
- name: 'Checkout'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
- name: 'Set up Java'
uses: 'actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654' # v5.2.0
with:
distribution: 'temurin'
java-version: '21'
cache: 'maven'
cache-dependency-path: 'packages/sdk-java/**/pom.xml'
settings-path: '${{ runner.temp }}/setup-java-m2'
- name: 'Set up Node.js'
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22'
package-manager-cache: false
- name: 'Enable Corepack'
run: 'corepack enable'
- name: 'Install, build and bundle CLI'
run: |-
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
- name: 'Install Managed Agent dependencies'
env:
MAVEN_ARGS: '--settings ${{ runner.temp }}/setup-java-m2/settings.xml --toolchains ${{ runner.temp }}/setup-java-m2/toolchains.xml'
run: |-
mvn --batch-mode --no-transfer-progress -f packages/sdk-java/qwencode/pom.xml -DskipTests -Dgpg.skip=true -Dmaven.javadoc.skip=true install
mvn --batch-mode --no-transfer-progress -f packages/sdk-java/runtime-broker/pom.xml -DskipTests install
- name: 'Verify Hosted Java, Spring and MySQL processes'
timeout-minutes: 5
env:
MAVEN_ARGS: '--settings ${{ runner.temp }}/setup-java-m2/settings.xml --toolchains ${{ runner.temp }}/setup-java-m2/toolchains.xml'
MYSQL_PORT: "${{ job.services.mysql.ports['3306'] }}"
run: >-
mvn --batch-mode --no-transfer-progress -f packages/sdk-java/managed-agent-server/pom.xml
-Phosted-harness-mysql -Dit.test=HostedHarnessMySqlIT
-Dqwen.cli.entry="${GITHUB_WORKSPACE}/dist/cli.js"
-Dmysql.url="jdbc:mysql://127.0.0.1:${MYSQL_PORT}/hosted_harness_test?allowPublicKeyRetrieval=true&useSSL=false"
-Dmysql.user=root -Dmysql.password=hosted-fixture verify checkstyle:check
- name: 'Upload Hosted process reports'
if: 'always()'
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'hosted-harness-mysql-reports'
path: 'packages/sdk-java/managed-agent-server/target/failsafe-reports/'
retention-days: 7
daemon-e2e:
name: 'Real daemon E2E / Java 11'
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name == ''push'' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || ''ubuntu-latest'' }}'

View file

@ -0,0 +1,45 @@
# Hosted no-tool process gate
[English](2026-09-26-hosted-no-tool-process-gate.md) | [简体中文](2026-09-26-hosted-no-tool-process-gate.zh-CN.md)
Status: implementation for #12728.
## Problem and baseline
The Hosted no-tool runtime in PR #12713 passed mocked tests while configuration initialization and stale prompt replay failed at the packaged CLI boundary. Initial independent verification used exact revision `5aa0b70526cc3bd5ebc32c247e44c6915f1734c5`, without production patches. After #12713 merged, this change was rebased onto its squash commit `879c311092f847e29539a9fc49dc7dbbabe0db63` on main, retaining only the process tests and CI gate.
## Proposed coverage
Add a focused integration suite that spawns `node dist/cli.js`, uses the existing deterministic OpenAI fixture and an HTTP adapter backed by the repository Session Store journal/resource implementation. Assert model request counts and contents, committed text/terminal order, retry receipts, cursor replay, failed/cancelled A → successful B → C history, tool refusal without filesystem effects, private-profile rejection, and writer release on detach/close.
## Isolation and failure handling
Each case owns a temporary home, workspace and Store directory, loopback listeners on ephemeral ports, and a bounded child process. Use an allowlisted process environment and local fake credentials. Always terminate children, abort SSE readers, close fixture listeners and remove temporary state, including startup timeout and assertion failure. Retain bounded diagnostics on failure. Missing build prerequisites fail explicitly; this gate never skips for absent credentials.
## CI and database slice
Add a reproducible focused npm entry point and include it in the existing required no-AK PR gate, with build/bundle prerequisites and workflow guards. Add startup/bind/cleanup coverage to the existing macOS/Windows lanes. Separately exercise the real Java HostedHarnessClient against the packaged CLI and Spring private Store using isolated real MySQL; assert the engine/version so MariaDB or H2 cannot substitute. Keep Java/database evidence separate from fixture evidence.
## Files and scope
Changes are limited to integration test helpers/cases/configuration, npm scripts, CI workflows and their guard tests, Java integration tests, and this design. No tools, approvals, output artifacts, public control-plane pipeline, kill/takeover recovery, uncertain turn replay, multi-instance failover, or production certification. No paid model calls.
## Validation and acceptance
Record exact SHA, platform, commands, results and omissions. Demonstrate separately that restoring configuration initialization and stale-history defects breaks the associated process regressions. Build, typecheck, run focused tests and workflow guards, then perform open-ended and reverse-evidence audits until two consecutive clean passes. A remote CI job must actually execute to count as evidence; skipped or unrun jobs do not count.
## Reproduction and recorded evidence
Install with `corepack pnpm install --frozen-lockfile` (its prepare step builds and bundles), or run `npm run build && npm run bundle` after a worktree bootstrap. Run `npm run test:integration:hosted:sandbox:none`. The portable subset is `npm run test:integration:hosted:sandbox:none -- -t 'portable startup'`.
For the database slice, install the `qwencode` and `runtime-broker` Maven modules, then run `mvn -f packages/sdk-java/managed-agent-server/pom.xml -Phosted-harness-mysql -Dit.test=HostedHarnessMySqlIT -Dqwen.cli.entry=<absolute-path-to-dist/cli.js> -Dmysql.url=<isolated-MySQL-JDBC-URL> -Dmysql.user=<user> -Dmysql.password=<password> verify checkstyle:check` using Java 21 and Node 22. The profile fails when prerequisites or tests are missing. The existing MariaDB profile excludes this test; the new CI job supplies an ephemeral `mysql:8.4.6` service.
On 2026-09-26, macOS 26.5.1 arm64 / Node 22.22.2 independently passed all 7 fixture-backed process tests against production SHA `5aa0b70526cc3bd5ebc32c247e44c6915f1734c5` without production source changes. Removing `lenientToolWarmup: true` from the bundled model function restored the original initialization defect: the fresh-session test failed with zero model requests and `SkillManager not available`. Restoring the old `setHistory(history)` made both failed/cancelled history cases fail because B contained A. Restoring and rebuilding the bundle passed again; all 1303 bundle files matched the baseline checksums.
The separate Java 21 / Spring private Store / packaged CLI test passed against an isolated Oracle MySQL **8.4.6, MySQL Community Server - GPL**, with 1 integration test executed and 0 skipped. It covered create, close/load, prompt receipt replay, SSE reconnection, explicit cancellation and subsequent history, detach/load, and persisted writer generations. The temporary database process stopped and its data directory was removed. This is macOS evidence, separate from the local JSONL fixture.
After rebasing onto main `879c311092f847e29539a9fc49dc7dbbabe0db63`, full build, bundle and typecheck passed again. Both the focused and standard no-AK configurations passed all 7 process tests with zero skips. Reinstalling the Java dependencies from that revision and rerunning against isolated MySQL 8.4.6 passed 1 integration test and 86 Java unit tests with zero skips; Checkstyle and resource cleanup also passed.
## Open evidence
The prerequisite PR has merged and this PR now targets main. Remote CI acceptance still requires a successful run on the final workflow revision. Linux and Windows execution remain unverified locally, and the existing macOS/Windows CI lanes run on merge-group, schedule or dispatch rather than ordinary PRs. The required no-AK PR job includes all process cases. Kill/takeover recovery and production readiness remain outside this verification.

View file

@ -0,0 +1,45 @@
# Hosted 无工具进程门禁
[English](2026-09-26-hosted-no-tool-process-gate.md) | [简体中文](2026-09-26-hosted-no-tool-process-gate.zh-CN.md)
状态:实现 #12728。
## 问题与基线
PR #12713 的 Hosted 无工具运行时曾通过 mock 测试,但打包 CLI 边界出现配置初始化失败和旧提示词重放。首次独立复验使用精确提交 `5aa0b70526cc3bd5ebc32c247e44c6915f1734c5`,无生产补丁。#12713 合入后,本变更新基线为其在 main 上的 squash 提交 `879c311092f847e29539a9fc49dc7dbbabe0db63`,仅保留进程测试和 CI 门禁。
## 测试方案
新增聚焦集成测试,启动 `node dist/cli.js`,复用确定性 OpenAI fixture,并通过 HTTP 适配器访问仓库 Session Store 日志及资源实现。检查模型请求次数和内容、已提交文本及终态顺序、重试回执、游标重放、失败/取消 A → 成功 B → C 的历史、拒绝工具且无文件副作用、私有协议拒绝行为及 detach/close 释放 writer。
## 隔离和失败处理
每个用例拥有独立临时 home、工作区及 Store 目录、临时端口上的 loopback listener,以及受时限约束的子进程。子进程环境采用白名单,仅使用本地假凭据。包括启动超时和断言失败在内,始终终止进程、中止 SSE reader、关闭 fixture listener 并清理临时状态。失败时保留有界诊断。缺少构建前置必须明确失败,不得因缺少凭据而跳过。
## CI 与数据库切片
新增可复现 npm 入口并接入现有无 AK PR 必跑门禁,包含构建/打包前置及 workflow 守护测试。在现有 macOS/Windows lane 添加启动、绑定及清理验证。独立使用真实 Java HostedHarnessClient → 打包 CLI → Spring 私有 Store → 隔离真实 MySQL,检查数据库种类和版本以拒绝 MariaDB/H2 替代。Java/数据库与 fixture 证据分别记录。
## 文件与范围
变更仅涉及集成测试辅助程序、用例及配置、npm 脚本、CI workflow 及守护测试、Java 集成测试和本设计。不包含工具、审批、输出产物、公共控制面流程、强杀接管恢复、不确定轮次重放、多实例故障转移或生产认证。不调用付费模型。
## 验证与验收
记录精确 SHA、平台、命令、结果及遗漏项。分别恢复配置初始化和旧历史缺陷,证明对应进程回归会失败。执行构建、类型检查、聚焦测试及 workflow 守护测试,再做无方向审计和反向证据核验,直到连续两轮干净。远端 CI 必须实际运行才能作为证据;跳过或未运行不计通过。
## 复现入口与已记录证据
使用 `corepack pnpm install --frozen-lockfile` 安装(prepare 会构建并打包),或在工作树依赖准备后执行 `npm run build && npm run bundle`。测试入口为 `npm run test:integration:hosted:sandbox:none`;可移植子集为 `npm run test:integration:hosted:sandbox:none -- -t 'portable startup'`。
数据库切片先安装 `qwencode` 和 `runtime-broker` Maven 模块,再使用 Java 21 和 Node 22 执行 `mvn -f packages/sdk-java/managed-agent-server/pom.xml -Phosted-harness-mysql -Dit.test=HostedHarnessMySqlIT -Dqwen.cli.entry=<absolute-path-to-dist/cli.js> -Dmysql.url=<isolated-MySQL-JDBC-URL> -Dmysql.user=<user> -Dmysql.password=<password> verify checkstyle:check`。缺少前置或测试时该 profile 必须失败。现有 MariaDB profile 排除此测试;新增 CI 作业提供临时 `mysql:8.4.6` service。
2026-09-26,在 macOS 26.5.1 arm64 / Node 22.22.2 上,针对生产提交 `5aa0b70526cc3bd5ebc32c247e44c6915f1734c5` 独立跑通全部 7 项 fixture 进程测试,无生产源码改动。从打包后的模型函数移除 `lenientToolWarmup: true` 会恢复原初始化缺陷:新会话测试失败,模型请求为 0,错误为 `SkillManager not available`。恢复旧 `setHistory(history)` 后,失败/取消两项历史测试均因 B 包含 A 而失败。恢复并重新打包后再次通过,全部 1303 个 bundle 文件与基线校验值相同。
独立 Java 21 / Spring 私有 Store / 打包 CLI 测试已在隔离的 Oracle MySQL **8.4.6, MySQL Community Server - GPL** 上通过,实际执行 1 项集成测试,跳过 0 项。覆盖创建、close/load、Prompt 回执重试、SSE 重连、显式取消及后续历史、detach/load、持久化 writer 代数。临时数据库进程已停止,数据目录已删除。这是 macOS 的实测证据,与本地 JSONL fixture 证据分别记录。
变基到 main `879c311092f847e29539a9fc49dc7dbbabe0db63` 后,完整构建、打包及类型检查再次通过。聚焦配置及标准无 AK 配置均通过全部 7 项进程测试,跳过 0 项。重新安装该版本的 Java 依赖后,在隔离 MySQL 8.4.6 上通过 1 项集成测试和 86 项 Java 单测,均跳过 0 项;Checkstyle 和资源清理检查也通过。
## 待完成证据
前置 PR 已合入,本 PR 现以 main 为目标。远端 CI 验收仍需最终 workflow 版本的成功运行。本地尚未验证 Linux、Windows;现有 macOS/Windows CI lane 在 merge-group、定时或手动触发时运行,不在普通 PR 上运行。无 AK PR 必跑作业包含全部进程用例。强杀接管恢复及生产就绪仍不在本次验证范围内。

View file

@ -0,0 +1,547 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createHash, randomUUID } from 'node:crypto';
import { existsSync } from 'node:fs';
import { createServer } from 'node:net';
import path from 'node:path';
import WebSocket from 'ws';
import type { ManagedSessionDurableRef } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js';
import { afterEach, describe, expect, it } from 'vitest';
import {
fakeToolCall,
startFakeOpenAIServer,
type FakeOpenAIHandler,
type FakeOpenAIServer,
} from '../fake-openai-server.js';
import {
HostedHarnessProcess,
HOSTED_TOKEN,
waitUntil,
} from '../helpers/hosted-harness-process.js';
import { startHostedSessionStore } from '../helpers/hosted-session-store.js';
interface Event {
v: number;
id: number;
type: string;
promptId?: string;
data: {
stopReason?: string;
code?: string;
update?: { content: { text: string } };
};
}
interface Receipt {
promptId: string;
lastEventId: number;
eventEpoch: string;
}
let cli: HostedHarnessProcess;
let model: FakeOpenAIServer | undefined;
let store: Awaited<ReturnType<typeof startHostedSessionStore>> | undefined;
let sessionId: string;
let clientId: string;
let releaseModel: (() => void) | undefined;
afterEach(async ({ task }) => {
releaseModel?.();
releaseModel = undefined;
const urls = [cli?.baseUrl, store?.baseUrl, model?.baseUrl].filter(
(url): url is string => !!url,
);
if (task.result?.state === 'fail')
console.error({
pid: cli?.child?.pid,
exitCode: cli?.child?.exitCode,
signal: cli?.child?.signalCode,
modelRequests: model?.requests.length,
output: cli?.output,
storeFailures: store?.failures,
});
try {
await cli?.close();
} finally {
try {
await store?.close();
} finally {
await model?.close();
store = undefined;
model = undefined;
}
}
if (cli?.child)
expect(cli.child.exitCode !== null || cli.child.signalCode !== null).toBe(
true,
);
if (cli?.root) expect(existsSync(cli.root)).toBe(false);
for (const url of urls) await assertPortReleased(url);
});
async function start(
handler: FakeOpenAIHandler = () => ({ content: 'HOSTED_REPLY' }),
) {
cli = new HostedHarnessProcess();
clientId = '';
model = await startFakeOpenAIServer(handler);
await cli.start(model.baseUrl);
sessionId = randomUUID();
store = await startHostedSessionStore(sessionId);
}
function connection() {
return {
baseUrl: store!.baseUrl,
tenantId: 'tenant',
workspaceId: 'workspace',
writerId: cli.bootId,
leaseDurationMs: 60_000,
};
}
async function json<T>(
route: string,
body?: unknown,
expected = 200,
method = body === undefined ? 'GET' : 'POST',
): Promise<T> {
const response = await cli.request(route, {
method,
headers: { ...cli.headers(clientId), 'Content-Type': 'application/json' },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const text = await response.text();
expect(response.status, text).toBe(expected);
return (text ? JSON.parse(text) : undefined) as T;
}
async function open(load = false) {
const created = await json<{ sessionId: string; clientId: string }>(
load ? `/session/${sessionId}/load` : '/session',
{
sessionId,
sessionScope: 'thread',
managedSessionStore: connection(),
},
);
expect(created.sessionId).toBe(sessionId);
clientId = created.clientId;
}
function payload(text: string) {
const prompt = [{ type: 'text', text }];
return {
prompt,
promptId: randomUUID(),
payloadDigest: `sha256:${createHash('sha256').update(JSON.stringify(prompt)).digest('hex')}`,
};
}
async function submit(body: ReturnType<typeof payload>) {
const receipt = await json<Receipt>(
`/session/${sessionId}/prompt`,
body,
202,
);
expect(receipt.promptId).toBe(body.promptId);
return receipt;
}
async function settled() {
await waitUntil(
async () =>
!(
await json<{ hasActivePrompt: boolean }>(`/session/${sessionId}/status`)
).hasActivePrompt,
);
expect(store!.failures).toEqual([]);
return (await json<{ events: Event[] }>(`/session/${sessionId}/transcript`))
.events;
}
function expectNoTools(request: Record<string, unknown>) {
expect(
request.tools === undefined ||
request.tools === null ||
(Array.isArray(request.tools) && request.tools.length === 0),
'Hosted model request must not advertise tools',
).toBe(true);
}
function assertCompleted(events: Event[], promptId: string, text: string) {
const visible = events.filter((event) => event.promptId === promptId);
expect(visible.map((event) => event.type)).toEqual([
'session_update',
'turn_complete',
]);
expect(visible[0].data.update?.content.text).toBe(text);
expect(visible[1].data.stopReason).toBe('end_turn');
}
async function replay(after: number, epoch: string, last: number) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10_000);
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
try {
const response = await cli.request(`/session/${sessionId}/events`, {
headers: {
...cli.headers(clientId),
'Last-Event-ID': String(after),
'X-Qwen-Event-Epoch': epoch,
},
signal: controller.signal,
});
expect(response.status).toBe(200);
expect(response.headers.get('x-qwen-event-epoch')).toBe(epoch);
reader = response.body!.getReader();
const events: Event[] = [];
const decoder = new TextDecoder();
let buffer = '';
while (events.at(-1)?.id !== last) {
const chunk = await reader.read();
expect(chunk.done).toBe(false);
buffer += decoder.decode(chunk.value, { stream: true });
let boundary: number;
while ((boundary = buffer.indexOf('\n\n')) >= 0) {
const frame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const event = JSON.parse(
frame
.split('\n')
.find((line) => line.startsWith('data: '))!
.slice(6),
) as Event;
expect(frame).toContain(`id: ${event.id}\n`);
expect(frame).toContain(`event: ${event.type}\n`);
events.push(event);
}
}
return events;
} finally {
controller.abort();
await reader?.cancel().catch(() => undefined);
clearTimeout(timer);
}
}
async function assertPortReleased(url: string) {
const server = createServer();
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(Number(new URL(url).port), '127.0.0.1', resolve);
});
await new Promise<void>((resolve) => server.close(() => resolve()));
}
describe(
'Hosted packaged no-tool process',
{ timeout: 90_000, retry: 0 },
() => {
it('initializes a fresh canonical Session and replays committed text without duplicate inference', async () => {
await start();
await open();
const body = payload('FRESH_PROMPT');
const receipt = await submit(body);
const events = await settled();
expect(model!.requests.length).toBe(1);
expectNoTools(model!.requests[0].body);
expect(
JSON.stringify(model!.requests[0].body.messages).includes(
'FRESH_PROMPT',
),
'fresh prompt reaches the model',
).toBe(true);
const visible = events.filter(
(event) => event.promptId === body.promptId,
);
expect(visible.map((event) => event.type)).toEqual([
'session_update',
'turn_complete',
]);
expect(visible[0].data.update?.content.text).toBe('HOSTED_REPLY');
expect(visible[1].data.stopReason).toBe('end_turn');
const committed = (await store!.scan()).events;
expect(events.map((event) => event.id)).toEqual(
committed.map((event) => event.sequence),
);
const message = committed.find(
(event) => event.sequence === visible[0].id,
)!;
expect(message).toMatchObject({
kind: 'message.committed',
payload: { role: 'assistant' },
});
expect(
JSON.parse(
(
await store!.readResource(
message.payload.contentRef as unknown as ManagedSessionDurableRef,
)
).toString(),
),
).toMatchObject({
sessionId,
daemonPromptId: body.promptId,
message: { role: 'model', parts: [{ text: 'HOSTED_REPLY' }] },
});
expect(
committed.find((event) => event.sequence === visible[1].id),
).toMatchObject({
kind: 'turn.settled',
payload: {
turnId: body.promptId,
outcome: 'completed',
stopReason: 'end_turn',
},
});
expect(await submit(body)).toEqual(receipt);
expect(
await replay(
receipt.lastEventId,
receipt.eventEpoch,
events.at(-1)!.id,
),
).toEqual(events.filter((event) => event.id > receipt.lastEventId));
expect(
await replay(visible[0].id, receipt.eventEpoch, events.at(-1)!.id),
).toEqual(events.filter((event) => event.id > visible[0].id));
expect(model!.requests.length).toBe(1);
await json(`/session/${sessionId}/detach`, {}, 204);
expect(store!.seals).toBe(1);
await open(true);
await json(`/session/${sessionId}`, undefined, 204, 'DELETE');
expect(store!.seals).toBe(2);
await open(true);
const loadedPrompt = payload('AFTER_LOAD');
await submit(loadedPrompt);
assertCompleted(await settled(), loadedPrompt.promptId, 'HOSTED_REPLY');
expect(model!.requests.length).toBe(2);
expect(
JSON.stringify(model!.requests[1].body.messages).includes(
'HOSTED_REPLY',
),
'completed history survives load',
).toBe(true);
});
it.each(['failed', 'cancelled'] as const)(
'omits %s A through successful B and C while retaining completed history',
async (outcome) => {
const held = new Promise<void>((resolve) => {
releaseModel = resolve;
});
await start(({ requestIndex }) =>
requestIndex === 0
? outcome === 'failed'
? { errorContent: 'DETERMINISTIC_FAILURE' }
: {
contentChunks: ['PARTIAL'],
holdAfterChunks: 1,
holdUntil: held,
}
: { content: `REPLY_${requestIndex}` },
);
await open();
const a = payload('UNANSWERED_A');
await submit(a);
await waitUntil(() => model!.requests.length === 1);
if (outcome === 'cancelled')
await json(`/session/${sessionId}/cancel`, {}, 204);
const events = await settled();
const terminal = events.find(
(event) =>
event.promptId === a.promptId && event.type.startsWith('turn_'),
);
expect(terminal).toMatchObject(
outcome === 'failed'
? { type: 'turn_error', data: { code: 'hosted_turn_failed' } }
: { type: 'turn_complete', data: { stopReason: 'cancelled' } },
);
releaseModel!();
for (const text of ['SUCCESS_B', 'NEXT_C']) {
const body = payload(text);
await submit(body);
assertCompleted(
await settled(),
body.promptId,
text === 'SUCCESS_B' ? 'REPLY_1' : 'REPLY_2',
);
const request = model!.requests.at(-1)!.body;
expectNoTools(request);
expect(
JSON.stringify(request.messages).includes('UNANSWERED_A'),
'unanswered A must not reappear',
).toBe(false);
expect(
JSON.stringify(request.messages).includes(text),
'current prompt reaches the model',
).toBe(true);
}
expect(model!.requests.length).toBe(3);
expect(
JSON.stringify(model!.requests[2].body.messages).includes(
'SUCCESS_B',
),
'C history: SUCCESS_B',
).toBe(true);
expect(
JSON.stringify(model!.requests[2].body.messages).includes('REPLY_1'),
'C history: REPLY_1',
).toBe(true);
expect(
JSON.stringify(model!.requests[2].body.messages).includes('PARTIAL'),
'C history: PARTIAL',
).toBe(false);
},
);
it('refuses a model-requested shell without local tools or a filesystem side effect', async () => {
await start(() => ({
toolCalls: [
fakeToolCall('run_shell_command', {
command: `echo forbidden > "${path.join(cli.root, 'forbidden-marker')}"`,
}),
],
}));
await open();
const body = payload('TRY_SHELL');
await submit(body);
expect(
(await settled()).filter((event) => event.promptId === body.promptId),
).toEqual([
expect.objectContaining({
type: 'turn_error',
data: expect.objectContaining({ code: 'hosted_turn_failed' }),
}),
]);
expect(model!.requests.length).toBe(1);
expectNoTools(model!.requests[0].body);
expect(cli.output).toContain(
'Hosted Harness no-tool turn refused a tool call',
);
expect(existsSync(path.join(cli.root, 'forbidden-marker'))).toBe(false);
});
it('enforces authentication, protocol, boot identity, storage and private HTTP/WebSocket boundaries', async () => {
await start();
for (const headers of [
new Headers(),
new Headers({ Authorization: 'Bearer wrong' }),
]) {
expect(
(await cli.request('/session', { method: 'POST', headers })).status,
).toBe(401);
}
expect(
(
await cli.request('/session', {
method: 'POST',
headers: { Authorization: `Bearer ${HOSTED_TOKEN}` },
})
).status,
).toBe(426);
expect(
(
await cli.request('/session', {
method: 'POST',
headers: {
Authorization: `Bearer ${HOSTED_TOKEN}`,
'X-Qwen-Harness-Protocol-Version': '1',
},
})
).status,
).toBe(400);
const stale = await cli.request('/session', {
method: 'POST',
headers: { ...cli.headers(), 'X-Qwen-Harness-Boot-Id': randomUUID() },
});
expect(stale.status).toBe(409);
expect(await stale.json()).toMatchObject({
code: 'hosted_harness_generation_mismatch',
});
for (const route of [
'/',
'/workspaces',
'/sessions',
'/acp',
'/mcp',
'/session/unknown/shell',
]) {
expect((await cli.request(route)).status).toBe(404);
}
const ws = new WebSocket(cli.baseUrl.replace('http:', 'ws:') + '/acp', {
headers: cli.headers(),
handshakeTimeout: 5_000,
});
try {
expect(
await new Promise<string>((resolve) => {
ws.once('open', () => resolve('opened'));
ws.once('unexpected-response', (_request, response) => {
response.resume();
resolve(`http ${response.statusCode}`);
});
ws.once('error', (error) => resolve(error.message));
}),
).toMatch(/404/);
} finally {
ws.on('error', () => undefined);
ws.terminate();
}
await store!.close();
await json(
'/session',
{
sessionId,
sessionScope: 'thread',
managedSessionStore: connection(),
},
503,
);
expect(model!.requests.length).toBe(0);
});
it('portable startup: binds loopback and cleans up after an assertion failure', async () => {
await start();
const urls = [cli.baseUrl, store!.baseUrl, model!.baseUrl];
await expect(
(async () => {
try {
expect(false, 'injected assertion failure').toBe(true);
} finally {
await cli.close();
await store!.close();
await model!.close();
}
})(),
).rejects.toThrow('injected assertion failure');
expect(
cli.child!.exitCode !== null || cli.child!.signalCode !== null,
).toBe(true);
expect(existsSync(cli.root)).toBe(false);
for (const url of urls) await assertPortReleased(url);
});
it('portable startup: refuses non-loopback and reaps a startup timeout', async () => {
cli = new HostedHarnessProcess();
await expect(
cli.start('http://127.0.0.1:9/v1', { hostname: '0.0.0.0' }),
).rejects.toThrow('requires a loopback');
expect(existsSync(cli.root)).toBe(false);
cli = new HostedHarnessProcess();
await expect(
cli.start('http://127.0.0.1:9/v1', {
startupTimeout: 100,
args: ['-e', 'setInterval(() => {}, 1000)'],
}),
).rejects.toThrow('Timed out');
expect(
cli.child!.exitCode !== null || cli.child!.signalCode !== null,
).toBe(true);
expect(existsSync(cli.root)).toBe(false);
});
},
);

View file

@ -0,0 +1,209 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
export const HOSTED_TOKEN = 'hosted-process-fixture-token';
export const HOSTED_DIGEST = `sha256:${'a'.repeat(64)}`;
export const HOSTED_CLI = path.resolve(
import.meta.dirname,
'../../dist/cli.js',
);
export async function waitUntil(
check: () => boolean | Promise<boolean>,
timeout = 30_000,
) {
const end = Date.now() + timeout;
while (!(await check())) {
if (Date.now() >= end) throw new Error(`Timed out after ${timeout}ms`);
await delay(30);
}
}
export class HostedHarnessProcess {
root = '';
baseUrl = '';
bootId = '';
output = '';
child?: ChildProcess;
private exited?: Promise<void>;
private spawnError?: Error;
async start(
modelUrl: string,
options: {
hostname?: string;
startupTimeout?: number;
args?: string[];
} = {},
) {
await access(HOSTED_CLI).catch(() => {
throw new Error(
'Missing packaged CLI: run npm run build && npm run bundle',
);
});
this.root = await mkdtemp(path.join(tmpdir(), 'hosted-no-tool-'));
try {
const config = path.join(this.root, '.qwen');
await mkdir(config);
await writeFile(
path.join(config, 'settings.json'),
JSON.stringify({
security: { auth: { selectedType: 'openai' } },
model: { name: 'hosted-fixture' },
telemetry: { enabled: false },
modelProviders: {
openai: [
{
id: 'hosted-fixture',
envKey: 'OPENAI_API_KEY',
baseUrl: modelUrl,
},
],
},
}),
);
const env: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(process.env)) {
if (
/^(PATH|SYSTEMROOT|WINDIR|COMSPEC|PATHEXT|TMP|TEMP|TMPDIR)$/i.test(
key,
)
)
env[key] = value;
}
this.child = spawn(
process.execPath,
options.args ?? [
HOSTED_CLI,
'serve',
'--profile',
'hosted-harness',
'--http-bridge',
'--no-web',
'--hostname',
options.hostname ?? '127.0.0.1',
'--port',
'0',
'--token',
HOSTED_TOKEN,
'--hosted-harness-capability-digest',
HOSTED_DIGEST,
'--workspace',
this.root,
],
{
cwd: this.root,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...env,
HOME: this.root,
USERPROFILE: this.root,
QWEN_HOME: config,
QWEN_CODE_SYSTEM_SETTINGS_PATH: path.join(
this.root,
'system-settings.json',
),
QWEN_CODE_SYSTEM_DEFAULTS_PATH: path.join(
this.root,
'system-defaults.json',
),
QWEN_RUNTIME_DIR: path.join(this.root, 'runtime'),
OPENAI_API_KEY: 'local-fixture-key',
OPENAI_BASE_URL: modelUrl,
OPENAI_MODEL: 'hosted-fixture',
QWEN_MODEL: 'hosted-fixture',
QWEN_SANDBOX: 'false',
NO_COLOR: '1',
},
},
);
const append = (data: Buffer) => {
this.output = (this.output + data.toString()).slice(-16_384);
};
this.child.stdout!.on('data', append);
this.child.stderr!.on('data', append);
this.exited = new Promise<void>((resolve) => {
this.child!.once('error', (error) => {
this.spawnError = error;
resolve();
});
this.child!.once('close', () => resolve());
});
await waitUntil(() => {
if (this.spawnError) throw this.spawnError;
if (this.child!.exitCode !== null || this.child!.signalCode !== null)
throw new Error(`Hosted CLI exited: ${this.output}`);
const match = this.output.match(
/qwen serve listening on (http:\/\/127\.0\.0\.1:\d+)/,
);
if (match) this.baseUrl = match[1];
return !!match;
}, options.startupTimeout);
await waitUntil(async () => {
const capabilities = await this.request('/capabilities');
const text = await capabilities.text();
if (capabilities.status === 503) return false;
if (!capabilities.ok)
throw new Error(
`Capabilities returned ${capabilities.status}: ${text}`,
);
const body = JSON.parse(text) as { hostedHarness: { bootId: string } };
this.bootId = body.hostedHarness.bootId;
return true;
}, options.startupTimeout);
} catch (error) {
await this.close();
throw error;
}
return this;
}
headers(clientId?: string): Record<string, string> {
return {
Authorization: `Bearer ${HOSTED_TOKEN}`,
'X-Qwen-Harness-Protocol-Version': '1',
'X-Qwen-Harness-Boot-Id': this.bootId,
...(clientId ? { 'X-Qwen-Client-Id': clientId } : {}),
};
}
request(route: string, init: RequestInit = {}) {
return fetch(this.baseUrl + route, {
...init,
headers: init.headers ?? this.headers(),
signal: init.signal ?? AbortSignal.timeout(10_000),
});
}
async close() {
if (
this.child &&
this.child.exitCode === null &&
this.child.signalCode === null
) {
this.child.kill('SIGTERM');
const timer = setTimeout(() => this.child?.kill('SIGKILL'), 3_000);
try {
await this.exited;
} finally {
clearTimeout(timer);
}
}
if (this.root)
await rm(this.root, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 100,
});
}
}

View file

@ -0,0 +1,212 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { createHash } from 'node:crypto';
import { createServer } from 'node:http';
import path from 'node:path';
import { LocalJsonlManagedSessionJournalStore } from '@qwen-code/qwen-code-core/managed-runtime/local-jsonl-managed-session-journal-store.js';
import { LocalManagedSessionResourceStore } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-resources.js';
import type { ManagedSessionDurableRef } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js';
import type { ManagedSessionJournalHandle } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-storage.js';
// An HTTP transport fixture, not a substitute for the Spring/MySQL slice.
export async function startHostedSessionStore(sessionId: string) {
const root = await mkdtemp(path.join(tmpdir(), 'hosted-store-'));
const sessionKey = {
tenantId: 'tenant',
workspaceId: 'workspace',
sessionId,
};
const transcriptPath = path.join(root, `${sessionId}.jsonl`);
const journal = new LocalJsonlManagedSessionJournalStore({
runtimeBaseDir: root,
sessionId,
transcriptPath,
});
const resources = LocalManagedSessionResourceStore.create({
runtimeBaseDir: root,
sessionKey,
});
const refs = new Map<string, ManagedSessionDurableRef>();
const transactions: Array<Record<string, unknown>> = [];
const failures: string[] = [];
const pending = new Set<Promise<void>>();
let handle: ManagedSessionJournalHandle | undefined;
let writerToken: string | undefined;
let writerGeneration = 0;
let seals = 0;
const scan = () =>
LocalJsonlManagedSessionJournalStore.read(transcriptPath, sessionKey);
const server = createServer((req, res) => {
res.setHeader('Cache-Control', 'no-store');
const request = (async () => {
const url = new URL(req.url!, 'http://127.0.0.1');
const prefix = `/internal/managed-session-store/v1/sessions/${sessionId}`;
assert(url.pathname.startsWith(prefix));
assert.equal(req.headers['x-qwen-tenant-id'], sessionKey.tenantId);
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
const body = JSON.parse(
Buffer.concat(chunks).toString() || '{}',
) as Record<string, unknown>;
assert.equal(
body.workspaceId ?? url.searchParams.get('workspaceId'),
sessionKey.workspaceId,
);
const route = url.pathname.slice(prefix.length);
const json = (value: unknown) => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(value));
};
if (route === '/writers:acquire') {
if (handle) {
res.statusCode = 409;
json({
error: { code: 'writer_busy', message: 'writer still attached' },
});
return;
}
writerToken = String(req.headers['x-qwen-managed-writer-token']);
handle = await journal.open({ sessionKey });
writerGeneration++;
} else {
assert.equal(req.headers['x-qwen-managed-writer-token'], writerToken);
}
const current = await scan();
const head = {
writerGeneration,
journalRevision: transactions.length,
committedSequence: current.committed,
lastCommitDigest: current.lastMarkerDigest,
activationEpoch: current.activation?.epoch ?? 0,
};
if (route === '/writers:acquire' || route === '/writers:renew') {
json({ ...head, leaseUntil: Date.now() + Number(body.leaseMillis) });
} else if (route === '/restore') {
json({
...head,
state: 'ACTIVE',
storageVersion: 1,
latestCheckpointResourceId: null,
compactedThroughRevision: 0,
recoveryStatus: 'READY',
recoveryDetailCode: null,
});
} else if (route === '/transactions:commit') {
assert(handle);
assert.equal(body.writerGeneration, writerGeneration);
assert.equal(body.expectedJournalRevision, transactions.length);
assert.equal(body.expectedCommittedSequence, current.committed);
for (const resource of body.resources as Array<
ManagedSessionDurableRef & { bytesBase64?: string }
>) {
if (resource.bytesBase64 !== undefined) {
const local = await resources.publish(
resource.kind,
Buffer.from(resource.bytesBase64, 'base64'),
);
assert.equal(local.digest, resource.digest);
assert.equal(local.byteLength, resource.byteLength);
refs.set(resource.resourceId, local);
} else {
assert(refs.has(resource.resourceId));
}
}
const bytes = Buffer.from(String(body.recordBytesBase64), 'base64');
assert.equal(
createHash('sha256').update(bytes).digest('hex'),
body.recordDigest,
);
await handle.appendTransaction(
bytes
.toString()
.trimEnd()
.split('\n')
.map((line) => JSON.parse(line) as unknown),
);
const committed = await scan();
assert.equal(committed.uncommitted, 0);
assert.equal(committed.committed, body.lastSequence);
assert.equal(committed.lastMarkerDigest, body.commitDigest);
transactions.push({
...body,
journalRevision: transactions.length + 1,
byteLength: bytes.length,
recordEncoding: 'identity',
});
json({
...body,
journalRevision: transactions.length,
committedSequence: committed.committed,
});
} else if (route === '/transactions') {
const after = Number(url.searchParams.get('afterRevision'));
const page = transactions.slice(after, after + 100);
json({
transactions: page,
nextRevision: after + page.length,
hasMore: after + page.length < transactions.length,
});
} else if (route.startsWith('/resources/')) {
const ref = refs.get(route.slice('/resources/'.length));
assert(ref);
res.setHeader('X-Qwen-Resource-Kind', ref.kind);
res.setHeader('X-Qwen-Resource-Schema-Version', ref.schemaVersion);
res.setHeader('X-Qwen-Resource-Digest', ref.digest);
res.end(await resources.read(ref));
} else if (route === '/writers:seal') {
assert(handle);
await handle.abort();
handle = undefined;
seals++;
json({ writerGeneration, state: 'SEALED' });
} else {
throw new Error(`Unexpected Store route: ${route}`);
}
})().catch((error: unknown) => {
failures.push(String(error));
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
error: { code: 'fixture_error', message: String(error) },
}),
);
});
pending.add(request);
void request.then(() => pending.delete(request));
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
assert(address && typeof address !== 'string');
return {
baseUrl: `http://127.0.0.1:${address.port}`,
sessionKey,
scan,
readResource: (ref: ManagedSessionDurableRef) => {
const local = refs.get(ref.resourceId);
assert(local);
return resources.read(local);
},
failures,
get seals() {
return seals;
},
async close() {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
await Promise.all(pending);
await handle?.abort();
handle = undefined;
await rm(root, { recursive: true, force: true, maxRetries: 5 });
},
};
}

View file

@ -0,0 +1,19 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['cli/hosted-harness-process.test.ts'],
testTimeout: 90_000,
hookTimeout: 15_000,
retry: 0,
fileParallelism: false,
pool: 'forks',
poolOptions: { forks: { minForks: 1, maxForks: 1 } },
},
});

View file

@ -65,7 +65,8 @@
"test:integration:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests",
"test:integration:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests",
"test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests",
"test:integration:no-ak:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 ./fake-openai-server.test.ts ./test-helper.test.ts ./chat-transcript-contract.test.ts ./skill-hooks-invocation-parity.test.ts ./skill-hooks-resume.test.ts ./qwen-live-m4-acp-call.test.ts ./qwen-live-m4-acp-permission.test.ts ./qwen-live-m4-acp-steering.test.ts ./qwen-live-m4-acp-multibackend.test.ts ./qwen-live-m1-call.test.ts ./qwen-live-m2-inject.test.ts ./qwen-live-m2-permission.test.ts ./qwen-live-m2-steering.test.ts ./cli/_prompt-latency-policy.test.ts ./cli/daemon-invocation-context.test.ts ./cli/headless-workflow-skill.test.ts ./cli/list_directory.test.ts ./cli/qwen-serve-routes.test.ts ./cli/qwen-serve-streaming.test.ts ./cli/qwen-serve-standalone-concurrency.test.ts ./sdk-typescript/abort-and-lifecycle.test.ts ./sdk-typescript/permission-control.test.ts ./sdk-typescript/sdk-mcp-server.test.ts ./sdk-typescript/subagents.test.ts ./sdk-typescript/system-control.test.ts ./sdk-typescript/tool-control.test.ts",
"test:integration:hosted:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --config ./vitest.hosted.config.ts",
"test:integration:no-ak:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 ./fake-openai-server.test.ts ./test-helper.test.ts ./chat-transcript-contract.test.ts ./skill-hooks-invocation-parity.test.ts ./skill-hooks-resume.test.ts ./qwen-live-m4-acp-call.test.ts ./qwen-live-m4-acp-permission.test.ts ./qwen-live-m4-acp-steering.test.ts ./qwen-live-m4-acp-multibackend.test.ts ./qwen-live-m1-call.test.ts ./qwen-live-m2-inject.test.ts ./qwen-live-m2-permission.test.ts ./qwen-live-m2-steering.test.ts ./cli/_prompt-latency-policy.test.ts ./cli/hosted-harness-process.test.ts ./cli/daemon-invocation-context.test.ts ./cli/headless-workflow-skill.test.ts ./cli/list_directory.test.ts ./cli/qwen-serve-routes.test.ts ./cli/qwen-serve-streaming.test.ts ./cli/qwen-serve-standalone-concurrency.test.ts ./sdk-typescript/abort-and-lifecycle.test.ts ./sdk-typescript/permission-control.test.ts ./sdk-typescript/sdk-mcp-server.test.ts ./sdk-typescript/subagents.test.ts ./sdk-typescript/system-control.test.ts ./sdk-typescript/tool-control.test.ts",
"test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 sdk-typescript",
"test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 sdk-typescript",
"test:sdk:python": "python3 -m pytest -c packages/sdk-python/pyproject.toml packages/sdk-python/tests -q",

View file

@ -109,6 +109,38 @@
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>${maven-failsafe-plugin.version}</version>
<configuration>
<excludes>
<exclude>**/HostedHarnessMySqlIT.java</exclude>
</excludes>
</configuration>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
<profile>
<id>hosted-harness-mysql</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>${maven-failsafe-plugin.version}</version>
<configuration>
<includes>
<include>**/HostedHarnessMySqlIT.java</include>
</includes>
<failIfNoTests>true</failIfNoTests>
<forkedProcessTimeoutInSeconds>180</forkedProcessTimeoutInSeconds>
</configuration>
<executions>
<execution>
<goals>

View file

@ -0,0 +1,497 @@
package com.alibaba.qwen.code.managedagent;
import static org.assertj.core.api.Assertions.assertThat;
import com.alibaba.qwen.code.daemon.CreateHarnessSession;
import com.alibaba.qwen.code.daemon.DaemonEvent;
import com.alibaba.qwen.code.daemon.GetHarnessTranscript;
import com.alibaba.qwen.code.daemon.HarnessEventStream;
import com.alibaba.qwen.code.daemon.HarnessSessionRef;
import com.alibaba.qwen.code.daemon.HostedHarnessClient;
import com.alibaba.qwen.code.daemon.LoadHarnessSession;
import com.alibaba.qwen.code.daemon.ManagedSessionStoreConnection;
import com.alibaba.qwen.code.daemon.PromptReceipt;
import com.alibaba.qwen.code.daemon.StreamHarnessEvents;
import com.alibaba.qwen.code.daemon.SubmitHarnessTurn;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BooleanSupplier;
import java.util.regex.Pattern;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
class HostedHarnessMySqlIT {
private static final String MODEL = "hosted-mysql-fixture";
private static final String TOKEN = "hosted-mysql-private-token";
private static final String DIGEST = "sha256:" + "a".repeat(64);
private static final Pattern LISTENING = Pattern.compile(
"listening on http://127\\.0\\.0\\.1:(\\d+)");
private final ObjectMapper json = new ObjectMapper();
private final List<JsonNode> modelRequests = new CopyOnWriteArrayList<>();
private final AtomicReference<Throwable> modelFailure = new AtomicReference<>();
private final CountDownLatch cancellationRequested = new CountDownLatch(1);
private final CountDownLatch releaseCancellation = new CountDownLatch(1);
private final StringBuilder output = new StringBuilder();
private final String tenant = "hosted-mysql-" + UUID.randomUUID();
private final String sessionId = UUID.randomUUID().toString();
private final ScheduledExecutorService watchdog =
Executors.newSingleThreadScheduledExecutor();
private final ExecutorService modelExecutor =
Executors.newCachedThreadPool();
@TempDir
private Path temporary;
private ServletWebServerApplicationContext spring;
private HttpServer model;
private Process child;
private Thread outputReader;
private JdbcTemplate jdbc;
private int harnessPort;
private int springPort;
private int modelPort;
@Test
@Timeout(120)
void javaClientUsesPackagedHarnessAndSpringStoreOnMySql() throws Exception {
Path cli = Path.of(required("qwen.cli.entry")).toAbsolutePath();
assertThat(cli).as("Build and bundle the packaged CLI first").isRegularFile();
DriverManagerDataSource dataSource = new DriverManagerDataSource(
required("mysql.url"), required("mysql.user"),
System.getProperty("mysql.password", ""));
Properties timeouts = new Properties();
timeouts.setProperty("connectTimeout", "5000");
timeouts.setProperty("socketTimeout", "10000");
dataSource.setConnectionProperties(timeouts);
jdbc = new JdbcTemplate(dataSource);
jdbc.setQueryTimeout(10);
Map<String, Object> database = jdbc.queryForMap(
"SELECT VERSION() AS version, @@version_comment AS engine");
System.out.println("HOSTED_MYSQL_DATABASE " + json.writeValueAsString(database));
assertThat(database.get("version").toString()).doesNotContainIgnoringCase("mariadb");
assertThat(database.get("engine").toString()).containsIgnoringCase("mysql");
spring = (ServletWebServerApplicationContext) new SpringApplicationBuilder(
ManagedAgentServerApplication.class).run(
"--server.address=127.0.0.1", "--server.port=0",
"--spring.datasource.url=" + required("mysql.url"),
"--spring.datasource.username=" + required("mysql.user"),
"--spring.datasource.password=" + System.getProperty("mysql.password", ""),
"--spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver",
"--spring.datasource.hikari.maximum-pool-size=3",
"--spring.datasource.hikari.connection-timeout=5000",
"--spring.datasource.hikari.data-source-properties.connectTimeout=5000",
"--spring.datasource.hikari.data-source-properties.socketTimeout=10000",
"--qwen.managed-agent.session-store.enabled=true",
"--qwen.managed-agent.harness.enabled=false",
"--qwen.managed-agent.runtime-broker.enabled=false");
springPort = spring.getWebServer().getPort();
startModel();
startHarness(cli);
try (HostedHarnessClient client = HostedHarnessClient.builder()
.baseUri(URI.create("http://127.0.0.1:" + harnessPort))
.bearerToken(TOKEN).capabilityDigest(DIGEST)
.connectTimeout(Duration.ofSeconds(3))
.requestTimeout(Duration.ofSeconds(10))
.heartbeatInterval(Duration.ZERO).build()) {
ManagedSessionStoreConnection store = ManagedSessionStoreConnection.builder()
.baseUri(URI.create("http://127.0.0.1:" + springPort))
.tenantId(tenant).workspaceId("hosted-mysql-workspace")
.writerId(client.capabilities().getBootId()).build();
HarnessSessionRef session = client.createSession(CreateHarnessSession.builder()
.harnessSessionId(sessionId).managedSessionStore(store).build());
assertThat(session.getHarnessSessionId()).isEqualTo(sessionId);
assertWriter("ACTIVE", 1);
SubmitHarnessTurn first = prompt(session, "MYSQL_FIRST");
PromptReceipt receipt = client.submitTurn(first);
List<JsonNode> events = awaitTurn(client, session, receipt);
assertCompleted(events, "MYSQL_REPLY_1");
assertThat(modelRequests.size()).isEqualTo(1);
PromptReceipt replay = client.submitTurn(first);
assertThat(replay.getPromptId()).isEqualTo(receipt.getPromptId());
assertThat(replay.getLastEventId()).isEqualTo(receipt.getLastEventId());
assertThat(replay.getEventEpoch()).isEqualTo(receipt.getEventEpoch());
assertThat(awaitTurn(client, session, replay)).isEqualTo(events);
assertThat(readEvents(client, session, events.getFirst().path("id").asLong(),
receipt.getEventEpoch(), events.size() - 1))
.isEqualTo(events.subList(1, events.size()));
assertThat(modelRequests.size()).isEqualTo(1);
client.closeSession(session);
assertWriter("SEALED", 1);
HarnessSessionRef loaded = client.loadSession(new LoadHarnessSession(sessionId, store));
assertThat(loaded.getHarnessClientId()).isNotEqualTo(session.getHarnessClientId());
assertWriter("ACTIVE", 2);
assertTranscriptPrefix(events, transcript(client, loaded, receipt.getLastEventId()));
PromptReceipt cancelled = client.submitTurn(prompt(loaded, "MYSQL_CANCEL"));
assertThat(cancellationRequested.await(15, TimeUnit.SECONDS))
.as("The cancellation must reach the actual model request").isTrue();
client.cancelTurn(loaded);
await(() -> !client.getStatus(loaded).hasActivePrompt(), 5, "Explicit cancellation");
List<JsonNode> cancelledEvents = awaitTurn(client, loaded, cancelled);
JsonNode cancellation = terminal(cancelledEvents);
assertThat(cancellation.path("type").asText()).isEqualTo("turn_complete");
assertThat(cancellation.path("data").path("stopReason").asText())
.isEqualTo("cancelled");
assertThat(cancelledEvents).noneMatch(event ->
"session_update".equals(event.path("type").asText()));
releaseCancellation.countDown();
assertCompleted(awaitTurn(client, loaded,
client.submitTurn(prompt(loaded, "MYSQL_AFTER_CANCEL"))), "MYSQL_REPLY_3");
assertCompleted(awaitTurn(client, loaded,
client.submitTurn(prompt(loaded, "MYSQL_NEXT"))), "MYSQL_REPLY_4");
assertThat(modelRequests.size()).isEqualTo(4);
assertHistory(2, "MYSQL_FIRST", "MYSQL_REPLY_1", "MYSQL_AFTER_CANCEL");
assertHistory(3, "MYSQL_FIRST", "MYSQL_REPLY_1", "MYSQL_AFTER_CANCEL",
"MYSQL_REPLY_3", "MYSQL_NEXT");
for (JsonNode request : modelRequests) {
assertThat(request.path("model").asText()).isEqualTo(MODEL);
assertThat(request.path("tools").isMissingNode()
|| request.path("tools").isArray() && request.path("tools").isEmpty()).isTrue();
}
List<JsonNode> committed = transcript(client, loaded, 0);
client.detachSession(loaded);
assertWriter("SEALED", 2);
HarnessSessionRef reattached = client.loadSession(new LoadHarnessSession(sessionId, store));
assertWriter("ACTIVE", 3);
assertTranscriptPrefix(committed, transcript(client, reattached, 0));
client.closeSession(reattached);
assertWriter("SEALED", 3);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM qwen_managed_session_journal_tx"
+ " WHERE tenant_id = ? AND session_id = ?", Long.class, tenant, sessionId))
.isGreaterThan(4);
assertThat(modelFailure.get()).isNull();
} catch (Exception | AssertionError failure) {
System.err.println("HOSTED_MYSQL_HARNESS_LOG\n" + logs());
throw failure;
}
}
private void startModel() throws IOException {
model = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
modelPort = model.getAddress().getPort();
model.setExecutor(modelExecutor);
model.createContext("/v1/chat/completions", this::respondToModel);
model.start();
}
private void respondToModel(HttpExchange exchange) throws IOException {
try {
JsonNode request = json.readTree(exchange.getRequestBody());
modelRequests.add(request);
if (request.path("messages").toString().contains("MYSQL_CANCEL")) {
cancellationRequested.countDown();
assertThat(releaseCancellation.await(30, TimeUnit.SECONDS)).isTrue();
return;
}
ObjectNode chunk = json.createObjectNode();
chunk.put("id", "mysql-fixture").put("object", "chat.completion.chunk")
.put("created", 0).put("model", MODEL);
ObjectNode choice = chunk.putArray("choices").addObject().put("index", 0);
choice.putObject("delta").put("role", "assistant")
.put("content", "MYSQL_REPLY_" + modelRequests.size());
choice.putNull("finish_reason");
String first = "data: " + json.writeValueAsString(chunk) + "\n\n";
choice.putObject("delta");
choice.put("finish_reason", "stop");
byte[] body = (first + "data: " + json.writeValueAsString(chunk)
+ "\n\ndata: [DONE]\n\n").getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
} catch (Exception | AssertionError failure) {
modelFailure.compareAndSet(null, failure);
} finally {
exchange.close();
}
}
private void startHarness(Path cli) throws Exception {
Path home = Files.createDirectory(temporary.resolve("home"));
Path config = Files.createDirectory(home.resolve(".qwen"));
Path workspace = Files.createDirectory(temporary.resolve("workspace"));
String modelUrl = "http://127.0.0.1:" + modelPort + "/v1";
Files.writeString(config.resolve("settings.json"), json.writeValueAsString(Map.of(
"security", Map.of("auth", Map.of("selectedType", "openai")),
"model", Map.of("name", MODEL), "telemetry", Map.of("enabled", false),
"modelProviders", Map.of("openai", List.of(Map.of("id", MODEL,
"envKey", "OPENAI_API_KEY", "baseUrl", modelUrl))))));
ProcessBuilder builder = new ProcessBuilder(System.getProperty("qwen.node", "node"),
cli.toString(), "serve", "--profile", "hosted-harness", "--http-bridge",
"--port", "0", "--hostname", "127.0.0.1", "--require-auth", "--no-web",
"--workspace", workspace.toString()).directory(workspace.toFile())
.redirectErrorStream(true);
Map<String, String> environment = builder.environment();
environment.clear();
for (String name : List.of("PATH", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT")) {
if (System.getenv(name) != null) {
environment.put(name, System.getenv(name));
}
}
environment.putAll(Map.of("HOME", home.toString(), "USERPROFILE", home.toString(),
"QWEN_HOME", config.toString(), "QWEN_RUNTIME_DIR", temporary.resolve("runtime").toString(),
"OPENAI_API_KEY", "fake-local-key", "OPENAI_BASE_URL", modelUrl,
"OPENAI_MODEL", MODEL, "QWEN_MODEL", MODEL,
"QWEN_SERVER_TOKEN", TOKEN, "QWEN_HOSTED_HARNESS_CAPABILITY_DIGEST", DIGEST));
environment.put("TMPDIR", temporary.toString());
environment.put("TMP", temporary.toString());
environment.put("TEMP", temporary.toString());
environment.put("QWEN_CODE_SYSTEM_SETTINGS_PATH", temporary.resolve("system-settings.json").toString());
environment.put("QWEN_CODE_SYSTEM_DEFAULTS_PATH", temporary.resolve("system-defaults.json").toString());
environment.put("QWEN_CODE_TRUSTED_FOLDERS_PATH", temporary.resolve("trusted-folders.json").toString());
child = builder.start();
watchdog.schedule(this::killChild, 100, TimeUnit.SECONDS);
outputReader = Thread.ofPlatform().daemon().start(() -> {
try (var reader = child.inputReader(StandardCharsets.UTF_8)) {
char[] buffer = new char[2048];
for (int count; (count = reader.read(buffer)) >= 0;) {
synchronized (output) {
output.append(buffer, 0, count);
if (output.length() > 16_384) {
output.delete(0, output.length() - 16_384);
}
}
}
} catch (IOException failure) {
synchronized (output) {
output.append(failure);
}
}
});
await(() -> {
assertThat(child.isAlive()).as("Hosted Harness exited:\n%s", logs()).isTrue();
var match = LISTENING.matcher(logs());
if (!match.find()) {
return false;
}
harnessPort = Integer.parseInt(match.group(1));
return true;
}, 30, "Hosted Harness startup");
await(this::harnessReady, 15, "Hosted Harness readiness");
}
private boolean harnessReady() {
HttpURLConnection connection = null;
try {
connection = (HttpURLConnection) URI.create("http://127.0.0.1:"
+ harnessPort + "/capabilities").toURL().openConnection();
connection.setRequestProperty("Authorization", "Bearer " + TOKEN);
connection.setConnectTimeout(1000);
connection.setReadTimeout(1000);
return connection.getResponseCode() == 200;
} catch (IOException unavailable) {
return false;
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
private SubmitHarnessTurn prompt(HarnessSessionRef session, String text) {
List<Map<String, Object>> content = List.of(Map.of("type", "text", "text", text));
return SubmitHarnessTurn.builder().session(session).promptId(UUID.randomUUID().toString())
.payloadDigest(SubmitHarnessTurn.computePayloadDigest(content))
.addContent(content.getFirst()).build();
}
private List<JsonNode> awaitTurn(HostedHarnessClient client, HarnessSessionRef session,
PromptReceipt receipt) throws Exception {
await(() -> !client.getStatus(session).hasActivePrompt(), 30, "Hosted turn settlement");
List<JsonNode> committed = transcript(client, session, receipt.getLastEventId());
assertThat(committed).isNotEmpty();
assertThat(terminal(committed).path("promptId").asText()).isEqualTo(receipt.getPromptId());
for (JsonNode event : committed) {
assertThat(event.path("data").path("sessionId").asText()).isEqualTo(sessionId);
if (event.has("promptId")) {
assertThat(event.path("promptId").asText()).isEqualTo(receipt.getPromptId());
}
}
List<JsonNode> streamed = readEvents(client, session, receipt.getLastEventId(),
receipt.getEventEpoch(), committed.size());
assertThat(streamed).isEqualTo(committed);
return streamed;
}
private List<JsonNode> transcript(HostedHarnessClient client, HarnessSessionRef session,
long after) {
var page = client.getTranscript(GetHarnessTranscript.builder().session(session)
.cursor(Long.toString(after)).limit(256).build());
assertThat(page.hasMore()).isFalse();
return page.getEvents().stream().map(value -> (JsonNode) json.valueToTree(value)).toList();
}
private List<JsonNode> readEvents(HostedHarnessClient client, HarnessSessionRef session,
long after, String epoch, int count) throws IOException {
// Stream.close cannot interrupt the client's synchronized blocking read.
var deadline = watchdog.schedule(this::killChild, 10, TimeUnit.SECONDS);
try (HarnessEventStream stream = client.streamEvents(StreamHarnessEvents.builder()
.session(session).lastEventId(after).eventEpoch(epoch).build())) {
List<JsonNode> result = new ArrayList<>();
for (int i = 0; i < count; i++) {
DaemonEvent event = stream.next();
assertThat(event).isNotNull();
ObjectNode envelope = json.createObjectNode().put("v", event.getVersion())
.put("id", event.getId()).put("type", event.getType());
envelope.set("data", json.valueToTree(event.getData()));
if (event.getPromptId() != null) {
envelope.put("promptId", event.getPromptId());
}
result.add(json.readTree(envelope.toString()));
}
return result;
} finally {
deadline.cancel(false);
}
}
private void assertCompleted(List<JsonNode> events, String text) {
JsonNode completed = terminal(events);
assertThat(completed.path("type").asText()).isEqualTo("turn_complete");
assertThat(completed.path("data").path("stopReason").asText()).isEqualTo("end_turn");
assertThat(events.stream().filter(event -> "session_update".equals(event.path("type").asText()))
.map(event -> event.path("data").path("update").path("content").path("text").asText()))
.containsExactly(text);
assertThat(events.stream().filter(event -> "session_update".equals(event.path("type").asText()))
.mapToLong(event -> event.path("id").asLong()).max().orElseThrow())
.isLessThan(completed.path("id").asLong());
}
private void assertHistory(int index, String... retained) {
String history = modelRequests.get(index).path("messages").toString();
for (String text : retained) {
assertThat(history.contains(text))
.as("Model request %s after cancellation must retain %s", index + 1, text).isTrue();
}
assertThat(history.contains("MYSQL_CANCEL"))
.as("Model request %s must not replay the cancelled prompt", index + 1).isFalse();
}
private JsonNode terminal(List<JsonNode> events) {
List<JsonNode> terminals = events.stream().filter(event ->
"turn_complete".equals(event.path("type").asText())
|| "turn_error".equals(event.path("type").asText())).toList();
assertThat(terminals).hasSize(1);
return terminals.getFirst();
}
private void assertTranscriptPrefix(List<JsonNode> before, List<JsonNode> after) {
assertThat(after).startsWith(before.toArray(JsonNode[]::new));
for (int index = before.size(); index < after.size(); index++) {
JsonNode event = after.get(index);
assertThat(event.path("type").asText()).isEqualTo("managed_journal_event");
assertThat(event.path("promptId").isMissingNode()).isTrue();
assertThat(event.path("data")).isEqualTo(json.valueToTree(Map.of("sessionId", sessionId)));
assertThat(event.path("id").asLong()).isEqualTo(after.get(index - 1).path("id").asLong() + 1);
}
}
private void assertWriter(String state, long generation) {
Map<String, Object> head = jdbc.queryForMap("SELECT state, writer_generation"
+ " FROM qwen_managed_session_journal_head WHERE tenant_id = ? AND session_id = ?",
tenant, sessionId);
assertThat(head.get("state")).isEqualTo(state);
assertThat(((Number) head.get("writer_generation")).longValue()).isEqualTo(generation);
}
private void await(BooleanSupplier predicate, int seconds, String label) throws InterruptedException {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds);
while (System.nanoTime() < deadline) {
if (predicate.getAsBoolean()) {
return;
}
Thread.sleep(50);
}
throw new AssertionError(label + " timed out\n" + logs());
}
private String logs() {
synchronized (output) {
return output.toString();
}
}
private void killChild() {
child.descendants().forEach(ProcessHandle::destroyForcibly);
child.destroyForcibly();
}
@AfterEach
void cleanup() throws Exception {
releaseCancellation.countDown();
watchdog.shutdownNow();
try {
if (child != null) {
child.descendants().forEach(ProcessHandle::destroyForcibly);
child.destroy();
if (!child.waitFor(5, TimeUnit.SECONDS)) {
child.destroyForcibly();
}
assertThat(child.waitFor(5, TimeUnit.SECONDS)).as("Hosted child must exit").isTrue();
}
} finally {
try {
if (model != null) {
model.stop(0);
}
} finally {
modelExecutor.shutdownNow();
if (spring != null) {
spring.close();
}
if (outputReader != null) {
outputReader.join(1000);
}
}
}
for (int port : List.of(harnessPort, modelPort, springPort)) {
if (port != 0) {
try (ServerSocket socket = new ServerSocket()) {
socket.setReuseAddress(true);
socket.bind(new InetSocketAddress("127.0.0.1", port));
}
}
}
}
private static String required(String name) {
String value = System.getProperty(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException("Required integration prerequisite: -D" + name);
}
return value;
}
}

View file

@ -0,0 +1,124 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { readFileSync } from 'node:fs';
import { parse } from 'yaml';
import { describe, expect, it } from 'vitest';
import { classifyChangedFiles } from '../../.github/scripts/ci/classify-profile.mjs';
const read = (file) =>
readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8');
const ci = parse(read('.github/workflows/ci.yml'));
const java = parse(read('.github/workflows/sdk-java.yml'));
const pkg = JSON.parse(read('package.json'));
const focused = 'test:integration:hosted:sandbox:none';
describe('Hosted real-process gates', () => {
it('runs the packaged suite on relevant PRs without credentials or optional prerequisites', () => {
const job = ci.jobs.integration_no_ak;
expect(ci.on).toHaveProperty('pull_request');
expect(job.if).toContain("github.event_name == 'pull_request'");
expect(job.if).toContain("github.event_name == 'merge_group'");
const install = job.steps.find(
(step) => step.name === 'Install Dependencies',
);
expect(install.run).toContain('corepack pnpm install --frozen-lockfile');
expect(install.env?.QWEN_SKIP_PREPARE).toBeUndefined();
expect(pkg.scripts.prepare).toBe('node scripts/prepare.js');
const prepare = read('scripts/prepare.js');
expect(prepare).toContain("run('npm', ['run', 'build'])");
expect(prepare).toContain("run('npm', ['run', 'bundle'])");
const run = job.steps.find(
(step) => step.name === 'Run required no-AK integration gate',
);
expect(run.if).toContain("ci_profile == 'full'");
expect(run.run).toContain('npm run test:integration:no-ak:sandbox:none');
expect(run['continue-on-error']).toBeUndefined();
expect(run.run).not.toContain('|| true');
expect(pkg.scripts['test:integration:no-ak:sandbox:none']).toContain(
'./cli/hosted-harness-process.test.ts',
);
expect(pkg.scripts[focused]).toContain(
'--config ./vitest.hosted.config.ts',
);
for (const file of [
'integration-tests/cli/hosted-harness-process.test.ts',
'integration-tests/helpers/hosted-session-store.ts',
'packages/cli/src/serve/hosted-harness-model.ts',
'.github/workflows/ci.yml',
]) {
expect(classifyChangedFiles([file])).toBe('full');
}
});
it.each(['test_macos', 'test_windows'])(
'runs portable startup and cleanup on %s',
(name) => {
const steps = ci.jobs[name].steps;
const index = steps.findIndex(
(step) => step.name === 'Hosted portable process smoke',
);
expect(index).toBeGreaterThan(
steps.findIndex((step) =>
/Install dependencies/i.test(step.name ?? ''),
),
);
expect(steps[index].run).toBe(
`npm run ${focused} -- -t 'portable startup'`,
);
expect(steps[index]['timeout-minutes']).toBe(5);
expect(steps[index]['continue-on-error']).toBeUndefined();
},
);
it('keeps real MySQL separate from the existing MariaDB slice and fails on missing tests', () => {
const job = java.jobs['hosted-harness-mysql'];
expect(job.services.mysql.image).toBe('mysql:8.4.6');
expect(job.services.mysql.ports).toEqual(['3306/tcp']);
expect(job.if).toBeUndefined();
expect(java.jobs['mysql-integration'].services.mariadb.image).toMatch(
/^mariadb:/,
);
for (const event of ['pull_request', 'push']) {
for (const path of [
'packages/sdk-java/**',
'packages/cli/src/serve/**',
'packages/core/src/managed-runtime/**',
'packages/core/src/config/**',
'packages/core/src/core/**',
'pnpm-lock.yaml',
]) {
expect(java.on[event].paths).toContain(path);
}
}
const run = job.steps.find(
(step) => step.name === 'Verify Hosted Java, Spring and MySQL processes',
);
expect(run.run).toContain(
'-Phosted-harness-mysql -Dit.test=HostedHarnessMySqlIT',
);
expect(run.run).toContain(
'-Dqwen.cli.entry="${GITHUB_WORKSPACE}/dist/cli.js"',
);
expect(run.run).toContain('verify checkstyle:check');
expect(run.run).not.toContain('skip');
expect(run['continue-on-error']).toBeUndefined();
const pom = read('packages/sdk-java/managed-agent-server/pom.xml');
const profile = pom.split('<id>hosted-harness-mysql</id>')[1];
expect(profile).toContain(
'<include>**/HostedHarnessMySqlIT.java</include>',
);
expect(profile).toContain('<failIfNoTests>true</failIfNoTests>');
expect(pom.split('<id>hosted-harness-mysql</id>')[0]).toContain(
'<exclude>**/HostedHarnessMySqlIT.java</exclude>',
);
const upload = job.steps.find(
(step) => step.name === 'Upload Hosted process reports',
);
expect(upload.if).toBe('always()');
expect(upload.with.path).toContain('failsafe-reports');
});
});

View file

@ -184,6 +184,7 @@ describe('no-AK integration CI wiring', () => {
'./qwen-live-m2-permission.test.ts',
'./qwen-live-m2-steering.test.ts',
'./cli/_prompt-latency-policy.test.ts',
'./cli/hosted-harness-process.test.ts',
'./cli/daemon-invocation-context.test.ts',
'./cli/headless-workflow-skill.test.ts',
'./cli/list_directory.test.ts',