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 ` | 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 ` | Get details of a session. |
-| `session run [prompt]` | Create a session, send a message, and stream the response. |
-| `session send ` | Send a message to an existing session and stream the response. |
+| `session run [prompt]` | Create a session, send a message, and poll until the response completes. |
+| `session send ` | Send a message to an existing session and poll until the response completes. |
| `session events ` | List event history for a session. |
| `session delete ` | 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>, !!options.json);
- } else {
+ if (stream) {
await streamAndRender((run as Awaited>).events, !!options.json);
+ } else {
+ renderCollectedEvents(run as Awaited>, !!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]")
- .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 ", "Agent name (auto-detected when only one agent is configured)")
.option("--identity-id ", "Override the configured Qoder Forward Identity")
@@ -242,16 +242,18 @@ sessionCmd
.option("--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 ")
- .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 ");
+ 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 | --run-id --sha )",
- );
+ throw new Error("usage: snapshot.ts apply (--version | --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);