From fd50dd79116a71d2574fbc403eaa5e08a860fb05 Mon Sep 17 00:00:00 2001 From: heimanba <371510756@qq.com> Date: Mon, 20 Jul 2026 17:29:31 +0800 Subject: [PATCH 1/2] Add Qoder Forward template support Change-Id: I0e941a1e2ad3c761b2359a19c5c4162e09fc98c7 --- .gitignore | 1 + CONTEXT.md | 8 +- apps/server/openapi.json | 4 + apps/webui/src/lib/api/generated/schema.d.ts | 40 +- docs/guides/deploy-to-qoder.md | 49 ++ docs/guides/run-sessions.md | 5 +- docs/reference/cli.md | 2 +- docs/reference/configuration.md | 6 +- packages/cli/src/commands/session.ts | 4 + packages/cli/src/program.ts | 2 + packages/cli/tests/unit/cli-contracts.test.ts | 7 + .../internal/core/agent-materialization.ts | 17 + .../sdk/src/internal/core/agent-runtime.ts | 10 +- .../sdk/src/internal/core/destroy-runtime.ts | 5 + .../sdk/src/internal/core/resource-runtime.ts | 9 +- .../sdk/src/internal/core/session-runtime.ts | 17 +- .../sdk/src/internal/core/validate-config.ts | 43 ++ .../sdk/src/internal/executor/executor.ts | 30 +- .../sdk/src/internal/executor/resolver.ts | 26 +- packages/sdk/src/internal/graph/dependency.ts | 12 +- packages/sdk/src/internal/parser/schema.ts | 14 + .../sdk/src/internal/planner/declaration.ts | 1 + packages/sdk/src/internal/planner/hasher.ts | 44 ++ packages/sdk/src/internal/planner/planner.ts | 13 +- packages/sdk/src/internal/planner/refresh.ts | 12 +- .../sdk/src/internal/providers/ark/adapter.ts | 2 + .../internal/providers/ark/capabilities.ts | 1 + .../sdk/src/internal/providers/ark/mapper.ts | 4 +- .../src/internal/providers/bailian/adapter.ts | 1 + .../providers/bailian/capabilities.ts | 1 + .../src/internal/providers/bailian/mapper.ts | 4 +- .../sdk/src/internal/providers/base-client.ts | 4 +- .../src/internal/providers/capabilities.ts | 2 + .../src/internal/providers/claude/adapter.ts | 3 +- .../internal/providers/claude/capabilities.ts | 1 + .../src/internal/providers/claude/mapper.ts | 4 +- .../sdk/src/internal/providers/interface.ts | 12 + .../src/internal/providers/qoder/adapter.ts | 299 ++++++++++- .../internal/providers/qoder/capabilities.ts | 1 + .../src/internal/providers/qoder/client.ts | 4 + .../src/internal/providers/qoder/config.ts | 1 + .../sdk/src/internal/providers/qoder/index.ts | 2 +- .../src/internal/providers/qoder/mapper.ts | 87 +++- .../sdk/src/internal/providers/registry.ts | 1 + .../internal/providers/resource-workflow.ts | 5 + .../src/internal/session/session-manager.ts | 15 + packages/sdk/src/internal/types/config.ts | 12 + packages/sdk/src/internal/types/dto.ts | 1 + packages/sdk/src/internal/types/session.ts | 24 +- .../tests/unit/core-session-runtime.test.ts | 39 ++ .../tests/unit/qoder-forward-template.test.ts | 471 ++++++++++++++++++ 51 files changed, 1319 insertions(+), 63 deletions(-) create mode 100644 packages/sdk/src/internal/core/agent-materialization.ts create mode 100644 packages/sdk/tests/unit/qoder-forward-template.test.ts diff --git a/.gitignore b/.gitignore index b1a09c2..3cdd468 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ packages/playground/web/ # local environment and state .env .env.* +/agents.yaml *.state.json agents.state.json agents.synced.yaml diff --git a/CONTEXT.md b/CONTEXT.md index 4089ff2..9ffc3ea 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -21,11 +21,17 @@ The forward-looking risk OpenAgentPack is a bet against: as agents move from "ca **Capability contract**: The explicit, per-provider capability matrix OpenAgentPack publishes (native / emulated / unsupported per resource kind). Portability means the *core declaration* is portable plus this contract is explicit and unsupported facets degrade gracefully (e.g. a provider emulating a non-native MCP transport or memory backend) — not that every feature is identical on every provider. Capability tiers are point-in-time: a resource emulated on a provider today may turn native as that provider catches up; only the matrix cell changes, not the resource's declared status. +**Agent Delivery**: +The per-provider choice of how an Agent Harness is exposed for use. `managed` delivery runs sessions directly from a provider-managed Agent; `forward` delivery exposes a reusable Template that requires a business Identity when starting a Session. Delivery belongs to the Agent declaration because it changes that Agent's provider-side form; it is not a Provider connection default. + +**Identity**: +A provider-visible business principal on whose behalf a Forward Session runs. An Identity is independent of Templates: one Identity may use several Templates, and one Template may serve several Identities. Session creation consumes an existing Identity selected by the caller; it does not create or own Identities. + ## Resources and workflow OpenAgentPack treats agents as **infrastructure as code**. A single `agents.yaml` declares the desired state and is the single source of truth. A Terraform-style workflow reconciles the real provider to match it: `validate → plan → apply → destroy`. -Declared resources: `environment`, `vault`, `memory_store`, `skill`, `file`, `agent`, `deployment`. `mcp_server` and `multiagent` are expressed through an agent; `session` is a runtime conversation started from a managed agent, not a declared resource. `deployment` declares scheduled/triggered runs of an agent; while every provider is expected to converge on native support, some currently expose it only via emulation — `plan` surfaces the tier and any behavioral differences. +Declared resources: `environment`, `vault`, `memory_store`, `skill`, `file`, `agent`, `deployment`. `mcp_server` and `multiagent` are expressed through an agent; `session` is a runtime conversation started from an applied Agent declaration, not a declared resource. Managed delivery starts it from a provider-managed Agent; Forward delivery starts it from a Template plus a caller-selected Identity. `deployment` declares scheduled/triggered runs of an agent; while every provider is expected to converge on native support, some currently expose it only via emulation — `plan` surfaces the tier and any behavioral differences. At any moment there are three descriptions: **config** (the YAML, desired state), **state** (a local state file mapping declared resources to remote IDs with content hashes), and **remote** (what actually exists on the provider). `plan` computes the diff; `apply` makes remote match config and updates state; content-hash diffing makes runs incremental; failed dependencies skip their dependents rather than leaving half-built state. diff --git a/apps/server/openapi.json b/apps/server/openapi.json index 9502589..101fa1d 100644 --- a/apps/server/openapi.json +++ b/apps/server/openapi.json @@ -719,6 +719,7 @@ "memory_store", "skill", "agent", + "template", "deployment", "file" ] @@ -749,6 +750,7 @@ "memory_store", "skill", "agent", + "template", "deployment", "file" ] @@ -783,6 +785,7 @@ "memory_store", "skill", "agent", + "template", "deployment", "file" ] @@ -838,6 +841,7 @@ "memory_store", "skill", "agent", + "template", "deployment", "file" ] diff --git a/apps/webui/src/lib/api/generated/schema.d.ts b/apps/webui/src/lib/api/generated/schema.d.ts index 7286b36..68919b0 100644 --- a/apps/webui/src/lib/api/generated/schema.d.ts +++ b/apps/webui/src/lib/api/generated/schema.d.ts @@ -275,14 +275,30 @@ export interface paths { message: string; resource?: { /** @enum {string} */ - type: "environment" | "vault" | "memory_store" | "skill" | "agent" | "deployment" | "file"; + type: + | "environment" + | "vault" + | "memory_store" + | "skill" + | "agent" + | "template" + | "deployment" + | "file"; name: string; provider: string; }; }[]; missing: { /** @enum {string} */ - type: "environment" | "vault" | "memory_store" | "skill" | "agent" | "deployment" | "file"; + type: + | "environment" + | "vault" + | "memory_store" + | "skill" + | "agent" + | "template" + | "deployment" + | "file"; name: string; provider: string; }[]; @@ -291,7 +307,15 @@ export interface paths { action: "create" | "update" | "delete" | "no-op"; address: { /** @enum {string} */ - type: "environment" | "vault" | "memory_store" | "skill" | "agent" | "deployment" | "file"; + type: + | "environment" + | "vault" + | "memory_store" + | "skill" + | "agent" + | "template" + | "deployment" + | "file"; name: string; provider: string; }; @@ -309,7 +333,15 @@ export interface paths { }; dependencies: { /** @enum {string} */ - type: "environment" | "vault" | "memory_store" | "skill" | "agent" | "deployment" | "file"; + type: + | "environment" + | "vault" + | "memory_store" + | "skill" + | "agent" + | "template" + | "deployment" + | "file"; name: string; provider: string; }[]; diff --git a/docs/guides/deploy-to-qoder.md b/docs/guides/deploy-to-qoder.md index 99120b0..6734e1a 100644 --- a/docs/guides/deploy-to-qoder.md +++ b/docs/guides/deploy-to-qoder.md @@ -9,12 +9,14 @@ providers: qoder: api_key: ${QODER_PAT} gateway: "https://api.qoder.com/api/v1/cloud" # optional; this is the default + forward_gateway: "https://api.qoder.com/api/v1/forward" # optional; derived from gateway by default ``` | Field | Required | Default | Description | |-------|:--------:|---------|-------------| | `api_key` | yes | — | Qoder personal access token. Resolve from `.env` with `${QODER_PAT}`. | | `gateway` | no | `https://api.qoder.com/api/v1/cloud` | Qoder cloud gateway base URL. | +| `forward_gateway` | no | derived from `gateway` | Qoder Forward gateway used for Template lifecycle requests. | ## Capabilities @@ -74,6 +76,53 @@ agents: ## What Qoder uniquely supports - **Memory stores** — persistent context for an agent. See [`examples/qoder/with-memory/`](../../examples/qoder/with-memory/). +- **Forward Templates** — declaratively materialize an `agents.*` declaration as a reusable Forward baseline: + +```yaml +agents: + forward-assistant: + model: auto + instructions: You are a helpful assistant. + environment: byoc + tunnel: private-network + vault: mcp-credentials + delivery: + qoder: + type: forward +``` + +`agents plan/apply/destroy` then manage a Qoder Forward Template (`tmpl_...`) instead of a Managed Agent +(`agent_...`). The default remains Managed Agent delivery when `delivery` is omitted. + +Identity is optional for local Forward Session testing. If omitted, OpenCMA finds the enabled Qoder Identity whose +`external_id` is `__qca_admin_identity__`, then sends its real `idn_...` id to the Session API. To run as a specific +user—or when the reserved Identity is unavailable—configure an existing Identity explicitly: + +```yaml +defaults: + provider: qoder + session: + qoder: + identity_id: idn_xxx +``` + +Then use the same session command to test a Forward-delivered agent: + +```bash +agents session run "Hello" --agent forward-assistant +``` + +The CLI resolves the applied Template and optional configured Identity, creates a Forward Session, and routes create, send, +stream, get, and archive operations through the Forward gateway. It never creates or updates an Identity as a side effect +of starting a Session. Use `--identity-id idn_xxx` to override the YAML default for one invocation. Managed sessions keep +using the Cloud gateway. + +Production messaging delivery is a separate Forward Channel concern. An IM Channel explicitly binds a business Identity +and Template; do not treat the CLI's local testing Identity as an end-user Identity. Qoder +recommends QR-code authorization for Channel binding so channel credentials do not need to be stored locally. + +Forward-delivered agents still cannot be referenced by OpenAgentPack deployments; scheduled Managed Deployment runs +require an Agent resource. ## Next steps diff --git a/docs/guides/run-sessions.md b/docs/guides/run-sessions.md index b88a44c..9d1d5c3 100644 --- a/docs/guides/run-sessions.md +++ b/docs/guides/run-sessions.md @@ -12,13 +12,14 @@ 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. +`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. Options: | Option | Description | |--------|-------------| | `--agent ` | Agent to run (auto-detected with one agent). | +| `--identity-id ` | Override the configured Qoder Forward Identity for this Session. | | `--environment ` | Override the agent's declared environment. | | `--vault ` | Override the agent's declared vault. | | `--memory-stores ` | Override the agent's declared memory stores (comma-separated). | @@ -45,7 +46,7 @@ agents session delete ## What a session binds -A session binds an agent + an environment + vaults + memory stores + files into one runnable unit. The bindings are resolved from the agent declaration and the state file; `session create` lets you override `--environment`, `--vault`, and `--memory-stores` at run time. +A Managed Session binds an Agent + environment + vaults + memory stores + files. A Qoder Forward Session binds a Template + Identity; the Template already owns its environment, tunnel, vault, and MCP configuration. `session create` lets callers override the relevant runtime bindings. ## Programmatic usage diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 8f763c8..0a05ce7 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -111,7 +111,7 @@ Manage runtime agent sessions. | `session events ` | List event history for a session. | | `session delete ` | Delete a session. | -`session create` / `session run` accept `--agent`, `--environment`, `--vault`, `--memory-stores`, `--title`, and `--provider`. `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` accept `--json` (JSONL output) and `--no-stream` (polling instead of SSE). `session list` accepts `--agent` and `--all`; `session events` accepts `--limit`, `--all`, `--json`. ## `agents deployment` diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index a20b996..46b31f0 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -7,7 +7,10 @@ The `agents.yaml` schema. Every field below is sourced from the Zod schema in `p ```yaml version: "1" providers: { : } -defaults: { provider: | "all" } +defaults: + provider: | "all" + session: + qoder: { identity_id: } environments: { : EnvironmentDecl } tunnels: { : TunnelDecl } vaults: { : VaultDecl } @@ -23,6 +26,7 @@ deployments: { : DeploymentDecl } | `version` | string | yes | Schema version. Currently `"1"`. | | `providers` | map | yes | One block per provider; each holds its credentials. | | `defaults.provider` | string | no | Default target for `plan`/`apply`. `all` targets every declared provider. | +| `defaults.session.qoder.identity_id` | string | no | Existing Qoder Forward Identity used as the local Session default. If omitted, OpenCMA looks up the enabled Identity whose `external_id` is `__qca_admin_identity__` and sends its real `idn_...` id. Never created or managed by `apply`. | | `environments` | map | no | Cloud runtimes. | | `tunnels` | map | no | Existing Qoder BYOC tunnels referenced by sessions; OpenCMA does not manage their lifecycle. | | `vaults` | map | no | Credential stores. | diff --git a/packages/cli/src/commands/session.ts b/packages/cli/src/commands/session.ts index da310a3..a5e8699 100644 --- a/packages/cli/src/commands/session.ts +++ b/packages/cli/src/commands/session.ts @@ -45,6 +45,7 @@ export function formatDuration(startIso: string, endIso?: string): string { interface SessionCreateOpts { file: string; agent?: string; + identityId?: string; environment?: string; environmentId?: string; tunnel?: string; @@ -68,6 +69,7 @@ export async function sessionCreateCommand( const ctx = await buildCliRuntime(options.file); const run = await createSessionForAgent(ctx, { agent: positionalAgent ?? options.agent, + identityId: options.identityId, provider: options.provider, environment: options.environment, environmentId: options.environmentId, @@ -255,6 +257,7 @@ function renderCollectedEvents(result: CollectedSessionEvents, json: boolean): v interface SessionRunOpts { file: string; agent?: string; + identityId?: string; environment?: string; environmentId?: string; tunnel?: string; @@ -282,6 +285,7 @@ export async function sessionRunCommand( const runOptions = { agent: positionalAgent ?? options.agent, + identityId: options.identityId, provider: options.provider, environment: options.environment, environmentId: options.environmentId, diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 7e984ec..99fd60b 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -193,6 +193,7 @@ sessionCmd .description("Create a new session for an agent") .addOption(configFileOption()) .option("--agent ", "Agent name (auto-detected when only one agent is configured)") + .option("--identity-id ", "Override the configured Qoder Forward Identity") .option("--environment ", "Override agent's declared environment") .option("--environment-id ", "Use an explicit remote environment id instead of the configured one") .option("--tunnel ", "Override agent's declared tunnel") @@ -231,6 +232,7 @@ sessionCmd .description("Create a session, send a message, and stream 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") .option("--environment ", "Override agent's declared environment") .option("--environment-id ", "Use an explicit remote environment id instead of the configured one") .option("--tunnel ", "Override agent's declared tunnel") diff --git a/packages/cli/tests/unit/cli-contracts.test.ts b/packages/cli/tests/unit/cli-contracts.test.ts index 1ee6f29..f3358bb 100644 --- a/packages/cli/tests/unit/cli-contracts.test.ts +++ b/packages/cli/tests/unit/cli-contracts.test.ts @@ -167,6 +167,13 @@ test("help does not load credentials from a malformed config.json", async () => expect(result.stderr).not.toContain("Failed to load"); }); +test("session run exposes an explicit Forward identity override", async () => { + const result = await runAgents(["session", "run", "--help"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("--identity-id "); +}); + test("global --file before plan selects the config file", async () => { const dir = await makeTempDir(); const configPath = await writeConfig(dir); diff --git a/packages/sdk/src/internal/core/agent-materialization.ts b/packages/sdk/src/internal/core/agent-materialization.ts new file mode 100644 index 0000000..f3afe97 --- /dev/null +++ b/packages/sdk/src/internal/core/agent-materialization.ts @@ -0,0 +1,17 @@ +import type { AgentDecl } from "../types/config.ts"; + +export interface AgentMaterialization { + resourceType: "agent" | "template"; + mode: "managed" | "forward"; +} + +/** + * Resolve the physical provider resource represented by a logical `agents.*` + * declaration. This is the single seam for delivery-mode decisions: graph, + * state, planner, executor, and runtime callers must not re-derive it. + */ +export function resolveAgentMaterialization(provider: string, agent: AgentDecl): AgentMaterialization { + const mode = agent.delivery?.[provider]?.type ?? "managed"; + if (mode === "managed") return { resourceType: "agent", mode }; + return { resourceType: "template", mode }; +} diff --git a/packages/sdk/src/internal/core/agent-runtime.ts b/packages/sdk/src/internal/core/agent-runtime.ts index bb0c77e..994f3b1 100644 --- a/packages/sdk/src/internal/core/agent-runtime.ts +++ b/packages/sdk/src/internal/core/agent-runtime.ts @@ -19,6 +19,7 @@ import type { import type { ExecutionPlan } from "../types/plan.ts"; import type { ResourceAddress } from "../types/state.ts"; import { addressKey } from "../types/state.ts"; +import { resolveAgentMaterialization } from "./agent-materialization.ts"; import type { BackendRuntimeInput, ProjectRuntimeContext } from "./project-runtime.ts"; import { getRuntimeProvider, writeProjectRuntime } from "./project-runtime.ts"; import { @@ -483,7 +484,10 @@ export function collectAgentAddresses(config: ProjectConfig, agentName: string, throw new UserError(`Agent '${agentName}' not found in config.`); } const resolvedProvider = provider ?? resolveSessionProvider(agentName, config, undefined); - const addresses: ResourceAddress[] = [{ type: "agent", name: agentName, provider: resolvedProvider }]; + const materialization = resolveAgentMaterialization(resolvedProvider, agent); + const addresses: ResourceAddress[] = [ + { type: materialization.resourceType, name: agentName, provider: resolvedProvider }, + ]; if (agent.environment) { addresses.push({ @@ -504,7 +508,9 @@ export function collectAgentAddresses(config: ProjectConfig, agentName: string, } } for (const subAgent of agent.multiagent?.agents ?? []) { - addresses.push({ type: "agent", name: subAgent, provider: resolvedProvider }); + const subDecl = config.agents?.[subAgent]; + const subType = subDecl ? resolveAgentMaterialization(resolvedProvider, subDecl).resourceType : "agent"; + addresses.push({ type: subType, name: subAgent, provider: resolvedProvider }); } return addresses; diff --git a/packages/sdk/src/internal/core/destroy-runtime.ts b/packages/sdk/src/internal/core/destroy-runtime.ts index 17c3d81..a89ebb6 100644 --- a/packages/sdk/src/internal/core/destroy-runtime.ts +++ b/packages/sdk/src/internal/core/destroy-runtime.ts @@ -48,6 +48,7 @@ export interface DestroyProjectResult extends DestroyPlanResult { const destroyOrder: Record = { deployment: 0, agent: 1, + template: 1, skill: 2, memory_store: 3, vault: 4, @@ -202,6 +203,10 @@ async function deleteRemoteResource( case "agent": await provider.deleteAgent(id); return; + case "template": + if (!provider.archiveTemplate) throw new UserError(`Provider does not support templates`); + await provider.archiveTemplate(id); + return; case "skill": await provider.deleteSkill(id); return; diff --git a/packages/sdk/src/internal/core/resource-runtime.ts b/packages/sdk/src/internal/core/resource-runtime.ts index 58ea9fd..cdba10d 100644 --- a/packages/sdk/src/internal/core/resource-runtime.ts +++ b/packages/sdk/src/internal/core/resource-runtime.ts @@ -83,7 +83,14 @@ export async function syncProjectResourcesWithStateBackend( }); } -const IMPORTABLE_RESOURCE_TYPES = new Set(["environment", "vault", "memory_store", "skill", "agent"]); +const IMPORTABLE_RESOURCE_TYPES = new Set([ + "environment", + "vault", + "memory_store", + "skill", + "agent", + "template", +]); // Adopt an existing remote resource into state: derive its content hash from the // plan's create action and record it. Owns which resource types may be imported. diff --git a/packages/sdk/src/internal/core/session-runtime.ts b/packages/sdk/src/internal/core/session-runtime.ts index 9485c31..6140687 100644 --- a/packages/sdk/src/internal/core/session-runtime.ts +++ b/packages/sdk/src/internal/core/session-runtime.ts @@ -12,6 +12,7 @@ import type { } from "../types/session-event.ts"; import type { ProviderSkillInfo } from "../types/skill-info.ts"; import { preparePromptForProvider } from "../utils/sandbox-mount.ts"; +import { resolveAgentMaterialization } from "./agent-materialization.ts"; import type { ProjectRuntimeContext } from "./project-runtime.ts"; import { getRuntimeProvider } from "./project-runtime.ts"; @@ -140,6 +141,7 @@ export async function createSessionForAgent( ): Promise { const { agentName, provider, adapter } = resolveSessionRuntime(ctx, options); const bindings = buildSessionBindings(agentName, ctx.config, provider, ctx.state, { + identityId: options.identityId, environment: options.environment, environmentId: options.environmentId, tunnel: options.tunnel, @@ -162,6 +164,7 @@ export async function startSessionRun( ): Promise { const { agentName, provider, adapter } = resolveSessionRuntime(ctx, options); const bindings = buildSessionBindings(agentName, ctx.config, provider, ctx.state, { + identityId: options.identityId, environment: options.environment, environmentId: options.environmentId, tunnel: options.tunnel, @@ -309,13 +312,17 @@ export async function listSessionsForProject( const resolved = resolveSessionRuntime(ctx, options); provider = resolved.provider; agentName = resolved.agentName; + const agentDecl = ctx.config.agents?.[resolved.agentName]; + const resourceType = agentDecl ? resolveAgentMaterialization(provider, agentDecl).resourceType : "agent"; const state = ctx.state.getResource({ - type: "agent", + type: resourceType, name: resolved.agentName, provider, }); if (!state?.remote_id) { - throw new UserError(`Agent '${resolved.agentName}' not found in state. Run \`agents apply\` first.`); + throw new UserError( + `${resourceType === "template" ? "Template" : "Agent"} '${resolved.agentName}' not found in state. Run \`agents apply\` first.`, + ); } agentId = state.remote_id; } else if (options.provider) { @@ -519,7 +526,11 @@ function resolveDirectAdapter(ctx: ProjectRuntimeContext, overrideProvider?: str function buildAgentNameByRemoteId(ctx: ProjectRuntimeContext, provider: string): Map { const names = new Map(); for (const resource of ctx.state.listResources()) { - if (resource.address.type === "agent" && resource.address.provider === provider && resource.remote_id) { + if ( + (resource.address.type === "agent" || resource.address.type === "template") && + resource.address.provider === provider && + resource.remote_id + ) { names.set(resource.remote_id, resource.address.name); } } diff --git a/packages/sdk/src/internal/core/validate-config.ts b/packages/sdk/src/internal/core/validate-config.ts index d89b30c..3cff1ba 100644 --- a/packages/sdk/src/internal/core/validate-config.ts +++ b/packages/sdk/src/internal/core/validate-config.ts @@ -120,6 +120,41 @@ export function collectProviderCapabilities( } const caps = def.capabilities; + for (const [name, agent] of Object.entries(config.agents ?? {})) { + if (agent.provider && agent.provider !== providerName) continue; + const delivery = agent.delivery?.[providerName]?.type ?? "managed"; + if (delivery === "forward" && !isSupported(caps, "template")) { + diagnostics.error( + `${providerName}.agent.delivery.forward.unsupported`, + `agent.${name}: provider '${providerName}' does not support delivery type 'forward'. Supported delivery types: managed.`, + { type: "agent", name, provider: providerName }, + ); + } + if (delivery === "forward" && providerName === "qoder") { + if (!agent.environment) { + diagnostics.error( + "qoder.template.environment.required", + `agent.${name}: Qoder Forward delivery requires an environment.`, + { type: "template", name, provider: providerName }, + ); + } + if (agent.memory_stores?.length) { + diagnostics.error( + "qoder.template.memory_store.unsupported", + `agent.${name}: memory_stores are not yet supported by Qoder Forward Template delivery.`, + { type: "template", name, provider: providerName }, + ); + } + if (agent.multiagent) { + diagnostics.error( + "qoder.template.multiagent.unsupported", + `agent.${name}: multiagent is not yet supported by Qoder Forward Template delivery.`, + { type: "template", name, provider: providerName }, + ); + } + } + } + if (providerName === "qoder") { // Qoder's /deployments API rejects tunnel_id (HTTP 400 "unknown field"), so // a declared/inherited tunnel is dropped from the deployment payload and @@ -127,6 +162,14 @@ export function collectProviderCapabilities( for (const [name, deployment] of Object.entries(config.deployments ?? {})) { if (deployment.provider && deployment.provider !== providerName) continue; const tunnel = deployment.tunnel ?? config.agents?.[deployment.agent]?.tunnel; + const referencedAgent = config.agents?.[deployment.agent]; + if (referencedAgent?.delivery?.qoder?.type === "forward") { + diagnostics.error( + "qoder.deployment.forward_template.unsupported", + `deployment.${name}: managed deployments cannot reference Forward-delivered agent '${deployment.agent}'.`, + { type: "deployment", name, provider: providerName }, + ); + } if (tunnel) { diagnostics.warning( `${providerName}.deployment.tunnel.unsupported`, diff --git a/packages/sdk/src/internal/executor/executor.ts b/packages/sdk/src/internal/executor/executor.ts index 41afcf3..2c0fca7 100644 --- a/packages/sdk/src/internal/executor/executor.ts +++ b/packages/sdk/src/internal/executor/executor.ts @@ -16,7 +16,7 @@ import { addressKey } from "../types/state.ts"; import { contentHash } from "../utils/hash.ts"; import { skillNameFromFiles } from "../utils/skill-manifest.ts"; import type { ExecContext } from "./context.ts"; -import { resolveAgentRefs, resolveDeploymentRefs } from "./resolver.ts"; +import { resolveAgentRefs, resolveDeploymentRefs, resolveTemplateRefs } from "./resolver.ts"; import { resolveSkillFiles } from "./skill-resolver.ts"; export interface ActionResult { @@ -270,6 +270,11 @@ async function executeActionInner( case "agent": await provider.deleteAgent(id); break; + case "template": + if (!provider.archiveTemplate) + throw new UserError(`Provider '${address.provider}' does not support templates`); + await provider.archiveTemplate(id); + break; case "memory_store": if (!provider.deleteMemoryStore) throw memoryStoreUnsupported(address.provider); await provider.deleteMemoryStore(id); @@ -464,6 +469,29 @@ async function executeActionInner( } break; } + case "template": { + const createTemplate = provider.createTemplate?.bind(provider); + const updateTemplate = provider.updateTemplate?.bind(provider); + if (!createTemplate || !updateTemplate) { + throw new UserError(`Provider '${address.provider}' does not support templates`); + } + const decl = ctx.config.agents![name]!; + const remoteName = decl.name ?? name; + const refs = resolveTemplateRefs(name, ctx.config, address.provider, ctx.state); + if (isUpdate) { + result = await updateTemplate(existingId!, remoteName, decl, refs); + } else { + try { + result = await createTemplate(remoteName, decl, refs); + } catch (err) { + result = await adoptOnConflict(err, address, provider, ctx.onFeedback, { + onExisting: (existing) => updateTemplate(existing.id!, remoteName, decl, refs), + }); + adopted = true; + } + } + break; + } case "deployment": { const decl = ctx.config.deployments![name]!; const refs = resolveDeploymentRefs(name, ctx.config, address.provider, ctx.state); diff --git a/packages/sdk/src/internal/executor/resolver.ts b/packages/sdk/src/internal/executor/resolver.ts index 0097a60..89e37aa 100644 --- a/packages/sdk/src/internal/executor/resolver.ts +++ b/packages/sdk/src/internal/executor/resolver.ts @@ -1,5 +1,5 @@ import { UserError } from "../errors.ts"; -import type { ResolvedAgentRefs, ResolvedDeploymentRefs } from "../providers/interface.ts"; +import type { ResolvedAgentRefs, ResolvedDeploymentRefs, ResolvedTemplateRefs } from "../providers/interface.ts"; import type { IStateManager } from "../state/state-manager.ts"; import type { ProjectConfig } from "../types/config.ts"; import type { ResourceAddress } from "../types/state.ts"; @@ -67,6 +67,30 @@ export function resolveAgentRefs( return refs; } +export function resolveTemplateRefs( + agentName: string, + config: ProjectConfig, + provider: string, + state: IStateManager, +): ResolvedTemplateRefs { + const agent = config.agents?.[agentName]; + if (!agent) throw new UserError(`Agent '${agentName}' not found in config`); + if (!agent.environment) { + throw new UserError(`Forward template '${agentName}' must declare an environment.`); + } + const environment = config.environments?.[agent.environment]; + if (!environment) throw new UserError(`Environment '${agent.environment}' is not defined in config.`); + + const agentRefs = resolveAgentRefs(agentName, config, provider, state); + return { + ...agentRefs, + environment_id: + environment.environment_id ?? requireRef(state, { type: "environment", name: agent.environment, provider }), + ...(agent.tunnel ? { tunnel_id: resolveTunnelIdFromConfig(config, agent.tunnel, provider) } : {}), + vault_ids: agent.vault ? [requireRef(state, { type: "vault", name: agent.vault, provider })] : [], + }; +} + export function resolveDeploymentRefs( deploymentName: string, config: ProjectConfig, diff --git a/packages/sdk/src/internal/graph/dependency.ts b/packages/sdk/src/internal/graph/dependency.ts index f6af4e5..a47ef5e 100644 --- a/packages/sdk/src/internal/graph/dependency.ts +++ b/packages/sdk/src/internal/graph/dependency.ts @@ -1,3 +1,4 @@ +import { resolveAgentMaterialization } from "../core/agent-materialization.ts"; import { UserError } from "../errors.ts"; import { isSupported } from "../providers/capabilities.ts"; import { getProvider } from "../providers/registry.ts"; @@ -75,7 +76,8 @@ export function buildDependencyGraph(config: ProjectConfig, targetProviders: str for (const name of Object.keys(config.agents)) { const decl = config.agents[name]!; if (decl.provider && decl.provider !== provider) continue; - const agentAddr: ResourceAddress = { type: "agent", name, provider }; + const materialization = resolveAgentMaterialization(provider, decl); + const agentAddr: ResourceAddress = { type: materialization.resourceType, name, provider }; addNode(agentAddr); if (decl.environment && config.environments?.[decl.environment]) { @@ -122,7 +124,9 @@ export function buildDependencyGraph(config: ProjectConfig, targetProviders: str if (decl.multiagent && isSupported(caps, "multiagent")) { for (const subName of decl.multiagent.agents) { - const subAddr: ResourceAddress = { type: "agent", name: subName, provider }; + const subDecl = config.agents[subName]; + const subType = subDecl ? resolveAgentMaterialization(provider, subDecl).resourceType : "agent"; + const subAddr: ResourceAddress = { type: subType, name: subName, provider }; addEdge(agentAddr, subAddr); } } @@ -136,7 +140,9 @@ export function buildDependencyGraph(config: ProjectConfig, targetProviders: str const depAddr: ResourceAddress = { type: "deployment", name, provider }; addNode(depAddr); - const agentAddr: ResourceAddress = { type: "agent", name: decl.agent, provider }; + const agentDecl = config.agents?.[decl.agent]; + const agentType = agentDecl ? resolveAgentMaterialization(provider, agentDecl).resourceType : "agent"; + const agentAddr: ResourceAddress = { type: agentType, name: decl.agent, provider }; if (nodes.has(addressKey(agentAddr))) addEdge(depAddr, agentAddr); if (decl.environment) { diff --git a/packages/sdk/src/internal/parser/schema.ts b/packages/sdk/src/internal/parser/schema.ts index e3b1612..73399a4 100644 --- a/packages/sdk/src/internal/parser/schema.ts +++ b/packages/sdk/src/internal/parser/schema.ts @@ -174,6 +174,10 @@ const agentSkillRefSchema = z version: s.version, })); +const agentDeliverySchema = z.object({ + type: z.enum(["managed", "forward"]), +}); + const agentSchema = z.object({ name: z.string().optional(), description: z.string().optional(), @@ -189,6 +193,7 @@ const agentSchema = z.object({ memory_stores: z.array(z.string()).optional(), multiagent: multiagentSchema.optional(), metadata: z.record(z.string(), z.string()).optional(), + delivery: z.record(z.string(), agentDeliverySchema).optional(), }); const deploymentFileResourceSchema = z.object({ @@ -259,6 +264,15 @@ export const projectConfigSchema = z.object({ defaults: z .object({ provider: z.string().optional(), + session: z + .object({ + qoder: z + .object({ + identity_id: z.string().min(1).optional(), + }) + .optional(), + }) + .optional(), }) .optional(), environments: z.record(z.string(), environmentSchema).optional(), diff --git a/packages/sdk/src/internal/planner/declaration.ts b/packages/sdk/src/internal/planner/declaration.ts index ae587be..c1ab180 100644 --- a/packages/sdk/src/internal/planner/declaration.ts +++ b/packages/sdk/src/internal/planner/declaration.ts @@ -13,6 +13,7 @@ export function getResourceDeclaration(address: ResourceAddress, config: Project case "skill": return config.skills?.[name] ?? null; case "agent": + case "template": return config.agents?.[name] ?? null; case "file": return config.files?.[name] ?? null; diff --git a/packages/sdk/src/internal/planner/hasher.ts b/packages/sdk/src/internal/planner/hasher.ts index 9c8ed80..a854bc6 100644 --- a/packages/sdk/src/internal/planner/hasher.ts +++ b/packages/sdk/src/internal/planner/hasher.ts @@ -33,6 +33,11 @@ export async function computeResourceHash( if (refs) return contentHash({ decl, refs }); } + if (address.type === "template") { + const refs = resolveTemplateReferenceIds(decl as TemplateRefDecl, config, address.provider, state); + return contentHash({ decl, refs }); + } + return contentHash(decl); } @@ -41,6 +46,45 @@ interface DeploymentRefDecl { environment?: string; } +interface TemplateRefDecl { + environment?: string; + tunnel?: string; + vault?: string; + skills?: Array; +} + +function resolveTemplateReferenceIds( + decl: TemplateRefDecl, + config: ProjectConfig, + provider: string, + state?: HashStateLookup, +): Record { + const environment = decl.environment ? config.environments?.[decl.environment] : undefined; + const tunnel = decl.tunnel ? config.tunnels?.[decl.tunnel] : undefined; + const skillIds = (decl.skills ?? []).map((skill) => { + if (typeof skill === "string") { + return state?.getResource({ type: "skill", name: skill, provider })?.remote_id ?? skill; + } + if (skill.type === "official") return `${skill.type}:${skill.skill_id}:${skill.version ?? ""}`; + return ( + state?.getResource({ type: "skill", name: skill.skill_id, provider })?.remote_id ?? + `${skill.type}:${skill.skill_id}:${skill.version ?? ""}` + ); + }); + return { + environment_id: + environment?.environment_id ?? + (decl.environment + ? (state?.getResource({ type: "environment", name: decl.environment, provider })?.remote_id ?? undefined) + : undefined), + tunnel_id: tunnel?.tunnel_id, + vault_ids: decl.vault + ? [state?.getResource({ type: "vault", name: decl.vault, provider })?.remote_id ?? decl.vault] + : [], + skill_ids: skillIds, + }; +} + // A deployment's identity includes the *resolved* ids of its reference-type // inputs that actually reach the wire (the environment id). Those values can // change while the referenced names stay the same, and a name-only hash would diff --git a/packages/sdk/src/internal/planner/planner.ts b/packages/sdk/src/internal/planner/planner.ts index 2a04e4f..27d3543 100644 --- a/packages/sdk/src/internal/planner/planner.ts +++ b/packages/sdk/src/internal/planner/planner.ts @@ -4,7 +4,7 @@ import { resolveTargetProviders, } from "../core/validate-config.ts"; import { DiagnosticCollector } from "../diagnostics/diagnostics.ts"; -import { buildDependencyGraph, topologicalSort } from "../graph/dependency.ts"; +import { buildDependencyGraph, type DependencyGraph, topologicalSort } from "../graph/dependency.ts"; import type { ProjectConfig } from "../types/config.ts"; import type { ExecutionPlan, PlannedAction } from "../types/plan.ts"; import type { ResourceAddress, StateFile } from "../types/state.ts"; @@ -176,6 +176,7 @@ export async function buildPlan( // Remaining in state but not in config: delete (reverse order) const toDelete = Array.from(stateIndex.values()).reverse(); for (const res of toDelete) { + const replacement = deliveryReplacementAddress(res.address, graph); actions.push({ action: "delete", address: res.address, @@ -185,13 +186,21 @@ export async function buildPlan( ? "Remove local reference only — externally managed remote resource is left intact" : "Resource removed from configuration", before: { content_hash: res.desired_hash ?? res.content_hash }, - dependencies: [], + dependencies: replacement ? [replacement] : [], }); } return { actions, diagnostics: diagnostics.getAll() }; } +/** Keep the old delivery resource alive when creating its new materialization fails. */ +function deliveryReplacementAddress(address: ResourceAddress, graph: DependencyGraph): ResourceAddress | undefined { + if (address.type !== "agent" && address.type !== "template") return undefined; + const replacementType = address.type === "agent" ? "template" : "agent"; + const candidate: ResourceAddress = { ...address, type: replacementType }; + return graph.nodes.has(addressKey(candidate)) ? candidate : undefined; +} + function collectChangedPaths( address: ResourceAddress, config: ProjectConfig, diff --git a/packages/sdk/src/internal/planner/refresh.ts b/packages/sdk/src/internal/planner/refresh.ts index 31a44f7..89c4928 100644 --- a/packages/sdk/src/internal/planner/refresh.ts +++ b/packages/sdk/src/internal/planner/refresh.ts @@ -74,11 +74,15 @@ export async function refreshState( ? provider.normalizeDesiredResource(res.address.type, res.address.name, decl) : null; const desiredComparableHash = desiredComparable === null ? undefined : contentHash(desiredComparable); - const baselineHash = res.desired_comparable_hash ?? desiredComparableHash; + // Resources created before full drift support have no comparable baseline. + // Adopt the first complete remote snapshot so subsequent refreshes can + // detect out-of-band changes without reporting a false migration drift. + const baselineHash = res.desired_comparable_hash ?? desiredComparableHash ?? remoteHash; const driftStatus = baselineHash && remoteHash !== baselineHash ? "drifted" : "in_sync"; + const comparisonBaseline = res.remote_snapshot ?? desiredComparable; const driftPaths = - driftStatus === "drifted" && desiredComparable !== null - ? diffChangedPaths(desiredComparable, remote.comparable) + driftStatus === "drifted" && comparisonBaseline != null + ? diffChangedPaths(comparisonBaseline, remote.comparable) : []; state.setResource({ @@ -86,7 +90,7 @@ export async function refreshState( version: remote.version ?? res.version, remote_id: remote.id, desired_hash: res.desired_hash ?? res.content_hash, - desired_comparable_hash: res.desired_comparable_hash ?? desiredComparableHash, + desired_comparable_hash: baselineHash, remote_hash: remoteHash, remote_snapshot: remote.snapshot ?? remote.comparable, drift_paths: driftPaths, diff --git a/packages/sdk/src/internal/providers/ark/adapter.ts b/packages/sdk/src/internal/providers/ark/adapter.ts index c73f9cb..bfdc488 100644 --- a/packages/sdk/src/internal/providers/ark/adapter.ts +++ b/packages/sdk/src/internal/providers/ark/adapter.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import { basename, dirname, resolve } from "node:path"; import JSZip from "jszip"; +import { UserError } from "../../errors.ts"; import type { AgentDecl, DeploymentDecl, @@ -362,6 +363,7 @@ export class ArkAdapter implements ProviderAdapter { } async createSession(bindings: SessionBindings): Promise { + if (bindings.delivery === "forward") throw new UserError("Ark does not support Forward sessions."); const body = mapSession(bindings); const res = (await this.client.post("/sessions", body)) as Record; return toSessionInfo(res); diff --git a/packages/sdk/src/internal/providers/ark/capabilities.ts b/packages/sdk/src/internal/providers/ark/capabilities.ts index 7f375d6..99decd9 100644 --- a/packages/sdk/src/internal/providers/ark/capabilities.ts +++ b/packages/sdk/src/internal/providers/ark/capabilities.ts @@ -5,6 +5,7 @@ export const ARK_CAPABILITIES: ProviderCapabilities = { vault: { tier: "native", reason: "vaults API" }, skill: { tier: "native", reason: "skills API with single-zip upload (create + get + attach only)" }, agent: { tier: "native", reason: "managed agents API" }, + template: { tier: "unsupported", reason: "no Forward Template equivalent on Ark" }, memory_store: { tier: "native", reason: "memory_stores API" }, mcp_server: { tier: "native", reason: "mcp_servers field on agent" }, multiagent: { tier: "native", reason: "coordinator + roster topology" }, diff --git a/packages/sdk/src/internal/providers/ark/mapper.ts b/packages/sdk/src/internal/providers/ark/mapper.ts index 79a9647..be58f2a 100644 --- a/packages/sdk/src/internal/providers/ark/mapper.ts +++ b/packages/sdk/src/internal/providers/ark/mapper.ts @@ -8,7 +8,7 @@ import type { ModelSpec, } from "../../types/config.ts"; import type { SessionEventType } from "../../types/dto.ts"; -import type { SessionBindings } from "../../types/session.ts"; +import type { ManagedSessionBindings } from "../../types/session.ts"; import type { ProviderSessionEvent } from "../../types/session-event.ts"; import { compactDeep, stripAgentsMetadata } from "../../utils/comparable.ts"; import type { ResolvedAgentRefs, ResolvedDeploymentRefs } from "../interface.ts"; @@ -414,7 +414,7 @@ function extractErrorMessage(raw: Record): string { return ""; } -export function mapSession(bindings: SessionBindings): unknown { +export function mapSession(bindings: ManagedSessionBindings): unknown { const body: Record = { agent: bindings.agent_version ? { diff --git a/packages/sdk/src/internal/providers/bailian/adapter.ts b/packages/sdk/src/internal/providers/bailian/adapter.ts index 48da316..58b8a6b 100644 --- a/packages/sdk/src/internal/providers/bailian/adapter.ts +++ b/packages/sdk/src/internal/providers/bailian/adapter.ts @@ -626,6 +626,7 @@ export class BailianAdapter implements ProviderAdapter { // --- Session --- async createSession(bindings: SessionBindings): Promise { + if (bindings.delivery === "forward") throw new UserError("Bailian does not support Forward sessions."); const body = mapSession(bindings); const res = (await this.client.post("/sessions", body)) as Record; return toSessionInfo(res); diff --git a/packages/sdk/src/internal/providers/bailian/capabilities.ts b/packages/sdk/src/internal/providers/bailian/capabilities.ts index 4fcde36..63f942e 100644 --- a/packages/sdk/src/internal/providers/bailian/capabilities.ts +++ b/packages/sdk/src/internal/providers/bailian/capabilities.ts @@ -5,6 +5,7 @@ export const BAILIAN_CAPABILITIES: ProviderCapabilities = { vault: { tier: "native", reason: "vaults + credentials API (static_bearer MCP credentials)" }, skill: { tier: "native", reason: "skills API with 2-step zip upload via Files API" }, agent: { tier: "native", reason: "agents API with versioned updates" }, + template: { tier: "unsupported", reason: "no Forward Template equivalent on Bailian" }, memory_store: { tier: "unsupported", reason: "no memory store primitive on Bailian", diff --git a/packages/sdk/src/internal/providers/bailian/mapper.ts b/packages/sdk/src/internal/providers/bailian/mapper.ts index b03899f..ce0fee2 100644 --- a/packages/sdk/src/internal/providers/bailian/mapper.ts +++ b/packages/sdk/src/internal/providers/bailian/mapper.ts @@ -8,7 +8,7 @@ import type { ModelSpec, VaultDecl, } from "../../types/config.ts"; -import type { SessionBindings } from "../../types/session.ts"; +import type { ManagedSessionBindings } from "../../types/session.ts"; import { compactDeep, stripAgentsMetadata } from "../../utils/comparable.ts"; import type { ResolvedAgentRefs, ResolvedDeploymentRefs } from "../interface.ts"; import { injectMetadata, secretPlaceholder } from "../sync-mapping.ts"; @@ -296,7 +296,7 @@ export function mapAgent( return body; } -export function mapSession(bindings: SessionBindings): unknown { +export function mapSession(bindings: ManagedSessionBindings): unknown { const body: Record = { agent: bindings.agent_id, environment_id: bindings.environment_id, diff --git a/packages/sdk/src/internal/providers/base-client.ts b/packages/sdk/src/internal/providers/base-client.ts index ab1668d..a506f3a 100644 --- a/packages/sdk/src/internal/providers/base-client.ts +++ b/packages/sdk/src/internal/providers/base-client.ts @@ -79,11 +79,11 @@ export abstract class BaseApiClient { return Buffer.from(await res.arrayBuffer()); } - async *sse(path: string): AsyncGenerator> { + async *sse(path: string, options?: { headers?: Record }): AsyncGenerator> { const controller = new AbortController(); const res = await fetch(`${this.baseUrl}${path}`, { method: "GET", - headers: { ...this.headers(), Accept: "text/event-stream" }, + headers: { ...this.headers(), Accept: "text/event-stream", ...options?.headers }, signal: controller.signal, }); await this.throwIfError(res); diff --git a/packages/sdk/src/internal/providers/capabilities.ts b/packages/sdk/src/internal/providers/capabilities.ts index 2496450..eb661df 100644 --- a/packages/sdk/src/internal/providers/capabilities.ts +++ b/packages/sdk/src/internal/providers/capabilities.ts @@ -3,6 +3,7 @@ export type ResourceKind = | "vault" | "skill" | "agent" + | "template" | "memory_store" | "mcp_server" | "multiagent" @@ -50,6 +51,7 @@ export const REQUIRED_METHODS_BY_KIND: Partial = { + private static readonly ENDPOINT_MAP: Partial> = { environment: "/environments", agent: "/agents", vault: "/vaults", @@ -336,6 +336,7 @@ export class ClaudeAdapter implements ProviderAdapter { } async createSession(bindings: SessionBindings): Promise { + if (bindings.delivery === "forward") throw new UserError("Claude does not support Forward sessions."); const body = mapSession(bindings); const res = (await this.client.post("/sessions", body)) as Record; return toSessionInfo(res); diff --git a/packages/sdk/src/internal/providers/claude/capabilities.ts b/packages/sdk/src/internal/providers/claude/capabilities.ts index d040e22..dc56122 100644 --- a/packages/sdk/src/internal/providers/claude/capabilities.ts +++ b/packages/sdk/src/internal/providers/claude/capabilities.ts @@ -5,6 +5,7 @@ export const CLAUDE_CAPABILITIES: ProviderCapabilities = { vault: { tier: "native", reason: "vaults API" }, skill: { tier: "native", reason: "skills API with files[] upload" }, agent: { tier: "native", reason: "managed agents API" }, + template: { tier: "unsupported", reason: "no Forward Template equivalent on Claude" }, memory_store: { tier: "unsupported", reason: "Claude exposes Memory Stores, but the OpenAgentPack adapter has not implemented them yet", diff --git a/packages/sdk/src/internal/providers/claude/mapper.ts b/packages/sdk/src/internal/providers/claude/mapper.ts index 3605a18..12f41c1 100644 --- a/packages/sdk/src/internal/providers/claude/mapper.ts +++ b/packages/sdk/src/internal/providers/claude/mapper.ts @@ -8,7 +8,7 @@ import type { ModelSpec, } from "../../types/config.ts"; import type { SessionEventType } from "../../types/dto.ts"; -import type { SessionBindings } from "../../types/session.ts"; +import type { ManagedSessionBindings } from "../../types/session.ts"; import type { ProviderSessionEvent } from "../../types/session-event.ts"; import { compactDeep, stripAgentsMetadata } from "../../utils/comparable.ts"; import type { ResolvedAgentRefs, ResolvedDeploymentRefs } from "../interface.ts"; @@ -508,7 +508,7 @@ function extractErrorMessage(raw: Record): string { return ""; } -export function mapSession(bindings: SessionBindings): unknown { +export function mapSession(bindings: ManagedSessionBindings): unknown { const body: Record = { agent: bindings.agent_version ? { diff --git a/packages/sdk/src/internal/providers/interface.ts b/packages/sdk/src/internal/providers/interface.ts index aeafb02..83e18e6 100644 --- a/packages/sdk/src/internal/providers/interface.ts +++ b/packages/sdk/src/internal/providers/interface.ts @@ -61,6 +61,13 @@ export interface ResolvedAgentRefs { multiagent_agent_ids?: string[]; } +export interface ResolvedTemplateRefs extends ResolvedAgentRefs { + environment_id: string; + /** Qoder BYOC private-network route used by Forward Templates. */ + tunnel_id?: string; + vault_ids: string[]; +} + export interface ResolvedDeploymentRefs { agent_id: string; agent_version?: number; @@ -150,6 +157,11 @@ export interface ProviderAdapter { updateAgent(id: string, name: string, decl: AgentDecl, refs: ResolvedAgentRefs): Promise; deleteAgent(id: string): Promise; + createTemplate?(name: string, decl: AgentDecl, refs: ResolvedTemplateRefs): Promise; + updateTemplate?(id: string, name: string, decl: AgentDecl, refs: ResolvedTemplateRefs): Promise; + /** Remove the template from desired state. Qoder implements this as a soft archive. */ + archiveTemplate?(id: string): Promise; + createMemoryStore?(name: string, decl: MemoryStoreDecl): Promise; deleteMemoryStore?(id: string): Promise; diff --git a/packages/sdk/src/internal/providers/qoder/adapter.ts b/packages/sdk/src/internal/providers/qoder/adapter.ts index 9792227..2bd2c37 100644 --- a/packages/sdk/src/internal/providers/qoder/adapter.ts +++ b/packages/sdk/src/internal/providers/qoder/adapter.ts @@ -12,7 +12,13 @@ import type { } from "../../types/config.ts"; import type { CloudAgent, CloudEnvironment, CloudVault } from "../../types/dto.ts"; import type { ProviderFileInfo } from "../../types/file.ts"; -import type { ProviderSessionInfo, SessionBindings, SessionFilter, SessionListResult } from "../../types/session.ts"; +import type { + ForwardSessionBindings, + ProviderSessionInfo, + SessionBindings, + SessionFilter, + SessionListResult, +} from "../../types/session.ts"; import type { EventListOptions, EventStreamOptions, @@ -36,6 +42,7 @@ import type { RemoteResource, ResolvedAgentRefs, ResolvedDeploymentRefs, + ResolvedTemplateRefs, } from "../interface.ts"; import { extractCreatedEventId, listSessionEventsPaged } from "../session-event-response.ts"; import { @@ -58,6 +65,7 @@ import { mapCredential, mapDeployment, mapEnvironment, + mapForwardTemplate, mapMemoryStore, mapSendMessage, mapSession, @@ -68,14 +76,29 @@ import { vaultToDecl, } from "./mapper.ts"; +function deriveForwardGateway(cloudGateway?: string): string { + if (!cloudGateway) return "https://api.qoder.com/api/v1/forward"; + const trimmed = cloudGateway.replace(/\/$/, ""); + return trimmed.endsWith("/cloud") ? `${trimmed.slice(0, -"/cloud".length)}/forward` : `${trimmed}/forward`; +} + +const QODER_DEFAULT_IDENTITY_EXTERNAL_ID = "__qca_admin_identity__"; + export class QoderAdapter implements ProviderAdapter { readonly name = "qoder" as const; readonly eventResume = true; private client: QoderClient; + private forwardClient: QoderClient; private projectName: string; + private forwardSessionIds = new Set(); + private defaultForwardIdentityId?: string; - constructor(apiKey: string, gateway?: string, projectName?: string) { + constructor(apiKey: string, gateway?: string, projectName?: string, forwardGateway?: string) { this.client = new QoderClient({ apiKey, gateway }); + this.forwardClient = new QoderClient({ + apiKey, + gateway: forwardGateway ?? deriveForwardGateway(gateway), + }); this.projectName = projectName ?? ""; } @@ -94,6 +117,10 @@ export class QoderAdapter implements ProviderAdapter { }; async findResource(type: ResourceType, name: string, id?: string | null): Promise { + if (type === "template") { + const raw = await locateRemote(this.forwardClient, "/templates", name, id, (item) => item.status !== "archived"); + return raw ? toRemoteResource(raw) : null; + } const raw = await locateRemote(this.client, QoderAdapter.ENDPOINT_MAP[type], name, id, notArchived); return raw ? toRemoteResource(raw) : null; } @@ -154,7 +181,7 @@ export class QoderAdapter implements ProviderAdapter { } getDriftSupport(type: ResourceType): DriftSupport { - if (type === "agent" || type === "environment") return "full"; + if (type === "agent" || type === "environment" || type === "template") return "full"; if (type === "deployment") return "unsupported"; return QoderAdapter.ENDPOINT_MAP[type] ? "existence" : "unsupported"; } @@ -164,9 +191,16 @@ export class QoderAdapter implements ProviderAdapter { id: string | null, name: string, ): Promise { - if (type !== "agent" && type !== "environment") return null; - const endpoint = type === "agent" ? "/agents" : "/environments"; - const raw = await locateRemote(this.client, endpoint, name, id, notArchived); + if (type !== "agent" && type !== "environment" && type !== "template") return null; + const isTemplate = type === "template"; + const endpoint = type === "agent" ? "/agents" : type === "environment" ? "/environments" : "/templates"; + const raw = await locateRemote( + isTemplate ? this.forwardClient : this.client, + endpoint, + name, + id, + isTemplate ? (item) => item.status !== "archived" : notArchived, + ); if (!raw) return null; const comparable = this.normalizeRemote(type, raw); @@ -192,6 +226,7 @@ export class QoderAdapter implements ProviderAdapter { mapAgent(name, decl as AgentDecl, { skill_ids: [] }, undefined, this.projectName) as Record, ); } + if (type === "template") return null; return null; } @@ -208,6 +243,26 @@ export class QoderAdapter implements ProviderAdapter { metadata: stripAgentsMetadata(raw.metadata), }); } + if (type === "template") { + return compactDeep({ + name: raw.name, + description: raw.description, + model: raw.model, + system: raw.system, + tools: raw.tools, + mcp_servers: raw.mcp_servers, + skills: raw.skills, + multiagent: raw.multiagent, + environment_id: raw.environment_id, + tunnel_id: raw.tunnel_id, + vault_ids: Array.isArray(raw.vault_ids) + ? raw.vault_ids + : Object.keys((raw.vaults ?? {}) as Record), + files: raw.files, + environment_variables: raw.environment_variables, + metadata: stripAgentsMetadata(raw.metadata), + }); + } return compactDeep({ description: raw.description, @@ -337,6 +392,35 @@ export class QoderAdapter implements ProviderAdapter { await this.client.delete(`/agents/${id}`); } + async createTemplate(name: string, decl: AgentDecl, refs: ResolvedTemplateRefs): Promise { + await this.registerForwardVaults(refs.vault_ids); + const body = mapForwardTemplate(name, decl, refs, this.projectName); + const res = (await this.forwardClient.post("/templates", body)) as Record; + return toRemoteResource(res); + } + + async updateTemplate(id: string, name: string, decl: AgentDecl, refs: ResolvedTemplateRefs): Promise { + await this.registerForwardVaults(refs.vault_ids); + const body = mapForwardTemplate(name, decl, refs, this.projectName) as Record; + // Forward updates are merge-style; null explicitly clears a previously inherited BYOC tunnel. + if (!refs.tunnel_id) body.tunnel_id = null; + const res = (await this.forwardClient.post(`/templates/${id}`, body)) as Record; + return toRemoteResource(res); + } + + async archiveTemplate(id: string): Promise { + await this.forwardClient.post(`/templates/${id}/archive`, {}); + } + + private async registerForwardVaults(vaultIds: string[]): Promise { + for (const id of vaultIds) { + await this.forwardClient.post("/resources/registry", { + type: "vault", + resource: { id }, + }); + } + } + async createMemoryStore(name: string, decl: MemoryStoreDecl): Promise { const body = mapMemoryStore(name, decl); const res = (await this.client.post("/memory_stores", body)) as Record; @@ -440,12 +524,82 @@ export class QoderAdapter implements ProviderAdapter { } async createSession(bindings: SessionBindings): Promise { + if (bindings.delivery === "forward") { + const identityId = bindings.identity_id ?? (await this.resolveDefaultForwardIdentityId()); + const body: Record = { + identity_id: identityId, + template_id: bindings.template_id, + incremental_streaming_enabled: false, + }; + if (bindings.title) body.title = bindings.title; + if (bindings.metadata) body.metadata = bindings.metadata; + if (bindings.files?.length) { + body.resources = bindings.files.map((file) => ({ + type: "file", + file_id: file.file_id, + mount_path: file.mount_path, + })); + } + const res = (await this.forwardClient.post("/sessions", body)) as Record; + const info = toForwardSessionInfo(res, bindings); + this.forwardSessionIds.add(info.id); + return info; + } const body = mapSession(bindings); const res = (await this.client.post("/sessions", body)) as Record; return toSessionInfo(res); } + private async resolveDefaultForwardIdentityId(): Promise { + if (this.defaultForwardIdentityId) return this.defaultForwardIdentityId; + + let afterId: string | undefined; + do { + const params = new URLSearchParams({ limit: "100" }); + if (afterId) params.set("after_id", afterId); + const res = (await this.forwardClient.get(`/identities?${params}`)) as Record; + const identities = (res.data ?? []) as Record[]; + const match = identities.find( + (identity) => + identity.external_id === QODER_DEFAULT_IDENTITY_EXTERNAL_ID && + identity.enabled !== false && + identity.archived !== true, + ); + if (typeof match?.id === "string") { + this.defaultForwardIdentityId = match.id; + return match.id; + } + + const hasMore = (res.has_more as boolean | undefined) ?? false; + const nextId = hasMore ? ((res.last_id as string | null | undefined) ?? undefined) : undefined; + if (!nextId || nextId === afterId) break; + afterId = nextId; + } while (afterId); + + throw new UserError( + `Qoder default Forward Identity '${QODER_DEFAULT_IDENTITY_EXTERNAL_ID}' was not found. ` + + `Ask Qoder to provision it, set defaults.session.qoder.identity_id, or pass --identity-id.`, + ); + } + async listSessions(filter?: SessionFilter): Promise { + if (filter?.agent_id?.startsWith("tmpl_")) { + const params = new URLSearchParams({ template_id: filter.agent_id }); + if (filter.limit) params.set("limit", String(filter.limit)); + if (filter.page) params.set("after_id", filter.page); + const res = (await this.forwardClient.get(`/sessions?${params}`)) as Record; + const data = (res.data ?? []) as Record[]; + const hasMore = (res.has_more as boolean | undefined) ?? false; + const nextPage = hasMore ? ((res.last_id as string | null | undefined) ?? undefined) : undefined; + for (const item of data) { + if (typeof item.id === "string") this.forwardSessionIds.add(item.id); + } + return { + sessions: data.map((item) => toForwardSessionInfo(item)), + has_more: hasMore, + next_page: nextPage, + }; + } const params = new URLSearchParams(); if (filter?.agent_id) params.set("agent_id", filter.agent_id); if (filter?.limit) params.set("limit", String(filter.limit)); @@ -461,21 +615,52 @@ export class QoderAdapter implements ProviderAdapter { } async getSession(id: string): Promise { - const res = (await this.client.get(`/sessions/${id}`)) as Record; - return toSessionInfo(res); + if (this.forwardSessionIds.has(id)) return this.getForwardSession(id); + try { + const res = (await this.client.get(`/sessions/${id}`)) as Record; + return toSessionInfo(res); + } catch (error) { + if (!ApiError.isNotFound(error)) throw error; + return this.getForwardSession(id); + } } async deleteSession(id: string): Promise { - await this.client.delete(`/sessions/${id}`); + if (this.forwardSessionIds.has(id)) { + await this.forwardClient.post(`/sessions/${id}/archive`, {}); + return; + } + try { + await this.client.delete(`/sessions/${id}`); + } catch (error) { + if (!ApiError.isNotFound(error)) throw error; + await this.forwardClient.post(`/sessions/${id}/archive`, {}); + this.forwardSessionIds.add(id); + } } async sendSessionMessage(sessionId: string, message: string): Promise { const body = mapSendMessage(message); - const res = (await this.client.post(`/sessions/${sessionId}/events`, body)) as Record; - return extractCreatedEventId(res); + if (this.forwardSessionIds.has(sessionId)) { + const res = (await this.forwardClient.post(`/sessions/${sessionId}/events`, body)) as Record; + return extractCreatedEventId(res); + } + try { + const res = (await this.client.post(`/sessions/${sessionId}/events`, body)) as Record; + return extractCreatedEventId(res); + } catch (error) { + if (!ApiError.isNotFound(error)) throw error; + const res = (await this.forwardClient.post(`/sessions/${sessionId}/events`, body)) as Record; + this.forwardSessionIds.add(sessionId); + return extractCreatedEventId(res); + } } async *streamSessionEvents(sessionId: string, options?: EventStreamOptions): AsyncIterable { + if (this.forwardSessionIds.has(sessionId)) { + yield* this.streamForwardSessionEvents(sessionId, options); + return; + } // Client-side fallback: skip events locally without passing after_id to // the server. This avoids a conflict where the server honours after_id // (omitting that event from the stream) and the client never finds the @@ -485,24 +670,76 @@ export class QoderAdapter implements ProviderAdapter { let skipping = !!options?.after_id; const afterId = options?.after_id; - for await (const raw of this.client.sse(path)) { - if (skipping) { - const eventId = raw.id as string | undefined; - if (eventId === afterId) { - // Found our marker event; stop skipping from next event onward. - skipping = false; + try { + for await (const raw of this.client.sse(path)) { + if (skipping) { + const eventId = raw.id as string | undefined; + if (eventId === afterId) { + // Found our marker event; stop skipping from next event onward. + skipping = false; + } + continue; } - continue; + yield toSessionEvent(raw); } - yield toSessionEvent(raw); + } catch (error) { + if (!ApiError.isNotFound(error)) throw error; + this.forwardSessionIds.add(sessionId); + yield* this.streamForwardSessionEvents(sessionId, options); } } async listSessionEvents(sessionId: string, options?: EventListOptions): Promise { + if (this.forwardSessionIds.has(sessionId)) return this.listForwardSessionEvents(sessionId, options); // Qoder additionally accepts the Agents-style `after_id` resume marker, so it is // forwarded (claude/bailian reject it); shared page-cursor handling lives in // listSessionEventsPaged. - return listSessionEventsPaged(this.client, sessionId, options, toSessionEvent, { forwardAfterId: true }); + try { + return await listSessionEventsPaged(this.client, sessionId, options, toSessionEvent, { forwardAfterId: true }); + } catch (error) { + if (!ApiError.isNotFound(error)) throw error; + this.forwardSessionIds.add(sessionId); + return this.listForwardSessionEvents(sessionId, options); + } + } + + private async getForwardSession(id: string): Promise { + const res = (await this.forwardClient.get(`/sessions/${id}`)) as Record; + this.forwardSessionIds.add(id); + return toForwardSessionInfo(res); + } + + private async *streamForwardSessionEvents( + sessionId: string, + options?: EventStreamOptions, + ): AsyncIterable { + const headers = options?.after_id ? { "Last-Event-ID": options.after_id } : undefined; + for await (const raw of this.forwardClient.sse(`/sessions/${sessionId}/events/stream`, { headers })) { + yield toSessionEvent(raw); + } + } + + private async listForwardSessionEvents( + sessionId: string, + options?: EventListOptions, + ): Promise { + const params = new URLSearchParams(); + if (options?.limit) params.set("limit", String(options.limit)); + if (options?.order) params.set("order", options.order); + const afterId = options?.after_id ?? options?.page_token ?? options?.page; + if (afterId) params.set("after_id", afterId); + const query = params.toString(); + const res = (await this.forwardClient.get(`/sessions/${sessionId}/events${query ? `?${query}` : ""}`)) as Record< + string, + unknown + >; + const data = (res.data ?? []) as Record[]; + const hasMore = (res.has_more as boolean | undefined) ?? false; + return { + events: data.map(toSessionEvent), + has_more: hasMore, + next_page: hasMore ? ((res.last_id as string | null | undefined) ?? undefined) : undefined, + }; } async listModels(): Promise { @@ -547,6 +784,28 @@ export function toSessionInfo(res: Record): ProviderSessionInfo return buildSessionInfo(res, (r) => (r.memory_store_ids as string[]) ?? []); } +function toForwardSessionInfo(res: Record, bindings?: ForwardSessionBindings): ProviderSessionInfo { + const template = (res.template ?? {}) as Record; + const templateId = + (res.template_id as string | undefined) ?? (template.id as string | undefined) ?? bindings?.template_id ?? ""; + const environmentId = + (res.environment_id as string | undefined) ?? (template.environment_id as string | undefined) ?? ""; + return { + id: res.id as string, + agent_id: templateId, + environment_id: environmentId, + tunnel_id: (res.tunnel_id as string | undefined) ?? (template.tunnel_id as string | undefined), + status: (res.status as string | undefined) ?? "unknown", + title: res.title as string | undefined, + vault_ids: (res.vault_ids as string[] | undefined) ?? [], + memory_store_ids: (res.memory_store_ids as string[] | undefined) ?? [], + created_at: (res.created_at as string | undefined) ?? new Date(0).toISOString(), + updated_at: + (res.updated_at as string | undefined) ?? (res.created_at as string | undefined) ?? new Date(0).toISOString(), + attributes: res, + }; +} + function normalizeModel(value: unknown): unknown { if (value && typeof value === "object" && "id" in value) { return (value as Record).id; diff --git a/packages/sdk/src/internal/providers/qoder/capabilities.ts b/packages/sdk/src/internal/providers/qoder/capabilities.ts index e0099d5..000c2f7 100644 --- a/packages/sdk/src/internal/providers/qoder/capabilities.ts +++ b/packages/sdk/src/internal/providers/qoder/capabilities.ts @@ -5,6 +5,7 @@ export const QODER_CAPABILITIES: ProviderCapabilities = { vault: { tier: "native", reason: "vaults + MCP credentials" }, skill: { tier: "native", reason: "skills API with zip upload" }, agent: { tier: "native", reason: "agents API" }, + template: { tier: "native", reason: "Forward Templates API" }, memory_store: { tier: "native", reason: "memory_stores API" }, mcp_server: { tier: "native", reason: "mcp_servers field on agent" }, multiagent: { diff --git a/packages/sdk/src/internal/providers/qoder/client.ts b/packages/sdk/src/internal/providers/qoder/client.ts index 3484296..2d9f0c9 100644 --- a/packages/sdk/src/internal/providers/qoder/client.ts +++ b/packages/sdk/src/internal/providers/qoder/client.ts @@ -17,6 +17,10 @@ export class QoderClient extends BaseApiClient { this.apiKey = config.apiKey; } + protected override isConflict(status: number): boolean { + return status === 409; + } + protected headers(): Record { return { "Content-Type": "application/json", diff --git a/packages/sdk/src/internal/providers/qoder/config.ts b/packages/sdk/src/internal/providers/qoder/config.ts index 1daf3d0..c825fbc 100644 --- a/packages/sdk/src/internal/providers/qoder/config.ts +++ b/packages/sdk/src/internal/providers/qoder/config.ts @@ -3,6 +3,7 @@ import { z } from "zod"; export const qoderConfigSchema = z.object({ api_key: z.string(), gateway: z.string().optional(), + forward_gateway: z.string().optional(), }); export type QoderConfig = z.infer; diff --git a/packages/sdk/src/internal/providers/qoder/index.ts b/packages/sdk/src/internal/providers/qoder/index.ts index 5afe174..600c3e4 100644 --- a/packages/sdk/src/internal/providers/qoder/index.ts +++ b/packages/sdk/src/internal/providers/qoder/index.ts @@ -9,6 +9,6 @@ registerProvider({ capabilities: QODER_CAPABILITIES, createAdapter: (config, projectName) => { const c = config as QoderConfig; - return new QoderAdapter(c.api_key, c.gateway, projectName); + return new QoderAdapter(c.api_key, c.gateway, projectName, c.forward_gateway); }, }); diff --git a/packages/sdk/src/internal/providers/qoder/mapper.ts b/packages/sdk/src/internal/providers/qoder/mapper.ts index 02c6a52..a4b8450 100644 --- a/packages/sdk/src/internal/providers/qoder/mapper.ts +++ b/packages/sdk/src/internal/providers/qoder/mapper.ts @@ -10,11 +10,11 @@ import type { VaultDecl, } from "../../types/config.ts"; import type { SessionEventType } from "../../types/dto.ts"; -import type { SessionBindings } from "../../types/session.ts"; +import type { ManagedSessionBindings } from "../../types/session.ts"; import type { ProviderSessionEvent } from "../../types/session-event.ts"; import { compactDeep, stripAgentsMetadata } from "../../utils/comparable.ts"; import { resolveSandboxMountPath } from "../../utils/sandbox-mount.ts"; -import type { ResolvedAgentRefs, ResolvedDeploymentRefs } from "../interface.ts"; +import type { ResolvedAgentRefs, ResolvedDeploymentRefs, ResolvedTemplateRefs } from "../interface.ts"; import { injectMetadata, secretPlaceholder, slug } from "../sync-mapping.ts"; // Qoder's API expects builtin tool names in PascalCase. The configuration layer @@ -411,6 +411,84 @@ export function mapAgent( return body; } +/** Compile one logical Agent declaration into Qoder's Forward Template baseline. */ +export function mapForwardTemplate( + name: string, + decl: AgentDecl, + refs: ResolvedTemplateRefs, + projectName?: string, +): unknown { + let model: string; + if (typeof decl.model === "string") { + model = decl.model; + } else { + const qoderModel: ModelSpec | undefined = decl.model.qoder; + if (!qoderModel) throw new UserError(`No Qoder model specified for template '${name}'`); + model = typeof qoderModel === "string" ? qoderModel : qoderModel.id; + } + + const body: Record = { + name, + description: decl.description ?? "", + model, + system: decl.instructions, + environment_id: refs.environment_id, + vault_ids: refs.vault_ids, + }; + if (refs.tunnel_id) body.tunnel_id = refs.tunnel_id; + if (projectName) body.metadata = injectMetadata(decl.metadata, projectName, name); + else body.metadata = decl.metadata ?? {}; + + if (decl.tools) { + const permissions = decl.tools.permissions ?? {}; + body.tools = [ + { + type: "agent_toolset_20260401", + configs: decl.tools.builtin.map((tool) => { + const normalized = normalizeToolNameForQoder(tool); + const policy = permissions[tool] ?? permissions[tool.toLowerCase()] ?? permissions[normalized]; + return { + name: normalized, + enabled: true, + ...(policy ? { permission_policy: { type: policy === "ask" ? "always_ask" : "always_allow" } } : {}), + }; + }), + }, + ]; + } else { + body.tools = [{ type: "agent_toolset_20260401" }]; + } + + body.mcp_servers = (decl.mcp_servers ?? []).map((server) => { + if (server.type === "official" || !server.url) { + throw new UserError(`Qoder MCP server '${server.name}' requires a url`); + } + return { name: server.name, type: "http", url: server.url }; + }); + if (decl.mcp_servers?.length) { + const tools = body.tools as unknown[]; + for (const server of decl.mcp_servers) { + const toolkit = decl.tools?.mcp?.find((item) => item.mcp_server_name === server.name); + if (toolkit) { + tools.push({ + type: "mcp_toolset", + mcp_server_name: server.name, + configs: toolkit.configs, + }); + } + } + } + + body.skills = refs.skill_ids.map((skill) => ({ + type: skill.type === "official" ? "qoder" : skill.type, + skill_id: skill.skill_id, + ...(skill.version ? { version: skill.version } : {}), + enabled: true, + })); + + return body; +} + export function mapSendMessage(text: string): unknown { return { events: [{ type: "user.message", content: [{ type: "text", text }] }], @@ -422,9 +500,12 @@ const QODER_EVENT_MAP: Record = { "user.message": "message", "agent.tool_use": "tool_use", "agent.tool_result": "tool_result", + "agent.mcp_tool_use": "tool_use", + "agent.mcp_tool_result": "tool_result", "agent.thinking": "thinking", "session.status_idle": "status", "session.status_running": "status", + "session.status_terminated": "status", "session.thread_status_idle": "status", "session.error": "error", }; @@ -518,7 +599,7 @@ function extractErrorMessage(raw: Record): string { return ""; } -export function mapSession(bindings: SessionBindings): unknown { +export function mapSession(bindings: ManagedSessionBindings): unknown { const body: Record = { agent: bindings.agent_id, environment_id: bindings.environment_id, diff --git a/packages/sdk/src/internal/providers/registry.ts b/packages/sdk/src/internal/providers/registry.ts index 686e372..308f8e8 100644 --- a/packages/sdk/src/internal/providers/registry.ts +++ b/packages/sdk/src/internal/providers/registry.ts @@ -83,6 +83,7 @@ const PROVIDER_ENV_VARS: Record; deleteAgent(id: string): Promise; + createTemplate?(name: string, decl: AgentDecl, refs: ResolvedTemplateRefs): Promise; + updateTemplate?(id: string, name: string, decl: AgentDecl, refs: ResolvedTemplateRefs): Promise; + archiveTemplate?(id: string): Promise; + // Optional: only providers whose capability matrix marks `memory_store` supported // implement these. The registry validates the matrix↔method match; unsupported // providers omit them entirely (no throw-stubs). diff --git a/packages/sdk/src/internal/session/session-manager.ts b/packages/sdk/src/internal/session/session-manager.ts index b73b14e..dd050f4 100644 --- a/packages/sdk/src/internal/session/session-manager.ts +++ b/packages/sdk/src/internal/session/session-manager.ts @@ -1,3 +1,4 @@ +import { resolveAgentMaterialization } from "../core/agent-materialization.ts"; import { UserError } from "../errors.ts"; import { requireRef } from "../executor/resolver.ts"; import type { IStateManager } from "../state/state-manager.ts"; @@ -5,6 +6,8 @@ import type { AgentDecl, ProjectConfig } from "../types/config.ts"; import type { SessionBindings } from "../types/session.ts"; export interface SessionCreateOptions { + /** Explicit Qoder Forward Identity id. Overrides defaults.session.qoder.identity_id. */ + identityId?: string; environment?: string; /** * Explicit remote environment id. When set, it is bound directly (bypassing the @@ -64,6 +67,18 @@ export function buildSessionBindings( const available = Object.keys(config.agents ?? {}).join(", "); throw new UserError(`Agent '${agentName}' not found in config. Available agents: ${available || "(none)"}`); } + if (resolveAgentMaterialization(provider, agent).resourceType === "template") { + const templateId = requireRef(state, { type: "template", name: agentName, provider }); + const identityId = options.identityId ?? config.defaults?.session?.qoder?.identity_id; + return { + delivery: "forward", + template_id: templateId, + ...(identityId ? { identity_id: identityId } : {}), + files: (options.files ?? []).map((file) => ({ file_id: file.fileId, mount_path: file.mountPath })), + title: options.title, + metadata: options.metadata, + }; + } const agentId = requireRef(state, { type: "agent", name: agentName, provider }); const agentState = state.getResource({ type: "agent", name: agentName, provider }); diff --git a/packages/sdk/src/internal/types/config.ts b/packages/sdk/src/internal/types/config.ts index 78a0f43..82c5e8f 100644 --- a/packages/sdk/src/internal/types/config.ts +++ b/packages/sdk/src/internal/types/config.ts @@ -16,6 +16,12 @@ export interface ProjectConfig { export interface DefaultsConfig { provider?: string; + session?: { + qoder?: { + /** Existing Qoder Forward Identity used only as the CLI/session runtime default. */ + identity_id?: string; + }; + }; } // --- Environment --- @@ -143,6 +149,12 @@ export interface AgentDecl { memory_stores?: string[]; multiagent?: MultiagentDecl; metadata?: Record; + /** Provider-specific remote materialization. Omitted means the existing managed Agent resource. */ + delivery?: Record; +} + +export interface AgentDeliveryDecl { + type: "managed" | "forward"; } export type AgentSkillDecl = string | AgentSkillRefDecl; diff --git a/packages/sdk/src/internal/types/dto.ts b/packages/sdk/src/internal/types/dto.ts index 410e93a..bbae310 100644 --- a/packages/sdk/src/internal/types/dto.ts +++ b/packages/sdk/src/internal/types/dto.ts @@ -6,6 +6,7 @@ export const ResourceTypeSchema = z.enum([ "memory_store", "skill", "agent", + "template", "deployment", "file", ]); diff --git a/packages/sdk/src/internal/types/session.ts b/packages/sdk/src/internal/types/session.ts index 0e364c7..bb96299 100644 --- a/packages/sdk/src/internal/types/session.ts +++ b/packages/sdk/src/internal/types/session.ts @@ -5,7 +5,16 @@ export interface SessionFileResource { mount_path: string; } -export interface SessionBindings { +interface CommonSessionBindings { + /** Uploaded files to mount in the session sandbox. */ + files?: SessionFileResource[]; + title?: string; + metadata?: Record; +} + +export interface ManagedSessionBindings extends CommonSessionBindings { + /** Omitted for backward compatibility; omitted always means managed. */ + delivery?: "managed"; agent_id: string; agent_version?: number; /** Cloud sandbox id. Every provider runs sessions inside an environment. */ @@ -14,11 +23,18 @@ export interface SessionBindings { tunnel_id?: string; vault_ids: string[]; memory_store_ids: string[]; - files?: SessionFileResource[]; - title?: string; - metadata?: Record; } +export interface ForwardSessionBindings extends CommonSessionBindings { + delivery: "forward"; + /** Qoder Forward Template selected from the applied Agent materialization. */ + template_id: string; + /** Existing business Identity supplied by the session caller; omitted uses the provider's default Identity. */ + identity_id?: string; +} + +export type SessionBindings = ManagedSessionBindings | ForwardSessionBindings; + export interface ProviderSessionInfo { id: string; agent_id: string; diff --git a/packages/sdk/tests/unit/core-session-runtime.test.ts b/packages/sdk/tests/unit/core-session-runtime.test.ts index 5cb7e1b..ff16040 100644 --- a/packages/sdk/tests/unit/core-session-runtime.test.ts +++ b/packages/sdk/tests/unit/core-session-runtime.test.ts @@ -129,6 +129,45 @@ function ctx(provider: ProviderAdapter): ProjectRuntimeContext { } describe("core session runtime", () => { + test("starts a Forward session with the caller's explicit Identity", async () => { + const forwardConfig = config(); + forwardConfig.agents!.assistant!.delivery = { qoder: { type: "forward" } }; + const forwardState = StateManager.initialize("/tmp/core-forward-session-runtime-state.json"); + forwardState.setResource({ + address: { type: "template", name: "assistant", provider: "qoder" }, + remote_id: "tmpl_1", + content_hash: "h", + }); + let receivedBindings: Record | undefined; + const provider = { + ...adapter("qoder", [], true), + createSession: async (bindings: Record) => { + receivedBindings = bindings; + return session("sess_forward"); + }, + }; + const runtime: ProjectRuntimeContext = { + configPath: "/tmp/agents.yaml", + statePath: "/tmp/agents.state.json", + projectName: "test", + config: { ...forwardConfig, _resolved: true }, + state: forwardState, + providers: new Map([["qoder", provider as ProviderAdapter]]), + }; + + const run = await startSessionRun(runtime, "do work", { + agent: "assistant", + identityId: "idn_runtime_user", + }); + + expect(run.session.id).toBe("sess_forward"); + expect(receivedBindings).toMatchObject({ + delivery: "forward", + template_id: "tmpl_1", + identity_id: "idn_runtime_user", + }); + }); + test("startRun creates a new session and streams events (resume adapter)", async () => { const calls: string[] = []; const provider = adapter("qoder", calls, true); diff --git a/packages/sdk/tests/unit/qoder-forward-template.test.ts b/packages/sdk/tests/unit/qoder-forward-template.test.ts new file mode 100644 index 0000000..d5af27d --- /dev/null +++ b/packages/sdk/tests/unit/qoder-forward-template.test.ts @@ -0,0 +1,471 @@ +import { describe, expect, test } from "bun:test"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { validateProjectConfig } from "../../src/internal/core/validate-config.ts"; +import { executePlan } from "../../src/internal/executor/executor.ts"; +import { buildDependencyGraph } from "../../src/internal/graph/dependency.ts"; +import { computeResourceHash } from "../../src/internal/planner/hasher.ts"; +import { buildPlan } from "../../src/internal/planner/planner.ts"; +import type { ProviderAdapter } from "../../src/internal/providers/interface.ts"; +import { QoderAdapter } from "../../src/internal/providers/qoder/adapter.ts"; +import { mapForwardTemplate } from "../../src/internal/providers/qoder/mapper.ts"; +import { buildSessionBindings } from "../../src/internal/session/session-manager.ts"; +import { StateManager } from "../../src/internal/state/state-manager.ts"; +import type { ProjectConfig } from "../../src/internal/types/config.ts"; +import type { StateFile } from "../../src/internal/types/state.ts"; +import "../../src/internal/providers/all.ts"; + +function tmpPath(label: string): string { + return join(tmpdir(), `${label}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); +} + +function forwardConfig(): ProjectConfig { + return { + version: "1", + providers: { qoder: { api_key: "test" } }, + defaults: { provider: "qoder" }, + environments: { + byoc: { environment_id: "env_byoc", config: { type: "self_hosted" } }, + }, + tunnels: { internal: { tunnel_id: "tnl_internal" } }, + vaults: { + mcp: { + display_name: "MCP", + credentials: [ + { + name: "coop", + type: "static_bearer", + mcp_server_url: "https://mcp.example.test/mcp", + access_token: "secret", + }, + ], + }, + }, + agents: { + assistant: { + description: "BYOC assistant", + model: { qoder: "auto" }, + instructions: "Use internal tools.", + environment: "byoc", + tunnel: "internal", + vault: "mcp", + tools: { builtin: ["Bash", "Read"], permissions: { bash: "ask" } }, + mcp_servers: [{ name: "coop", type: "http", url: "https://mcp.example.test/mcp" }], + delivery: { qoder: { type: "forward" } }, + }, + }, + }; +} + +describe("Qoder Forward Template declaration", () => { + test("materializes a forward-delivered agent as a template graph resource", async () => { + const config = forwardConfig(); + const graph = buildDependencyGraph(config, ["qoder"]); + expect([...graph.nodes.keys()]).toContain("qoder.template.assistant"); + expect([...graph.nodes.keys()]).not.toContain("qoder.agent.assistant"); + expect([...graph.edges.get("qoder.template.assistant")!].sort()).toEqual([ + "qoder.environment.byoc", + "qoder.vault.mcp", + ]); + + const plan = await buildPlan(config, { resources: [] }); + expect(plan.diagnostics).toEqual([]); + expect(plan.actions.map((action) => `${action.action}:${action.address.type}:${action.address.name}`)).toEqual([ + "create:environment:byoc", + "create:vault:mcp", + "create:template:assistant", + ]); + }); + + test("keeps managed delivery as the backward-compatible default", async () => { + const config = forwardConfig(); + delete config.agents!.assistant!.delivery; + const plan = await buildPlan(config, { resources: [] }); + expect(plan.actions.some((action) => action.address.type === "agent")).toBe(true); + expect(plan.actions.some((action) => action.address.type === "template")).toBe(false); + }); + + test("plans create-template then delete-agent when delivery changes", async () => { + const config = forwardConfig(); + const state: StateFile = { + resources: [ + { + address: { type: "agent", name: "assistant", provider: "qoder" }, + remote_id: "agent_old", + content_hash: "old", + }, + ], + }; + const plan = await buildPlan(config, state); + const templateIndex = plan.actions.findIndex( + (action) => action.action === "create" && action.address.type === "template", + ); + const deleteIndex = plan.actions.findIndex( + (action) => action.action === "delete" && action.address.type === "agent", + ); + expect(templateIndex).toBeGreaterThanOrEqual(0); + expect(deleteIndex).toBeGreaterThan(templateIndex); + expect(plan.actions[deleteIndex]!.dependencies).toEqual([ + { type: "template", name: "assistant", provider: "qoder" }, + ]); + }); + + test("keeps the old managed Agent when replacement Template creation fails", async () => { + const config = forwardConfig(); + delete config.agents!.assistant!.vault; + delete config.vaults; + const state = StateManager.initialize(tmpPath("forward-replacement")); + state.setResource({ + address: { type: "agent", name: "assistant", provider: "qoder" }, + remote_id: "agent_old", + content_hash: "old", + }); + const plan = await buildPlan(config, state.getStateFile()); + let deleted = false; + const provider = { + name: "qoder", + findResource: async () => null, + createTemplate: async () => { + throw new Error("template create failed"); + }, + updateTemplate: async () => ({ id: "tmpl_1", type: "template" }), + archiveTemplate: async () => {}, + deleteAgent: async () => { + deleted = true; + }, + } as unknown as ProviderAdapter; + + const result = await executePlan(plan, { + config, + providers: new Map([["qoder", provider]]), + state, + }); + + expect(result.partial).toBe(true); + expect(deleted).toBe(false); + expect(state.getResource({ type: "agent", name: "assistant", provider: "qoder" })?.remote_id).toBe("agent_old"); + }); + + test("includes resolved tunnel and vault ids in the desired hash", async () => { + const config = forwardConfig(); + const lookup = { + getResource: ({ type }: { type: string }) => + type === "vault" ? { remote_id: "vault_one" } : { remote_id: "unused" }, + }; + const address = { type: "template", name: "assistant", provider: "qoder" } as const; + const first = await computeResourceHash(address, config, undefined, lookup); + config.tunnels!.internal!.tunnel_id = "tnl_changed"; + const second = await computeResourceHash(address, config, undefined, lookup); + expect(second).not.toBe(first); + }); +}); + +describe("Qoder Forward Template mapping and lifecycle", () => { + test("maps BYOC bindings and tool permissions", () => { + const decl = forwardConfig().agents!.assistant!; + const body = mapForwardTemplate("assistant", decl, { + environment_id: "env_byoc", + tunnel_id: "tnl_internal", + vault_ids: ["vault_mcp"], + skill_ids: [], + }) as Record; + expect(body).toMatchObject({ + name: "assistant", + model: "auto", + environment_id: "env_byoc", + tunnel_id: "tnl_internal", + vault_ids: ["vault_mcp"], + mcp_servers: [{ name: "coop", type: "http", url: "https://mcp.example.test/mcp" }], + }); + expect(body.tools[0].configs).toEqual([ + { + name: "Bash", + enabled: true, + permission_policy: { type: "always_ask" }, + }, + { name: "Read", enabled: true }, + ]); + }); + + test("uses the Forward endpoints for create, update, archive, and lookup", async () => { + const calls: Array<{ method: string; path: string; body?: unknown }> = []; + const adapter = new QoderAdapter("pt-test") as any; + adapter.forwardClient = { + post: async (path: string, body: unknown) => { + calls.push({ method: "POST", path, body }); + return { id: "tmpl_1", type: "template", name: "assistant", status: "active" }; + }, + get: async (path: string) => { + calls.push({ method: "GET", path }); + return { id: "tmpl_1", type: "template", name: "assistant", status: "active" }; + }, + getAllPaged: async () => [], + }; + const decl = forwardConfig().agents!.assistant!; + const refs = { + environment_id: "env_byoc", + tunnel_id: "tnl_internal", + vault_ids: ["vault_mcp"], + skill_ids: [], + }; + + await adapter.createTemplate("assistant", decl, refs); + await adapter.updateTemplate("tmpl_1", "assistant", decl, refs); + await adapter.archiveTemplate("tmpl_1"); + await adapter.findResource("template", "assistant", "tmpl_1"); + + expect(calls.map(({ method, path }) => `${method} ${path}`)).toEqual([ + "POST /resources/registry", + "POST /templates", + "POST /resources/registry", + "POST /templates/tmpl_1", + "POST /templates/tmpl_1/archive", + "GET /templates/tmpl_1", + ]); + expect(calls.filter((call) => call.path === "/resources/registry").map((call) => call.body)).toEqual([ + { type: "vault", resource: { id: "vault_mcp" } }, + { type: "vault", resource: { id: "vault_mcp" } }, + ]); + }); + + test("reads a full Template drift snapshot including BYOC bindings", async () => { + const adapter = new QoderAdapter("pt-test") as any; + adapter.forwardClient = { + get: async () => ({ + id: "tmpl_1", + type: "template", + status: "active", + name: "assistant", + model: "auto", + system: "Use internal tools.", + environment_id: "env_byoc", + tunnel_id: "tnl_internal", + vault_ids: ["vault_mcp"], + metadata: { "agents.project": "ignored", business: "kept" }, + }), + getAllPaged: async () => [], + }; + + const remote = await adapter.readComparableResource("template", "tmpl_1", "assistant"); + expect(remote?.comparable).toMatchObject({ + name: "assistant", + environment_id: "env_byoc", + tunnel_id: "tnl_internal", + vault_ids: ["vault_mcp"], + metadata: { business: "kept" }, + }); + }); + + test("uses the explicit Identity without creating one and routes sessions through the Forward gateway", async () => { + const calls: Array<{ method: string; path: string; body?: unknown; options?: unknown }> = []; + const adapter = new QoderAdapter("pt-test") as any; + adapter.client = { + post: async (path: string) => { + throw new Error(`managed gateway must not receive ${path}`); + }, + }; + adapter.forwardClient = { + get: async (path: string) => { + calls.push({ method: "GET", path }); + if (path.startsWith("/sessions/sess_forward/events?")) { + return { + data: [ + { id: "evt_tool", type: "agent.mcp_tool_use", mcp_server_name: "coop", name: "search" }, + { id: "evt_idle", type: "session.status_idle" }, + ], + has_more: false, + last_id: "evt_idle", + }; + } + throw new Error(`unexpected GET ${path}`); + }, + post: async (path: string, body: unknown) => { + calls.push({ method: "POST", path, body }); + if (path === "/sessions") { + return { + id: "sess_forward", + status: "idle", + template: { id: "tmpl_1" }, + identity_id: "idn_cli", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:01Z", + }; + } + if (path.endsWith("/events")) return { data: [{ id: "evt_user" }] }; + return {}; + }, + sse: async function* (path: string, options?: unknown) { + calls.push({ method: "SSE", path, options }); + yield { id: "evt_idle", type: "session.status_idle" }; + }, + }; + + const created = await adapter.createSession({ + delivery: "forward", + template_id: "tmpl_1", + identity_id: "idn_zhang", + title: "Forward test", + }); + const eventId = await adapter.sendSessionMessage(created.id, "hello"); + const listed = await adapter.listSessionEvents(created.id, { limit: 100 }); + const streamed = []; + for await (const event of adapter.streamSessionEvents(created.id, { after_id: eventId })) streamed.push(event); + await adapter.deleteSession(created.id); + + expect(created).toMatchObject({ id: "sess_forward", agent_id: "tmpl_1", status: "idle" }); + expect(calls.find((call) => call.path === "/sessions")?.body).toMatchObject({ + identity_id: "idn_zhang", + template_id: "tmpl_1", + }); + expect(eventId).toBe("evt_user"); + expect(listed.events[0]).toMatchObject({ type: "tool_use", tool_name: "search" }); + expect(streamed.at(-1)).toMatchObject({ type: "status", status: "idle" }); + expect(calls.map(({ method, path }) => `${method} ${path}`)).toEqual([ + "POST /sessions", + "POST /sessions/sess_forward/events", + "GET /sessions/sess_forward/events?limit=100", + "SSE /sessions/sess_forward/events/stream", + "POST /sessions/sess_forward/archive", + ]); + }); + + test("resolves Qoder's system Identity external_id to its real id and caches it", async () => { + const calls: Array<{ method: string; path: string; body?: Record }> = []; + const adapter = new QoderAdapter("pt-test") as any; + adapter.forwardClient = { + get: async (path: string) => { + calls.push({ method: "GET", path }); + if (path === "/identities?limit=100") { + return { + data: [{ id: "idn_other", external_id: "other" }], + has_more: true, + last_id: "idn_other", + }; + } + if (path === "/identities?limit=100&after_id=idn_other") { + return { + data: [{ id: "idn_admin", external_id: "__qca_admin_identity__", enabled: true }], + has_more: false, + }; + } + throw new Error(`unexpected GET ${path}`); + }, + post: async (path: string, body: Record) => { + expect(path).toBe("/sessions"); + calls.push({ method: "POST", path, body }); + return { + id: `sess_default_identity_${calls.length}`, + status: "idle", + template: { id: "tmpl_1" }, + identity_id: "idn_admin", + created_at: "2026-01-01T00:00:00Z", + }; + }, + }; + + await adapter.createSession({ delivery: "forward", template_id: "tmpl_1" }); + await adapter.createSession({ delivery: "forward", template_id: "tmpl_1" }); + + expect(calls.map(({ method, path }) => `${method} ${path}`)).toEqual([ + "GET /identities?limit=100", + "GET /identities?limit=100&after_id=idn_other", + "POST /sessions", + "POST /sessions", + ]); + expect(calls.filter((call) => call.method === "POST").map((call) => call.body?.identity_id)).toEqual([ + "idn_admin", + "idn_admin", + ]); + }); + + test("fails clearly when Qoder's system Identity is not provisioned", async () => { + const adapter = new QoderAdapter("pt-test") as any; + adapter.forwardClient = { + get: async () => ({ data: [], has_more: false }), + post: async () => { + throw new Error("session must not be created"); + }, + }; + + await expect(adapter.createSession({ delivery: "forward", template_id: "tmpl_1" })).rejects.toThrow( + /__qca_admin_identity__.*--identity-id/, + ); + }); +}); + +describe("Forward delivery validation and runtime isolation", () => { + test("rejects forward delivery on providers without the capability", () => { + const config = forwardConfig(); + config.providers = { bailian: {} }; + config.defaults = { provider: "bailian" }; + config.agents!.assistant!.delivery = { bailian: { type: "forward" } }; + const diagnostics = validateProjectConfig(config); + expect(diagnostics.some((item) => item.code === "bailian.agent.delivery.forward.unsupported")).toBe(true); + }); + + test("rejects managed deployments that reference a forward template", () => { + const config = forwardConfig(); + config.deployments = { + job: { agent: "assistant", initial_events: [{ type: "user.message", content: "run" }] }, + }; + const diagnostics = validateProjectConfig(config); + expect(diagnostics.some((item) => item.code === "qoder.deployment.forward_template.unsupported")).toBe(true); + }); + + test("builds Forward session bindings from the explicit YAML identity default", () => { + const config = forwardConfig(); + config.defaults = { + provider: "qoder", + session: { qoder: { identity_id: "idn_zhang" } }, + } as ProjectConfig["defaults"]; + const state = StateManager.initialize(tmpPath("forward-session")); + state.setResource({ + address: { type: "template", name: "assistant", provider: "qoder" }, + remote_id: "tmpl_1", + content_hash: "h", + }); + expect(buildSessionBindings("assistant", config, "qoder", state)).toMatchObject({ + delivery: "forward", + template_id: "tmpl_1", + identity_id: "idn_zhang", + }); + }); + + test("uses different caller Identities with the same applied Template", () => { + const config = forwardConfig(); + config.defaults = { + provider: "qoder", + session: { qoder: { identity_id: "idn_zhang" } }, + }; + const state = StateManager.initialize(tmpPath("forward-multi-user")); + state.setResource({ + address: { type: "template", name: "assistant", provider: "qoder" }, + remote_id: "tmpl_shared", + content_hash: "h", + }); + + const zhang = buildSessionBindings("assistant", config, "qoder", state); + const li = buildSessionBindings("assistant", config, "qoder", state, { identityId: "idn_li" }); + + expect(zhang).toMatchObject({ template_id: "tmpl_shared", identity_id: "idn_zhang" }); + expect(li).toMatchObject({ template_id: "tmpl_shared", identity_id: "idn_li" }); + }); + + test("allows Qoder to use its default Identity when none is configured", () => { + const config = forwardConfig(); + const state = StateManager.initialize(tmpPath("forward-missing-identity")); + state.setResource({ + address: { type: "template", name: "assistant", provider: "qoder" }, + remote_id: "tmpl_1", + content_hash: "h", + }); + + expect(buildSessionBindings("assistant", config, "qoder", state)).toEqual({ + delivery: "forward", + template_id: "tmpl_1", + files: [], + title: undefined, + metadata: undefined, + }); + }); +}); From 90c23429ee1661e1f9bd3203cd85561177ef0944 Mon Sep 17 00:00:00 2001 From: heimanba <371510756@qq.com> Date: Mon, 20 Jul 2026 17:40:57 +0800 Subject: [PATCH 2/2] Rerun CI when PR descriptions change Change-Id: I2126a26be4cbc875389c1570726cad4f599ef502 --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b94ff70..520b9f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: pull_request: + types: [opened, synchronize, reopened, edited] push: branches: [main]