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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions apps/chatgpt-mcp/src/invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
FinalizeRunInput,
ReopenRunInput,
SupersedeRunInput,
ArchiveRunInput,
ListRunsInput,
} from "./schemas.js";

describe("MCP tool registry invariants", () => {
Expand Down Expand Up @@ -57,6 +59,8 @@ describe("MCP tool registry invariants", () => {
"reopen_run",
// Milestone 12: deterministic run supersession
"supersede_run",
// Milestone 13: deterministic run archiving
"archive_run",
]);
const actual = new Set(REGISTERED_TOOL_NAMES);
assert.deepStrictEqual(actual, expected);
Expand Down Expand Up @@ -528,3 +532,125 @@ describe("No-hidden-agent regression (Milestone 12)", () => {
}
});
});

// ---------------------------------------------------------------
// Milestone 13: ArchiveRunInput schema validation
// ---------------------------------------------------------------
describe("ArchiveRunInput schema (Milestone 13)", () => {
it("should accept a valid archive request", () => {
const schema = z.object(ArchiveRunInput);
const result = schema.safeParse({ runId: "run-abc", reason: "Archiving completed run" });
assert.ok(result.success, "Valid archive request should pass validation");
});

it("should reject missing runId", () => {
const schema = z.object(ArchiveRunInput);
const result = schema.safeParse({ reason: "reason" });
assert.ok(!result.success, "Missing runId should fail validation");
});

it("should reject missing reason", () => {
const schema = z.object(ArchiveRunInput);
const result = schema.safeParse({ runId: "run-abc" });
assert.ok(!result.success, "Missing reason should fail validation");
});

it("should reject an empty reason", () => {
const schema = z.object(ArchiveRunInput);
const result = schema.safeParse({ runId: "run-abc", reason: "" });
assert.ok(!result.success, "Empty reason should fail validation (min 1)");
});

it("should reject a reason exceeding 500 characters", () => {
const schema = z.object(ArchiveRunInput);
const result = schema.safeParse({ runId: "run-abc", reason: "x".repeat(501) });
assert.ok(!result.success, "Reason exceeding 500 chars should fail validation");
});

it("should accept a reason of exactly 500 characters", () => {
const schema = z.object(ArchiveRunInput);
const result = schema.safeParse({ runId: "run-abc", reason: "x".repeat(500) });
assert.ok(result.success, "Reason of exactly 500 chars should be valid");
});
});

// ---------------------------------------------------------------
// Milestone 13: ListRunsInput archive filtering schema
// ---------------------------------------------------------------
describe("ListRunsInput archive filtering (Milestone 13)", () => {
it("should accept includeArchived=true", () => {
const schema = z.object(ListRunsInput);
const result = schema.safeParse({ includeArchived: true });
assert.ok(result.success, "includeArchived=true should be valid");
assert.strictEqual(result.data?.includeArchived, true);
});

it("should accept archivedOnly=true", () => {
const schema = z.object(ListRunsInput);
const result = schema.safeParse({ archivedOnly: true });
assert.ok(result.success, "archivedOnly=true should be valid");
assert.strictEqual(result.data?.archivedOnly, true);
});

it("should accept both flags together", () => {
const schema = z.object(ListRunsInput);
const result = schema.safeParse({ includeArchived: true, archivedOnly: true });
assert.ok(result.success, "Both flags together should be valid schema-wise");
});

it("should default both flags to undefined when omitted", () => {
const schema = z.object(ListRunsInput);
const result = schema.safeParse({});
assert.ok(result.success, "Empty input should be valid");
assert.strictEqual(result.data?.includeArchived, undefined);
assert.strictEqual(result.data?.archivedOnly, undefined);
});

it("should reject non-boolean includeArchived", () => {
const schema = z.object(ListRunsInput);
const result = schema.safeParse({ includeArchived: "yes" });
assert.ok(!result.success, "Non-boolean includeArchived should fail validation");
});
});

// ---------------------------------------------------------------
// Milestone 13: No-hidden-agent regression for archive_run
// ---------------------------------------------------------------
describe("No-hidden-agent regression (Milestone 13)", () => {
it("archive_run should be registered as a lifecycle tool", () => {
assert.ok(
REGISTERED_TOOL_NAMES.includes("archive_run" as (typeof REGISTERED_TOOL_NAMES)[number]),
"archive_run must be in the tool registry",
);
});

it("archive_run is not an autonomous continuation tool", () => {
const coarsePatterns = ["continue", "resume", "agent", "turn", "codex_reply", "fix_end"];
for (const pattern of coarsePatterns) {
assert.ok(
!"archive_run".includes(pattern),
`"archive_run" must not contain autonomous pattern "${pattern}"`,
);
}
});

it("daemon method run.archive is not a forbidden agent-runtime method", () => {
const forbiddenMethods = [
"turn/start",
"turn/steer",
"review/start",
"codex",
"codex-reply",
"continue_run",
"resume_thread",
"agent_step",
"fix_end_to_end",
];
for (const method of forbiddenMethods) {
assert.ok(
method !== "run.archive",
`run.archive must not be a forbidden agent-runtime method`,
);
}
});
});
38 changes: 37 additions & 1 deletion apps/chatgpt-mcp/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ export const ApproveActionInput = {
};

// ---------------------------------------------------------------
// list_runs (Milestone 7)
// list_runs (Milestone 7; extended in Milestone 13)
// ---------------------------------------------------------------
export const ListRunsInput = {
limit: z
Expand All @@ -228,6 +228,19 @@ export const ListRunsInput = {
.string()
.optional()
.describe("Filter by run status (e.g. active, done, blocked)"),
// Milestone 13: archive filtering
includeArchived: z
.boolean()
.optional()
.describe(
"When true, include archived runs alongside non-archived runs in the results. Default: false (archived runs are excluded).",
),
archivedOnly: z
.boolean()
.optional()
.describe(
"When true, return only archived runs. Takes precedence over includeArchived.",
),
};

// ---------------------------------------------------------------
Expand Down Expand Up @@ -351,3 +364,26 @@ export const SupersedeRunInput = {
"Human-readable reason for supersession (required for auditability)",
),
};

// ---------------------------------------------------------------
// archive_run (Milestone 13)
//
// Explicitly archives a finalized run so it remains preserved and
// inspectable but is excluded from the default active run listing.
// Only finalized runs (completed, failed, abandoned) may be archived.
// Archiving is deterministic and audited; it does not execute work.
// ---------------------------------------------------------------
export const ArchiveRunInput = {
runId: z
.string()
.describe(
"Run ID of the finalized run to archive (must be finalized: completed, failed, or abandoned)",
),
reason: z
.string()
.min(1)
.max(500)
.describe(
"Human-readable reason for archiving (required for auditability)",
),
};
25 changes: 23 additions & 2 deletions apps/chatgpt-mcp/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
FinalizeRunInput,
ReopenRunInput,
SupersedeRunInput,
ArchiveRunInput,
} from "./schemas.js";

/**
Expand Down Expand Up @@ -77,6 +78,8 @@ export const REGISTERED_TOOL_NAMES = [
"reopen_run",
// Milestone 12: deterministic run supersession
"supersede_run",
// Milestone 13: deterministic run archiving
"archive_run",
] as const;

export function registerTools(server: McpServer, client: DaemonClient): void {
Expand Down Expand Up @@ -269,16 +272,18 @@ export function registerTools(server: McpServer, client: DaemonClient): void {
},
);

// ---- list_runs (Milestone 7) ----
// ---- list_runs (Milestone 7; extended in Milestone 13) ----
server.tool(
"list_runs",
"List known runs with their status and metadata (read-only)",
"List known runs with status and metadata (read-only). Supports archive filtering via optional parameters: set includeArchived=true to include archived runs, or archivedOnly=true to return only archived runs.",
ListRunsInput,
async (params) => {
const result = await client.call("runs.list", {
limit: params.limit,
workspaceId: params.workspaceId,
status: params.status,
includeArchived: params.includeArchived,
archivedOnly: params.archivedOnly,
});
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
Expand Down Expand Up @@ -401,4 +406,20 @@ export function registerTools(server: McpServer, client: DaemonClient): void {
};
},
);

// ---- archive_run (Milestone 13) ----
server.tool(
"archive_run",
"Explicitly archive a finalized run so it remains preserved and inspectable but is excluded from the default active run listing. Only finalized runs (completed, failed, or abandoned) may be archived. Archiving is deterministic and audited; it does not execute work or reopen the run. Archived runs can still be read via get_run_state, get_run_history, and list_runs (with includeArchived=true or archivedOnly=true).",
ArchiveRunInput,
async (params) => {
const result = await client.call("run.archive", {
runId: params.runId,
reason: params.reason,
});
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
};
},
);
}
1 change: 1 addition & 0 deletions codex-rs/deterministic-core/src/approval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ mod tests {
superseded_by_run_id: None,
supersession_reason: None,
superseded_at: None,
archive_metadata: None,
created_at: "2024-01-01T00:00:00Z".into(),
updated_at: "2024-01-01T00:00:00Z".into(),
}
Expand Down
1 change: 1 addition & 0 deletions codex-rs/deterministic-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub mod git_diff;
pub mod git_status;
pub mod code_search;
pub mod patch_apply;
pub mod run_archive;
pub mod run_finalize;
pub mod run_prepare;
pub mod run_refresh;
Expand Down
Loading
Loading