diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d19d05..d55f7ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,7 +62,6 @@ jobs: if: github.event_name == 'workflow_dispatch' && inputs.channel == 'beta' run: >- bun run scripts/release/snapshot.ts apply - --run-id "${{ github.run_id }}" --sha "${{ github.sha }}" - name: Validate channel, branch, and package versions diff --git a/docs/getting-started.md b/docs/getting-started.md index 3d927f6..4a2bbf9 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -131,7 +131,7 @@ $ agents apply -y ## Run a session -A **session** is a runtime conversation started from a managed agent. `agents session run` creates a session, sends a prompt, and streams the response: +A **session** is a runtime conversation started from a managed agent. `agents session run` creates a session, sends a prompt, and polls until the response completes. Add `--stream` to stream live events over SSE: ```bash agents session run "Summarize the repo structure" --agent assistant diff --git a/docs/getting-started.zh-CN.md b/docs/getting-started.zh-CN.md index 9fa21c6..d1c581d 100644 --- a/docs/getting-started.zh-CN.md +++ b/docs/getting-started.zh-CN.md @@ -131,7 +131,7 @@ $ agents apply -y ## 运行 session -**session** 是从一个已托管 agent 启动的运行时对话。`agents session run` 创建 session、发送 prompt 并流式返回: +**session** 是从一个已托管 agent 启动的运行时对话。`agents session run` 创建 session、发送 prompt,并默认轮询至响应完成;如需通过 SSE 实时返回事件,请添加 `--stream`: ```bash agents session run "Summarize the repo structure" --agent assistant diff --git a/docs/guides/run-sessions.md b/docs/guides/run-sessions.md index 9d1d5c3..710b981 100644 --- a/docs/guides/run-sessions.md +++ b/docs/guides/run-sessions.md @@ -12,7 +12,7 @@ A **session** is a runtime conversation started from a managed agent. Sessions a agents session run "Summarize the repo structure" --agent assistant ``` -`session run` creates a session, sends the prompt, and streams the response. When only one agent is configured, `--agent` is auto-detected. For a Qoder agent with `delivery.qoder.type: forward`, Identity is optional: without one OpenCMA looks up the enabled Identity whose `external_id` is `__qca_admin_identity__` and sends its real `idn_...` id. Configure `defaults.session.qoder.identity_id` or pass `--identity-id` to select an existing business Identity. OpenCMA never creates or updates Identity resources implicitly. +`session run` creates a session, sends the prompt, and polls until the response completes. Pass `--stream` to receive live events over SSE. When only one agent is configured, `--agent` is auto-detected. For a Qoder agent with `delivery.qoder.type: forward`, Identity is optional: without one OpenCMA looks up the enabled Identity whose `external_id` is `__qca_admin_identity__` and sends its real `idn_...` id. Configure `defaults.session.qoder.identity_id` or pass `--identity-id` to select an existing business Identity. OpenCMA never creates or updates Identity resources implicitly. Options: @@ -26,7 +26,7 @@ Options: | `--title ` | Session title. | | `--provider <name>` | Target provider (required for multi-provider agents). | | `--json` | Output events as JSONL. | -| `--no-stream` | Use polling instead of SSE streaming. | +| `--stream` | Use SSE streaming instead of the default polling mode. | ## Continue an existing session diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 0a05ce7..5723136 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -106,12 +106,12 @@ Manage runtime agent sessions. | `session create [agent-name]` | Create a new session. | | `session list` | List sessions from the provider. | | `session get <session-id>` | Get details of a session. | -| `session run <prompt-or-agent> [prompt]` | Create a session, send a message, and stream the response. | -| `session send <session-id> <message>` | Send a message to an existing session and stream the response. | +| `session run <prompt-or-agent> [prompt]` | Create a session, send a message, and poll until the response completes. | +| `session send <session-id> <message>` | Send a message to an existing session and poll until the response completes. | | `session events <session-id>` | List event history for a session. | | `session delete <session-id>` | Delete a session. | -`session create` / `session run` accept `--agent`, `--identity-id`, `--environment`, `--vault`, `--memory-stores`, `--title`, and `--provider`. `--identity-id` selects an existing Qoder Forward Identity and overrides `defaults.session.qoder.identity_id`; when neither is provided, OpenCMA resolves the Identity whose `external_id` is `__qca_admin_identity__`. OpenCMA never creates or updates an Identity. `session run` and `session send` accept `--json` (JSONL output) and `--no-stream` (polling instead of SSE). `session list` accepts `--agent` and `--all`; `session events` accepts `--limit`, `--all`, `--json`. +`session create` / `session run` accept `--agent`, `--identity-id`, `--environment`, `--vault`, `--memory-stores`, `--title`, and `--provider`. `--identity-id` selects an existing Qoder Forward Identity and overrides `defaults.session.qoder.identity_id`; when neither is provided, OpenCMA resolves the Identity whose `external_id` is `__qca_admin_identity__`. OpenCMA never creates or updates an Identity. `session run` and `session send` use polling by default and accept `--stream` to opt into SSE streaming, plus `--json` for JSON output. `session list` accepts `--agent` and `--all`; `session events` accepts `--limit`, `--all`, `--json`. ## `agents deployment` diff --git a/packages/cli/src/commands/session.ts b/packages/cli/src/commands/session.ts index a5e8699..d852540 100644 --- a/packages/cli/src/commands/session.ts +++ b/packages/cli/src/commands/session.ts @@ -267,9 +267,14 @@ interface SessionRunOpts { title?: string; provider?: string; json?: boolean; + stream?: boolean; noStream?: boolean; } +export function shouldStreamSession(options: { stream?: boolean; noStream?: boolean }): boolean { + return options.stream === true && options.noStream !== true; +} + export async function sessionRunCommand( promptOrAgent: string, promptOrOptions?: string | SessionRunOpts, @@ -297,18 +302,19 @@ export async function sessionRunCommand( }; const ctx = await buildCliRuntime(options.file); - const run = options.noStream - ? await startSessionRunPolling(ctx, prompt, runOptions) - : await startSessionRun(ctx, prompt, runOptions); + const stream = shouldStreamSession(options); + const run = stream + ? await startSessionRun(ctx, prompt, runOptions) + : await startSessionRunPolling(ctx, prompt, runOptions); const session = run.session; if (!options.json) { log.success(`Session created: ${chalk.bold(session.id)}`); } - if (options.noStream) { - renderCollectedEvents(run as Awaited<ReturnType<typeof startSessionRunPolling>>, !!options.json); - } else { + if (stream) { await streamAndRender((run as Awaited<ReturnType<typeof startSessionRun>>).events, !!options.json); + } else { + renderCollectedEvents(run as Awaited<ReturnType<typeof startSessionRunPolling>>, !!options.json); } } @@ -316,17 +322,18 @@ interface SessionSendOpts { file: string; provider?: string; json?: boolean; + stream?: boolean; noStream?: boolean; } export async function sessionSendCommand(sessionId: string, message: string, options: SessionSendOpts) { const ctx = await buildCliRuntime(options.file); - if (options.noStream) { - const result = await sendSessionMessagePolling(ctx, sessionId, message, { provider: options.provider }); - renderCollectedEvents(result, !!options.json); - } else { + if (shouldStreamSession(options)) { const events = await sendSessionMessageStreaming(ctx, sessionId, message, { provider: options.provider }); await streamAndRender(events, !!options.json); + } else { + const result = await sendSessionMessagePolling(ctx, sessionId, message, { provider: options.provider }); + renderCollectedEvents(result, !!options.json); } } diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 99fd60b..818dd03 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -229,7 +229,7 @@ sessionCmd sessionCmd .command("run <prompt-or-agent> [prompt]") - .description("Create a session, send a message, and stream the response") + .description("Create a session, send a message, and wait for the response") .addOption(configFileOption()) .option("--agent <name>", "Agent name (auto-detected when only one agent is configured)") .option("--identity-id <id>", "Override the configured Qoder Forward Identity") @@ -242,16 +242,18 @@ sessionCmd .option("--title <title>", "Session title") .addOption(providerOption("Target provider")) .option("--json", "Output events as JSONL") - .option("--no-stream", "Use polling instead of SSE streaming") + .addOption(new Option("--stream", "Stream events over SSE instead of polling").conflicts("noStream")) + .addOption(new Option("--no-stream", "Use polling (deprecated; polling is now the default)").hideHelp()) .action(withResolvedConfigFile(sessionRunCommand)); sessionCmd .command("send <session-id> <message>") - .description("Send a message to an existing session and stream the response") + .description("Send a message to an existing session and wait for the response") .addOption(configFileOption()) .addOption(providerOption("Target provider")) .option("--json", "Output events as JSONL") - .option("--no-stream", "Use polling instead of SSE streaming") + .addOption(new Option("--stream", "Stream events over SSE instead of polling").conflicts("noStream")) + .addOption(new Option("--no-stream", "Use polling (deprecated; polling is now the default)").hideHelp()) .action(withResolvedConfigFile(sessionSendCommand)); sessionCmd diff --git a/packages/cli/tests/unit/cli-contracts.test.ts b/packages/cli/tests/unit/cli-contracts.test.ts index f3358bb..94cd495 100644 --- a/packages/cli/tests/unit/cli-contracts.test.ts +++ b/packages/cli/tests/unit/cli-contracts.test.ts @@ -172,6 +172,16 @@ test("session run exposes an explicit Forward identity override", async () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("--identity-id <id>"); + expect(result.stdout).toContain("--stream"); + expect(result.stdout).not.toContain("--no-stream"); +}); + +test("session send exposes explicit streaming as an opt-in", async () => { + const result = await runAgents(["session", "send", "--help"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("--stream"); + expect(result.stdout).not.toContain("--no-stream"); }); test("global --file before plan selects the config file", async () => { @@ -528,11 +538,11 @@ test("json-mode user errors are written to stderr with empty stdout", async () = expect(result.stderr).toContain("File not found"); }); -test("session run reports missing applied resources through the core runtime", async () => { +test("session run defaults to polling and reports missing applied resources through the core runtime", async () => { const dir = await makeTempDir(); const configPath = await writeConfig(dir); - const result = await runAgents(["session", "run", "assistant", "hello", "--file", configPath, "--no-stream"]); + const result = await runAgents(["session", "run", "assistant", "hello", "--file", configPath]); expect(result.exitCode).toBe(1); expect(result.stdout).toBe(""); diff --git a/packages/cli/tests/unit/session-event.test.ts b/packages/cli/tests/unit/session-event.test.ts index b90bbbc..5b958cb 100644 --- a/packages/cli/tests/unit/session-event.test.ts +++ b/packages/cli/tests/unit/session-event.test.ts @@ -4,8 +4,20 @@ import { formatTimestamp, isTerminalSessionStatus, shouldRenderLiveEvent, + shouldStreamSession, } from "../../src/commands/session.ts"; +describe("session transport selection", () => { + test("uses polling by default for every provider", () => { + expect(shouldStreamSession({})).toBe(false); + expect(shouldStreamSession({ noStream: true })).toBe(false); + }); + + test("uses SSE only when explicitly requested", () => { + expect(shouldStreamSession({ stream: true })).toBe(true); + }); +}); + describe("session live rendering", () => { test("suppresses user-message echoes", () => { expect( diff --git a/scripts/release/channel.test.ts b/scripts/release/channel.test.ts index f96fd3f..e52b23b 100644 --- a/scripts/release/channel.test.ts +++ b/scripts/release/channel.test.ts @@ -13,14 +13,12 @@ describe("release channel guard", () => { }); test("accepts deterministic beta snapshots only on main", () => { - expect(validateReleaseIdentity("beta", "main", "0.0.0-beta.run-123456789.sha-a1b2c3d")).toEqual({ + expect(validateReleaseIdentity("beta", "main", "1.2.3-beta-a1b2c3d-20260720")).toEqual({ channel: "beta", - version: "0.0.0-beta.run-123456789.sha-a1b2c3d", + version: "1.2.3-beta-a1b2c3d-20260720", distTag: "beta", }); - expect(() => validateReleaseIdentity("beta", "feature/test", "0.0.0-beta.run-123456789.sha-a1b2c3d")).toThrow( - "main", - ); + expect(() => validateReleaseIdentity("beta", "feature/test", "1.2.3-beta-a1b2c3d-20260720")).toThrow("main"); expect(() => validateReleaseIdentity("beta", "main", "1.2.3-beta.0")).toThrow("unexpected format"); }); diff --git a/scripts/release/channel.ts b/scripts/release/channel.ts index 899362b..a41ac4c 100644 --- a/scripts/release/channel.ts +++ b/scripts/release/channel.ts @@ -12,6 +12,7 @@ export interface ReleaseIdentity { const root = resolve(import.meta.dirname, "../.."); const releasePackages = ["sdk", "playground", "cli"] as const; const stableVersion = /^[0-9]+\.[0-9]+\.[0-9]+$/; +export const betaSnapshotVersion = /^[0-9]+\.[0-9]+\.[0-9]+-beta-[0-9a-f]{7}-\d{8}$/; export function releasePackageVersions(): string[] { return releasePackages.map((pkg) => { @@ -38,7 +39,7 @@ export function validateReleaseIdentity(channel: ReleaseChannel, ref: string, ve } if (ref !== "main") throw new Error(`beta snapshots must run from main, not ${ref}`); - if (!/^0\.0\.0-beta\.run-[1-9]\d*\.sha-[0-9a-f]{7}$/.test(version)) { + if (!betaSnapshotVersion.test(version)) { throw new Error(`beta snapshot version has an unexpected format: ${version}`); } return { channel, version, distTag: "beta" }; diff --git a/scripts/release/consumer-smoke.test.ts b/scripts/release/consumer-smoke.test.ts index b6038ad..301c6ac 100644 --- a/scripts/release/consumer-smoke.test.ts +++ b/scripts/release/consumer-smoke.test.ts @@ -1,7 +1,18 @@ import { describe, expect, test } from "bun:test"; -import { commonRegistryVersion, packageName, registryVersion, waitForRegistry } from "./consumer-smoke.ts"; +import { + commonRegistryVersion, + normalizeLineEndings, + packageName, + registryVersion, + waitForRegistry, +} from "./consumer-smoke.ts"; describe("published consumer smoke", () => { + test("compares published files across Windows and Unix line endings", () => { + expect(normalizeLineEndings("first\r\nsecond\r\n")).toBe("first\nsecond\n"); + expect(normalizeLineEndings("first\nsecond\n")).toBe("first\nsecond\n"); + }); + test("builds canonical package names", () => { expect(packageName("sdk")).toBe("@openagentpack/sdk"); }); diff --git a/scripts/release/consumer-smoke.ts b/scripts/release/consumer-smoke.ts index ade4d79..0991fcb 100644 --- a/scripts/release/consumer-smoke.ts +++ b/scripts/release/consumer-smoke.ts @@ -122,6 +122,10 @@ function installPackage(directory: string, name: string, version: string): void ); } +export function normalizeLineEndings(value: string): string { + return value.replace(/\r\n/g, "\n"); +} + function assertInstalledPackage(directory: string, name: string, version: string): void { const packageDirectory = join(directory, "node_modules", ...name.split("/")); const manifest = JSON.parse(readFileSync(join(packageDirectory, "package.json"), "utf8")) as { version?: string }; @@ -129,7 +133,8 @@ function assertInstalledPackage(directory: string, name: string, version: string throw new Error(`${name} version mismatch: expected ${version}, received ${manifest.version ?? "missing"}`); } const installedLicense = readFileSync(join(packageDirectory, "LICENSE"), "utf8"); - if (installedLicense !== readFileSync(join(root, "LICENSE"), "utf8")) { + const repositoryLicense = readFileSync(join(root, "LICENSE"), "utf8"); + if (normalizeLineEndings(installedLicense) !== normalizeLineEndings(repositoryLicense)) { throw new Error(`${name} is missing the repository license`); } if (name === "@openagentpack/sdk") readFileSync(join(packageDirectory, "NOTICE"), "utf8"); diff --git a/scripts/release/snapshot.test.ts b/scripts/release/snapshot.test.ts index e6a6544..6522440 100644 --- a/scripts/release/snapshot.test.ts +++ b/scripts/release/snapshot.test.ts @@ -2,12 +2,15 @@ import { describe, expect, test } from "bun:test"; import { snapshotVersion } from "./snapshot.ts"; describe("beta snapshot version", () => { - test("is deterministic and valid SemVer", () => { - expect(snapshotVersion("123456789", "A1B2C3D99887766")).toBe("0.0.0-beta.run-123456789.sha-a1b2c3d"); + test("uses the workspace base version, short SHA, and UTC date", () => { + expect(snapshotVersion("1.2.3", "A1B2C3D99887766", new Date("2026-07-20T23:59:59Z"))).toBe( + "1.2.3-beta-a1b2c3d-20260720", + ); }); test("rejects ambiguous inputs", () => { - expect(() => snapshotVersion("0", "a1b2c3d")).toThrow("run ID"); - expect(() => snapshotVersion("123456789", "not-a-sha")).toThrow("sha"); + expect(() => snapshotVersion("1.2.3-beta.0", "a1b2c3d")).toThrow("base version"); + expect(() => snapshotVersion("1.2.3", "not-a-sha")).toThrow("sha"); + expect(() => snapshotVersion("1.2.3", "a1b2c3d", new Date("invalid"))).toThrow("date"); }); }); diff --git a/scripts/release/snapshot.ts b/scripts/release/snapshot.ts index f0579b4..8e3a3f6 100644 --- a/scripts/release/snapshot.ts +++ b/scripts/release/snapshot.ts @@ -1,13 +1,18 @@ import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; +import { betaSnapshotVersion, commonReleaseVersion, releasePackageVersions } from "./channel.ts"; const root = resolve(import.meta.dirname, "../.."); const releasePackages = ["sdk", "playground", "cli"] as const; -export function snapshotVersion(runId: string, sha: string): string { - if (!/^[1-9]\d*$/.test(runId)) throw new Error("run ID must be a positive integer"); +export function snapshotVersion(baseVersion: string, sha: string, date = new Date()): string { + if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(baseVersion)) { + throw new Error(`base version must be X.Y.Z; found ${baseVersion}`); + } if (!/^[0-9a-f]{7,40}$/i.test(sha)) throw new Error("sha must contain 7 to 40 hexadecimal characters"); - return `0.0.0-beta.run-${runId}.sha-${sha.slice(0, 7).toLowerCase()}`; + if (Number.isNaN(date.getTime())) throw new Error("date must be valid"); + const utcDate = date.toISOString().slice(0, 10).replaceAll("-", ""); + return `${baseVersion}-beta-${sha.slice(0, 7).toLowerCase()}-${utcDate}`; } export function applySnapshotVersion(version: string): void { @@ -26,16 +31,14 @@ function option(name: string): string | undefined { function main(): void { if (process.argv[2] !== "apply") { - throw new Error( - "usage: snapshot.ts apply (--version <snapshot-version> | --run-id <github-run-id> --sha <git-sha>)", - ); + throw new Error("usage: snapshot.ts apply (--version <snapshot-version> | --sha <git-sha>)"); } const suppliedVersion = option("version"); - const runId = option("run-id"); const sha = option("sha"); - const version = suppliedVersion ?? (runId && sha ? snapshotVersion(runId, sha) : undefined); - if (!version) throw new Error("provide --version or both --run-id and --sha"); - if (!/^0\.0\.0-beta\.run-[1-9]\d*\.sha-[0-9a-f]{7}$/.test(version)) { + const version = + suppliedVersion ?? (sha ? snapshotVersion(commonReleaseVersion(releasePackageVersions()), sha) : undefined); + if (!version) throw new Error("provide --version or --sha"); + if (!betaSnapshotVersion.test(version)) { throw new Error(`invalid beta snapshot version: ${version}`); } applySnapshotVersion(version);