269 lines
10 KiB
TypeScript
269 lines
10 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { renderSessionSendError, renderSessionSendResult } from "./agentrun/session-send-render";
|
|
import type { AgentRunOutputMode, AgentRunResourceOptions } from "./agentrun/utils";
|
|
|
|
const command = "agentrun send session/sess_admission_fixture --prompt-stdin";
|
|
const sessionId = "sess_admission_fixture";
|
|
const runId = "run_admission_fixture";
|
|
const commandId = "cmd_admission_fixture";
|
|
const dispatchIntentId = "rdi_admission_fixture";
|
|
const plannedRunnerJobId = "rjob_planned_fixture";
|
|
|
|
const durableAdmissionFixture: Record<string, unknown> = {
|
|
ok: true,
|
|
command,
|
|
data: {
|
|
action: "session-send",
|
|
dryRun: false,
|
|
mutation: true,
|
|
partialWrite: false,
|
|
sessionId,
|
|
decision: "turn",
|
|
internalCommandType: "turn",
|
|
run: { id: runId, status: "queued", sessionRef: { sessionId } },
|
|
command: {
|
|
id: commandId,
|
|
state: "pending",
|
|
type: "turn",
|
|
dispatchIntent: {
|
|
id: dispatchIntentId,
|
|
state: "pending",
|
|
runnerJobId: plannedRunnerJobId,
|
|
actualRunnerJobId: null,
|
|
attemptCount: 0,
|
|
durable: true,
|
|
},
|
|
},
|
|
runnerJob: {
|
|
action: "runner-dispatch-admitted",
|
|
state: "pending",
|
|
runId,
|
|
commandId,
|
|
runnerJobId: plannedRunnerJobId,
|
|
dispatchIntentId,
|
|
durable: true,
|
|
},
|
|
runnerAdmission: {
|
|
mode: "durable-dispatch-intent",
|
|
state: "pending",
|
|
reason: "session-runner-admission-recovered",
|
|
disposition: "recovered",
|
|
failureKind: null,
|
|
sessionId,
|
|
runId,
|
|
commandId,
|
|
dispatchIntentId,
|
|
plannedRunnerJobId,
|
|
actualRunnerJobId: null,
|
|
attemptCount: 0,
|
|
durable: true,
|
|
mutation: true,
|
|
partialWrite: false,
|
|
recoveredPriorPartialWrite: true,
|
|
retryable: true,
|
|
retryAuthority: "dispatcher",
|
|
recoveryActions: [
|
|
{ operation: "describe", resourceKind: "session", resourceName: sessionId },
|
|
{ operation: "events", resourceKind: "run", resourceName: runId, runId, afterSeq: 0, limit: 100 },
|
|
],
|
|
valuesPrinted: false,
|
|
},
|
|
pollActions: [
|
|
{ operation: "logs", resourceKind: "session", resourceName: sessionId, sessionId, limit: 100 },
|
|
{ operation: "cancel", resourceKind: "session", resourceName: sessionId },
|
|
],
|
|
valuesPrinted: false,
|
|
},
|
|
};
|
|
|
|
const legacySuccessFixture: Record<string, unknown> = {
|
|
ok: true,
|
|
command,
|
|
data: {
|
|
action: "session-send",
|
|
dryRun: false,
|
|
mutation: true,
|
|
partialWrite: false,
|
|
sessionId,
|
|
decision: "turn",
|
|
internalCommandType: "turn",
|
|
run: { id: runId, status: "queued", sessionRef: { sessionId } },
|
|
command: { id: commandId, state: "pending", type: "turn" },
|
|
runnerJob: { id: "rjob_legacy_fixture", state: "queued", runId, commandId },
|
|
pollActions: [
|
|
{ operation: "describe", resourceKind: "session", resourceName: sessionId },
|
|
{ operation: "events", resourceKind: "run", resourceName: runId, runId, afterSeq: 0, limit: 100 },
|
|
],
|
|
valuesPrinted: false,
|
|
},
|
|
};
|
|
|
|
const typedFailureFixture: Record<string, unknown> = {
|
|
ok: false,
|
|
command,
|
|
failureKind: "validation-failed",
|
|
message: "durable runner dispatch is not enabled for session turn admission",
|
|
httpStatus: 503,
|
|
agentrun: {
|
|
failureKind: "infra-failed",
|
|
error: {
|
|
name: "AgentRunError",
|
|
failureKind: "infra-failed",
|
|
details: {
|
|
code: "runner-admission-failed",
|
|
reason: "session-runner-dispatcher-disabled",
|
|
phase: "runner-admission",
|
|
sessionId,
|
|
mutation: false,
|
|
partialWrite: false,
|
|
retryable: false,
|
|
retryAuthority: "none",
|
|
recoveryAction: "restore-runner-dispatcher",
|
|
recoveryActions: [
|
|
{ operation: "describe", resourceKind: "session", resourceName: sessionId },
|
|
],
|
|
valuesPrinted: false,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
describe("AgentRun session send runner admission rendering", () => {
|
|
test("default output exposes durable admission without claiming a Kubernetes Job exists", () => {
|
|
const result = renderSessionSendResult(command, durableAdmissionFixture, options("human"));
|
|
expect(result.ok).toBe(true);
|
|
expect(result.renderedText).toContain("RunnerAdmission:");
|
|
expect(result.renderedText).toContain("mode=durable-dispatch-intent state=pending disposition=recovered");
|
|
expect(result.renderedText).toContain(`runId=${runId} commandId=${commandId} dispatchIntentId=${dispatchIntentId}`);
|
|
expect(result.renderedText).toContain(`plannedRunnerJobId=${plannedRunnerJobId} actualRunnerJobId=-`);
|
|
expect(result.renderedText).toContain("mutation=true partialWrite=false recoveredPriorPartialWrite=true");
|
|
expect(result.renderedText).toContain("retryable=true retryAuthority=dispatcher");
|
|
expect(result.renderedText).toContain(`planned-runnerjob/${plannedRunnerJobId}`);
|
|
expect(result.renderedText).toContain("它本身不是 Kubernetes Job 已创建的证据");
|
|
expect(result.renderedText).toContain("durable dispatcher 负责自动重试;这不是重复执行 session send 的授权");
|
|
expect(result.renderedText).not.toContain(`describe runnerjob/${plannedRunnerJobId}`);
|
|
expect(result.renderedText).not.toContain("agentrun send session/");
|
|
expect(Buffer.byteLength(result.renderedText, "utf8")).toBeLessThan(7000);
|
|
});
|
|
|
|
test("JSON and YAML expose the same bounded runner admission contract and read-only Next", () => {
|
|
const jsonText = renderSessionSendResult(command, durableAdmissionFixture, options("json")).renderedText;
|
|
const yamlText = renderSessionSendResult(command, durableAdmissionFixture, options("yaml")).renderedText;
|
|
const json = JSON.parse(jsonText) as Record<string, any>;
|
|
const yaml = Bun.YAML.parse(yamlText) as Record<string, any>;
|
|
expect(yaml).toEqual(json);
|
|
expect(json.runnerAdmission).toMatchObject({
|
|
mode: "durable-dispatch-intent",
|
|
state: "pending",
|
|
reason: "session-runner-admission-recovered",
|
|
disposition: "recovered",
|
|
runId,
|
|
commandId,
|
|
dispatchIntentId,
|
|
plannedRunnerJobId,
|
|
actualRunnerJobId: null,
|
|
mutation: true,
|
|
partialWrite: false,
|
|
recoveredPriorPartialWrite: true,
|
|
retryable: true,
|
|
retryAuthority: "dispatcher",
|
|
});
|
|
expect(json.resources.runnerJob).toBeNull();
|
|
expect(json.resources.plannedRunnerJob).toEqual({
|
|
kind: "planned-runnerjob",
|
|
id: plannedRunnerJobId,
|
|
identityRole: "durable-planned",
|
|
kubernetesJobCreated: null,
|
|
kubernetesJobCreationEvidence: "not-asserted",
|
|
state: "pending",
|
|
durable: true,
|
|
});
|
|
expect(json.next.actions.length).toBeGreaterThan(0);
|
|
for (const action of json.next.actions) {
|
|
expect(action.mutation).toBe(false);
|
|
expect(action.command).toMatch(/^bun scripts\/cli\.ts agentrun (?:describe|events|logs|result)\b/u);
|
|
expect(action.command).not.toContain("agentrun send");
|
|
expect(action.command).not.toContain(`runnerjob/${plannedRunnerJobId}`);
|
|
}
|
|
for (const output of [jsonText, yamlText]) expect(Buffer.byteLength(output, "utf8")).toBeLessThan(7000);
|
|
});
|
|
|
|
test("legacy manager success output keeps runnerJob behavior when runnerAdmission is absent", () => {
|
|
const human = renderSessionSendResult(command, legacySuccessFixture, options("human")).renderedText;
|
|
const json = JSON.parse(renderSessionSendResult(command, legacySuccessFixture, options("json")).renderedText) as Record<string, any>;
|
|
expect(human).not.toContain("RunnerAdmission:");
|
|
expect(human).toContain("runnerjob/rjob_legacy_fixture state=queued");
|
|
expect(human).toContain("只使用 manager 返回的资源 identity 做只读下钻");
|
|
expect(json.runnerAdmission).toBeNull();
|
|
expect(json.resources.runnerJob).toEqual({ kind: "runnerjob", id: "rjob_legacy_fixture", state: "queued" });
|
|
expect(json.resources).not.toHaveProperty("plannedRunnerJob");
|
|
expect(json.next.actions.some((action: Record<string, unknown>) => String(action.command).includes("describe runnerjob/rjob_legacy_fixture"))).toBe(true);
|
|
});
|
|
|
|
test("typed failure remains fail-closed, bounded, and does not suggest a repeated send", () => {
|
|
const human = renderSessionSendError(command, typedFailureFixture, options("human"));
|
|
const jsonText = renderSessionSendError(command, typedFailureFixture, options("json")).renderedText;
|
|
const yamlText = renderSessionSendError(command, typedFailureFixture, options("yaml")).renderedText;
|
|
const json = JSON.parse(jsonText) as Record<string, any>;
|
|
const yaml = Bun.YAML.parse(yamlText) as Record<string, any>;
|
|
expect(human.ok).toBe(false);
|
|
expect(human.renderedText).toContain("TypedFailure: infra-failed");
|
|
expect(human.renderedText).toContain("Reason: session-runner-dispatcher-disabled");
|
|
expect(human.renderedText).toContain("Mutation: false");
|
|
expect(human.renderedText).toContain("PartialWrite: false");
|
|
expect(human.renderedText).toContain("Retryable: false");
|
|
expect(human.renderedText).toContain("RetryAuthority: none");
|
|
expect(human.renderedText).toContain("RecoveryAction: restore-runner-dispatcher");
|
|
expect(human.renderedText).not.toContain("agentrun send session/");
|
|
expect(yaml).toEqual(json);
|
|
expect(json).toMatchObject({
|
|
kind: "SessionSendError",
|
|
mutation: false,
|
|
partialWrite: false,
|
|
error: {
|
|
failureKind: "validation-failed",
|
|
typedFailureKind: "infra-failed",
|
|
code: "runner-admission-failed",
|
|
reason: "session-runner-dispatcher-disabled",
|
|
retryable: false,
|
|
retryAuthority: "none",
|
|
recoveryAction: "restore-runner-dispatcher",
|
|
},
|
|
});
|
|
for (const output of [human.renderedText, jsonText, yamlText]) expect(Buffer.byteLength(output, "utf8")).toBeLessThan(7000);
|
|
});
|
|
});
|
|
|
|
function options(output: AgentRunOutputMode): AgentRunResourceOptions {
|
|
return {
|
|
output,
|
|
full: false,
|
|
raw: false,
|
|
debug: false,
|
|
taskInput: false,
|
|
limit: 20,
|
|
cursor: null,
|
|
queue: null,
|
|
state: null,
|
|
unread: false,
|
|
readerId: "cli",
|
|
taskId: null,
|
|
runId: null,
|
|
commandId: null,
|
|
sessionId: null,
|
|
afterSeq: null,
|
|
eventDetailSeq: null,
|
|
tail: null,
|
|
fullText: false,
|
|
reason: null,
|
|
dryRun: false,
|
|
file: null,
|
|
aipod: null,
|
|
idempotencyKey: null,
|
|
promptStdin: false,
|
|
node: null,
|
|
lane: null,
|
|
passthroughArgs: [],
|
|
};
|
|
}
|