Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/guides/run-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -26,7 +26,7 @@ Options:
| `--title <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

Expand Down
6 changes: 3 additions & 3 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
27 changes: 17 additions & 10 deletions packages/cli/src/commands/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -297,36 +302,38 @@ 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);
}
}

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);
}
}

Expand Down
10 changes: 6 additions & 4 deletions packages/cli/src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand Down
14 changes: 12 additions & 2 deletions packages/cli/tests/unit/cli-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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("");
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/tests/unit/session-event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 3 additions & 5 deletions scripts/release/channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand Down
3 changes: 2 additions & 1 deletion scripts/release/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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" };
Expand Down
13 changes: 12 additions & 1 deletion scripts/release/consumer-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Expand Down
7 changes: 6 additions & 1 deletion scripts/release/consumer-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,14 +122,19 @@ 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 };
if (manifest.version !== version) {
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");
Expand Down
11 changes: 7 additions & 4 deletions scripts/release/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
23 changes: 13 additions & 10 deletions scripts/release/snapshot.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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);
Expand Down