From b642682c11248b689a989fc1ef76ce7427fb9e96 Mon Sep 17 00:00:00 2001
From: Cedric Vidal
Date: Thu, 25 Jun 2026 12:04:31 -0700
Subject: [PATCH 01/14] feat(shared): typed task prompts, AGENTS.md type,
size-based blob storage
Foundation for AGENTS.md prompt support (#732): typed task prompts ('task' | 'agents.md') with content-hash ids, size-based body storage (small bodies inline, large bodies uploaded to blob and referenced via contentBlobUrl), a prompt client/resolver, typed prompt-feature extraction, and request schema fields (agentsMd, agentsMdParentIds, agentsMdPromptId).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../prompt-feature-provider-fs.ts | 10 +-
.../prompt-feature-store.test.ts | 112 ++++++++++++++
.../prompt-features/prompt-feature-store.ts | 24 ++-
packages/shared/src/schemas/prompt-feature.ts | 3 +
packages/shared/src/schemas/request.ts | 10 ++
packages/shared/src/schemas/task-prompt.ts | 10 +-
packages/shared/src/storage/blob-storage.ts | 22 +++
packages/shared/src/task-prompts/index.ts | 1 +
.../src/task-prompts/prompt-client.test.ts | 45 ++++++
.../shared/src/task-prompts/prompt-client.ts | 42 ++++++
.../task-prompts/task-prompt-store.test.ts | 137 +++++++++++++++++-
.../src/task-prompts/task-prompt-store.ts | 102 +++++++++++--
packages/shared/src/types/types.ts | 56 ++++++-
13 files changed, 545 insertions(+), 29 deletions(-)
create mode 100644 packages/shared/src/prompt-features/prompt-feature-store.test.ts
create mode 100644 packages/shared/src/task-prompts/prompt-client.test.ts
create mode 100644 packages/shared/src/task-prompts/prompt-client.ts
diff --git a/packages/shared/src/prompt-features/prompt-feature-provider-fs.ts b/packages/shared/src/prompt-features/prompt-feature-provider-fs.ts
index c76574ace..44dc6be30 100644
--- a/packages/shared/src/prompt-features/prompt-feature-provider-fs.ts
+++ b/packages/shared/src/prompt-features/prompt-feature-provider-fs.ts
@@ -4,7 +4,7 @@
import { readFileSync, readdirSync, existsSync } from 'fs';
import { join } from 'path';
import { parse as parseYaml } from 'yaml';
-import { PromptFeatureConfig } from '../types/types.js';
+import { PromptFeatureConfig, PromptType, PROMPT_TYPES } from '../types/types.js';
import { PromptFeatureProvider } from './prompt-feature-provider.js';
/**
@@ -47,9 +47,17 @@ export class FileSystemPromptFeatureProvider implements PromptFeatureProvider {
throw new Error(`Missing or invalid 'prompt' field in ${file}`);
}
+ // Normalize legacy 'task' to canonical 'select'; accept any known type.
+ const rawType =
+ typeof data.type === "string" && data.type === "task"
+ ? "select"
+ : data.type;
const feature: PromptFeatureConfig = {
id: data.id.trim(),
prompt: data.prompt.trim(),
+ ...(typeof rawType === "string" && PROMPT_TYPES.includes(rawType as PromptType)
+ ? { type: rawType as PromptType }
+ : {}),
};
if (this.registry.has(feature.id)) {
diff --git a/packages/shared/src/prompt-features/prompt-feature-store.test.ts b/packages/shared/src/prompt-features/prompt-feature-store.test.ts
new file mode 100644
index 000000000..b29faf4cc
--- /dev/null
+++ b/packages/shared/src/prompt-features/prompt-feature-store.test.ts
@@ -0,0 +1,112 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { describe, it, expect, beforeEach } from "vitest";
+import { PromptFeatureStore } from "./prompt-feature-store.js";
+import type { PromptFeatureDocument } from "../types/types.js";
+
+// ── Minimal in-memory collection ─────────────────────────────────────────────
+// Supports the subset of query operators the store uses: `id`, `deletedAt`
+// existence, and the `$or`/`type` existence filters for type scoping.
+
+function matchesType(doc: PromptFeatureDocument, clause: any): boolean {
+ if (clause.type !== undefined) {
+ if (typeof clause.type === "object" && clause.type.$exists === false) {
+ return doc.type === undefined;
+ }
+ return doc.type === clause.type;
+ }
+ return true;
+}
+
+function createMockCollection() {
+ const docs: PromptFeatureDocument[] = [];
+
+ return {
+ _docs: docs,
+ find(filter: any) {
+ let results = docs.filter((d) => {
+ if (filter.deletedAt && filter.deletedAt.$exists === false && d.deletedAt) return false;
+ if (filter.$or) {
+ if (!filter.$or.some((clause: any) => matchesType(d, clause))) return false;
+ }
+ return true;
+ });
+ return {
+ sort() { return this; },
+ toArray() { return Promise.resolve(results.map((d) => ({ ...d }))); },
+ };
+ },
+ findOne(filter: any) {
+ const doc = docs.find((d) => {
+ if (filter.id && d.id !== filter.id) return false;
+ if (filter.deletedAt && filter.deletedAt.$exists === false && d.deletedAt) return false;
+ return true;
+ });
+ return Promise.resolve(doc ? { ...doc } : null);
+ },
+ insertOne(doc: PromptFeatureDocument) {
+ docs.push({ ...doc });
+ return Promise.resolve({ insertedId: doc.id });
+ },
+ updateOne() { return Promise.resolve({ matchedCount: 1, modifiedCount: 1 }); },
+ };
+}
+
+describe("PromptFeatureStore type support", () => {
+ let store: PromptFeatureStore;
+ let collection: ReturnType;
+
+ beforeEach(() => {
+ collection = createMockCollection();
+ store = new PromptFeatureStore(collection as any);
+ });
+
+ it("create persists the type field when provided", async () => {
+ const doc = await store.create({ id: "agents_terse", prompt: "Be terse", type: "agents.md" });
+ expect(doc.type).toBe("agents.md");
+ expect(collection._docs[0].type).toBe("agents.md");
+ });
+
+ it("create omits type when not provided (backward compatible)", async () => {
+ const doc = await store.create({ id: "task_feat", prompt: "A task feature" });
+ expect(doc.type).toBeUndefined();
+ expect("type" in collection._docs[0]).toBe(false);
+ });
+
+ it("getAll({type: 'agents.md'}) returns only agents.md features", async () => {
+ await store.create({ id: "task_a", prompt: "p", type: "select" });
+ await store.create({ id: "legacy", prompt: "p" }); // no type → select
+ await store.create({ id: "agents_b", prompt: "p", type: "agents.md" });
+
+ const result = await store.getAll({ type: "agents.md" });
+ expect(result.map((f) => f.id)).toEqual(["agents_b"]);
+ });
+
+ it("getAll({type: 'select'}) includes legacy (untyped) features", async () => {
+ await store.create({ id: "task_a", prompt: "p", type: "select" });
+ await store.create({ id: "legacy", prompt: "p" });
+ await store.create({ id: "agents_b", prompt: "p", type: "agents.md" });
+
+ const result = await store.getAll({ type: "select" });
+ expect(result.map((f) => f.id).sort()).toEqual(["legacy", "task_a"]);
+ });
+
+ it("getAll() with no filter returns all features", async () => {
+ await store.create({ id: "task_a", prompt: "p" });
+ await store.create({ id: "agents_b", prompt: "p", type: "agents.md" });
+
+ const result = await store.getAll();
+ expect(result).toHaveLength(2);
+ });
+
+ it("seed carries the type field for new features", async () => {
+ const inserted = await store.seed([
+ { id: "agents_seed", prompt: "p", type: "agents.md" },
+ { id: "task_seed", prompt: "p" },
+ ]);
+ expect(inserted).toBe(2);
+ expect(collection._docs.find((d) => d.id === "agents_seed")?.type).toBe("agents.md");
+ expect("type" in (collection._docs.find((d) => d.id === "task_seed") as any)).toBe(false);
+ });
+});
diff --git a/packages/shared/src/prompt-features/prompt-feature-store.ts b/packages/shared/src/prompt-features/prompt-feature-store.ts
index 7fda78964..82e4a39a0 100644
--- a/packages/shared/src/prompt-features/prompt-feature-store.ts
+++ b/packages/shared/src/prompt-features/prompt-feature-store.ts
@@ -1,8 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { Collection } from 'mongodb';
-import { PromptFeatureConfig, PromptFeatureDocument } from '../types/types.js';
+import { Collection, Filter } from 'mongodb';
+import { PromptFeatureConfig, PromptFeatureDocument, PromptType } from '../types/types.js';
/**
* MongoDB-backed prompt feature store for CRUD operations on prompt feature definitions.
@@ -13,10 +13,19 @@ import { PromptFeatureConfig, PromptFeatureDocument } from '../types/types.js';
export class PromptFeatureStore {
constructor(private collection: Collection) {}
- /** List all active (non-deleted) prompt features */
- async getAll(): Promise {
+ /** List all active (non-deleted) prompt features, optionally filtered by type */
+ async getAll(opts?: { type?: PromptType }): Promise {
+ const filter: Filter = { deletedAt: { $exists: false } };
+ if (opts?.type) {
+ // Absent `type` is treated as 'select' for backward compatibility
+ // (legacy prompt features predate typing and target the scenario task).
+ filter.$or =
+ opts.type === "select"
+ ? [{ type: "select" }, { type: { $exists: false } }]
+ : [{ type: opts.type }];
+ }
return this.collection
- .find({ deletedAt: { $exists: false } })
+ .find(filter)
.sort({ id: 1 })
.toArray();
}
@@ -30,8 +39,9 @@ export class PromptFeatureStore {
async create(input: {
id: string;
prompt: string;
+ type?: PromptType;
}): Promise {
- const { id, prompt } = input;
+ const { id, prompt, type } = input;
// Validate ID format
if (!/^[a-z0-9_-]+$/.test(id)) {
@@ -49,6 +59,7 @@ export class PromptFeatureStore {
const doc: PromptFeatureDocument = {
id,
prompt: prompt.trim(),
+ ...(type ? { type } : {}),
createdAt: new Date(),
};
@@ -102,6 +113,7 @@ export class PromptFeatureStore {
await this.collection.insertOne({
id: config.id,
prompt: config.prompt,
+ ...(config.type ? { type: config.type } : {}),
createdAt: new Date(),
} as any);
inserted++;
diff --git a/packages/shared/src/schemas/prompt-feature.ts b/packages/shared/src/schemas/prompt-feature.ts
index bf4d96ec3..b42215007 100644
--- a/packages/shared/src/schemas/prompt-feature.ts
+++ b/packages/shared/src/schemas/prompt-feature.ts
@@ -3,6 +3,7 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
+import { PromptTypeSchema } from "./task-prompt.js";
extendZodWithOpenApi(z);
@@ -10,6 +11,7 @@ export const CreatePromptFeatureInputSchema = z
.object({
id: z.string().regex(/^[a-z][a-z0-9_]*$/),
prompt: z.string(),
+ type: PromptTypeSchema.optional(),
})
.openapi("CreatePromptFeatureInput");
@@ -23,6 +25,7 @@ export const PromptFeatureResponseSchema = z
.object({
id: z.string(),
prompt: z.string(),
+ type: PromptTypeSchema.optional(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date().optional(),
deletedAt: z.coerce.date().optional(),
diff --git a/packages/shared/src/schemas/request.ts b/packages/shared/src/schemas/request.ts
index 513e54996..4cc338ee3 100644
--- a/packages/shared/src/schemas/request.ts
+++ b/packages/shared/src/schemas/request.ts
@@ -129,6 +129,14 @@ export const CreateRequestInputSchema = z
profileId: z.string().optional(),
profileVariations: z.array(z.string()).optional(),
priority: z.number().int().optional(),
+ // Raw AGENTS.md body to deliver to the worker workspace. When present, the
+ // API findOrCreates an `agents.md`-typed prompt and stores its id on the
+ // request (see `agentsMdPromptId`).
+ agentsMd: z.string().optional(),
+ // Lineage edges (parent AGENTS.md prompt ids) when this candidate was
+ // derived from earlier ones: [] / omitted = root, [p] = mutation,
+ // [i, j] = merge of two parents.
+ agentsMdParentIds: z.array(z.string()).optional(),
gates: z.array(GateConfigSchema).optional(),
})
.openapi("CreateRequestInput");
@@ -147,6 +155,8 @@ export const RequestResponseSchema = z
persona: PersonaSchema.optional(),
deletedAt: z.coerce.date().optional(),
taskPromptId: z.string().optional(),
+ agentsMdPromptId: z.string().optional(),
+ agentsMdParentIds: z.array(z.string()).optional(),
mcpServers: z.array(z.string()).optional(),
skillRevisions: z.array(z.string()).optional(),
codebaseRevisionId: z.string().optional(),
diff --git a/packages/shared/src/schemas/task-prompt.ts b/packages/shared/src/schemas/task-prompt.ts
index 71f3fb053..bfbc8c05f 100644
--- a/packages/shared/src/schemas/task-prompt.ts
+++ b/packages/shared/src/schemas/task-prompt.ts
@@ -4,11 +4,11 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
import { PromptFeatureResultSchema } from "./prompt-feature.js";
-import { GATES } from "../types/types.js";
+import { PROMPT_TYPES } from "../types/types.js";
extendZodWithOpenApi(z);
-export const PromptTypeSchema = z.enum(GATES);
+export const PromptTypeSchema = z.enum(PROMPT_TYPES).openapi("PromptType");
export const CreateTaskPromptInputSchema = z
.object({
@@ -21,7 +21,11 @@ export const TaskPromptResponseSchema = z
.object({
_id: z.string(),
type: PromptTypeSchema.optional(),
- text: z.string(),
+ // Present for inline-stored bodies; absent when the body lives in blob
+ // storage (see `contentBlobUrl`). Use the `/content` endpoint to always
+ // get the resolved text.
+ text: z.string().optional(),
+ contentBlobUrl: z.string().optional(),
features: z.array(PromptFeatureResultSchema).optional(),
featuresExtractedAt: z.coerce.date().optional(),
createdAt: z.coerce.date(),
diff --git a/packages/shared/src/storage/blob-storage.ts b/packages/shared/src/storage/blob-storage.ts
index 19a7e3db2..c9b50038b 100644
--- a/packages/shared/src/storage/blob-storage.ts
+++ b/packages/shared/src/storage/blob-storage.ts
@@ -438,6 +438,28 @@ export class BlobStorage {
return blockBlobClient.url;
}
+ /**
+ * Uploads an in-memory UTF-8 text body to the snapshots container as a single
+ * block blob. Overwrites if the blob already exists. Returns the blob URL.
+ *
+ * Use this for plain-text/markdown bodies (e.g. large prompt or AGENTS.md
+ * content) where the JSON content-type from `uploadJson` would be misleading.
+ */
+ async uploadText(
+ blobName: string,
+ text: string,
+ contentType: string = "text/plain; charset=utf-8",
+ ): Promise {
+ await this.ensureContainer();
+
+ const buf = Buffer.from(text, "utf-8");
+ const blockBlobClient = this.containerClient.getBlockBlobClient(blobName);
+ await blockBlobClient.uploadData(buf, {
+ blobHTTPHeaders: { blobContentType: contentType },
+ });
+ return blockBlobClient.url;
+ }
+
/**
* Downloads a snapshot from blob storage and extracts it to the target directory.
*/
diff --git a/packages/shared/src/task-prompts/index.ts b/packages/shared/src/task-prompts/index.ts
index b00d0570c..0cb7cb5e9 100644
--- a/packages/shared/src/task-prompts/index.ts
+++ b/packages/shared/src/task-prompts/index.ts
@@ -3,3 +3,4 @@
export * from "./task-prompt-id.js";
export * from "./task-prompt-store.js";
+export * from "./prompt-client.js";
diff --git a/packages/shared/src/task-prompts/prompt-client.test.ts b/packages/shared/src/task-prompts/prompt-client.test.ts
new file mode 100644
index 000000000..34100ba3b
--- /dev/null
+++ b/packages/shared/src/task-prompts/prompt-client.test.ts
@@ -0,0 +1,45 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { PromptClient } from "./prompt-client.js";
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("PromptClient.getText", () => {
+ it("fetches resolved text from the content endpoint", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ id: "p1", text: "# AGENTS\nBe concise." }),
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ const client = new PromptClient("https://api.example.com/");
+ const text = await client.getText("p1");
+
+ expect(text).toBe("# AGENTS\nBe concise.");
+ // Trailing slash on base URL is trimmed; id is path-encoded.
+ expect(fetchMock).toHaveBeenCalledWith("https://api.example.com/api/v1/task-prompts/p1/content");
+ });
+
+ it("throws a clear error on 404", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404, statusText: "Not Found" }));
+ const client = new PromptClient("https://api.example.com");
+ await expect(client.getText("missing")).rejects.toThrow(/not found/i);
+ });
+
+ it("throws on non-OK HTTP status", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: "Server Error" }));
+ const client = new PromptClient("https://api.example.com");
+ await expect(client.getText("p1")).rejects.toThrow(/500/);
+ });
+
+ it("throws when the response lacks a text field", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ id: "p1" }) }));
+ const client = new PromptClient("https://api.example.com");
+ await expect(client.getText("p1")).rejects.toThrow(/no text/i);
+ });
+});
diff --git a/packages/shared/src/task-prompts/prompt-client.ts b/packages/shared/src/task-prompts/prompt-client.ts
new file mode 100644
index 000000000..ade51cc2f
--- /dev/null
+++ b/packages/shared/src/task-prompts/prompt-client.ts
@@ -0,0 +1,42 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/**
+ * Client for resolving prompt text (task or AGENTS.md) via the Scope REST API.
+ *
+ * Used by queue processors at message-processing time to fetch the resolved
+ * plain-text body of a prompt (inline or blob-backed) without needing direct
+ * blob storage access. The API endpoint `GET /api/v1/task-prompts/:id/content`
+ * runs `resolvePromptText` server-side and returns `{ id, text }`.
+ */
+export class PromptClient {
+ private readonly apiUrl: string;
+
+ constructor(apiUrl: string) {
+ this.apiUrl = apiUrl.replace(/\/+$/, "");
+ }
+
+ /**
+ * Fetch the resolved plain-text body of a prompt by id.
+ *
+ * @param id - Prompt id (content hash) of a task or agents.md prompt.
+ * @throws Error if the prompt cannot be resolved (404 or HTTP error).
+ */
+ async getText(id: string): Promise {
+ const url = `${this.apiUrl}/api/v1/task-prompts/${encodeURIComponent(id)}/content`;
+ const res = await fetch(url);
+
+ if (res.status === 404) {
+ throw new Error(`Prompt '${id}' not found via API`);
+ }
+ if (!res.ok) {
+ throw new Error(`[PromptClient] GET ${url} failed: ${res.status} ${res.statusText}`);
+ }
+
+ const data = (await res.json()) as { id: string; text: string };
+ if (typeof data.text !== "string") {
+ throw new Error(`[PromptClient] GET ${url} returned no text for prompt '${id}'`);
+ }
+ return data.text;
+ }
+}
diff --git a/packages/shared/src/task-prompts/task-prompt-store.test.ts b/packages/shared/src/task-prompts/task-prompt-store.test.ts
index 930846978..a0521e807 100644
--- a/packages/shared/src/task-prompts/task-prompt-store.test.ts
+++ b/packages/shared/src/task-prompts/task-prompt-store.test.ts
@@ -3,7 +3,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TaskPromptStore } from "./task-prompt-store.js";
-import { computeTaskPromptId } from "./task-prompt-id.js";
+import { computeTaskPromptId, computePromptId } from "./task-prompt-id.js";
import type { TaskPromptDocument, PromptFeatureResult } from "../types/types.js";
// ── Mock Collection ──────────────────────────────────────────────────────────
@@ -63,9 +63,10 @@ function createMockCollection() {
let count = 0;
for (const doc of docs.values()) {
if (filter.deletedAt?.$exists === false && doc.deletedAt) continue;
+ if (!matchesType(filter, doc)) continue;
if (filter.text?.$regex) {
const regex = new RegExp(filter.text.$regex, filter.text.$options);
- if (!regex.test(doc.text)) continue;
+ if (!regex.test(doc.text ?? "")) continue;
}
count++;
}
@@ -76,9 +77,10 @@ function createMockCollection() {
const results: TaskPromptDocument[] = [];
for (const doc of docs.values()) {
if (filter.deletedAt?.$exists === false && doc.deletedAt) continue;
+ if (!matchesType(filter, doc)) continue;
if (filter.text?.$regex) {
const regex = new RegExp(filter.text.$regex, filter.text.$options);
- if (!regex.test(doc.text)) continue;
+ if (!regex.test(doc.text ?? "")) continue;
}
results.push({ ...doc });
}
@@ -87,6 +89,27 @@ function createMockCollection() {
};
}
+/** Replicates the store's `$or`/`type` filter against an in-memory doc. */
+function matchesType(filter: any, doc: TaskPromptDocument): boolean {
+ if (filter.$or) {
+ return filter.$or.some((clause: any) => {
+ if (clause.type?.$exists === false) return doc.type === undefined;
+ return doc.type === clause.type;
+ });
+ }
+ if (filter.type !== undefined) {
+ const cond = filter.type;
+ if (cond && typeof cond === "object") {
+ // `$nin` (and `$in`) treat a missing `type` as `undefined` — mirrors Mongo,
+ // so legacy untyped docs stay visible under the default `$nin` filter.
+ if (Array.isArray(cond.$nin)) return !cond.$nin.includes(doc.type);
+ if (Array.isArray(cond.$in)) return cond.$in.includes(doc.type);
+ }
+ return doc.type === cond;
+ }
+ return true;
+}
+
// ── Tests ────────────────────────────────────────────────────────────────────
describe("TaskPromptStore", () => {
@@ -131,6 +154,98 @@ describe("TaskPromptStore", () => {
expect(revived._id).toBe(doc._id);
expect(revived.deletedAt).toBeUndefined();
});
+
+ it("namespaces non-task types into a distinct id", async () => {
+ const task = await store.findOrCreate("same text");
+ const agents = await store.findOrCreate("same text", "agents.md");
+ expect(agents._id).not.toBe(task._id);
+ expect(agents._id).toBe(computePromptId("agents.md", "same text"));
+ expect(agents.type).toBe("agents.md");
+ });
+
+ it("keeps task ids backward-compatible (unchanged by type arg)", async () => {
+ const a = await store.findOrCreate("hello");
+ const b = await store.findOrCreate("hello", "select");
+ expect(a._id).toBe(b._id);
+ expect(a._id).toBe(computeTaskPromptId("hello"));
+ });
+ });
+
+ // -- size-based storage ---------------------------------------------------
+
+ describe("size-based storage", () => {
+ function createMockBlob() {
+ const blobs = new Map();
+ return {
+ store: blobs,
+ uploadText: vi.fn(async (name: string, text: string) => {
+ blobs.set(name, text);
+ return `https://acct.blob/snapshots/${name}`;
+ }),
+ downloadBlobToBuffer: vi.fn(async (name: string) => {
+ const v = blobs.get(name);
+ if (v === undefined) throw new Error(`blob ${name} not found`);
+ return Buffer.from(v, "utf-8");
+ }),
+ };
+ }
+
+ it("stores small bodies inline and never touches blob", async () => {
+ const blob = createMockBlob();
+ const s = new TaskPromptStore(col as any, blob as any, 1024);
+ const doc = await s.findOrCreate("tiny");
+ expect(doc.text).toBe("tiny");
+ expect(doc.contentBlobUrl).toBeUndefined();
+ expect(blob.uploadText).not.toHaveBeenCalled();
+ });
+
+ it("uploads over-threshold bodies to blob on a create miss", async () => {
+ const blob = createMockBlob();
+ const s = new TaskPromptStore(col as any, blob as any, 16);
+ const big = "x".repeat(64);
+ const doc = await s.findOrCreate(big, "agents.md");
+ expect(doc.text).toBeUndefined();
+ expect(doc.contentBlobUrl).toContain(`prompts/${doc._id}.txt`);
+ expect(blob.uploadText).toHaveBeenCalledTimes(1);
+ });
+
+ it("skips the upload on a hit (idempotent)", async () => {
+ const blob = createMockBlob();
+ const s = new TaskPromptStore(col as any, blob as any, 16);
+ const big = "y".repeat(64);
+ await s.findOrCreate(big, "agents.md");
+ await s.findOrCreate(big, "agents.md");
+ expect(blob.uploadText).toHaveBeenCalledTimes(1);
+ });
+
+ it("resolvePromptText returns inline text without blob access", async () => {
+ const blob = createMockBlob();
+ const s = new TaskPromptStore(col as any, blob as any, 1024);
+ const doc = await s.findOrCreate("inline body");
+ expect(await s.resolvePromptText(doc)).toBe("inline body");
+ expect(blob.downloadBlobToBuffer).not.toHaveBeenCalled();
+ });
+
+ it("resolvePromptText downloads blob-backed bodies", async () => {
+ const blob = createMockBlob();
+ const s = new TaskPromptStore(col as any, blob as any, 16);
+ const big = "z".repeat(64);
+ const doc = await s.findOrCreate(big, "agents.md");
+ expect(await s.resolvePromptText(doc)).toBe(big);
+ expect(blob.downloadBlobToBuffer).toHaveBeenCalledTimes(1);
+ });
+
+ it("rejects bodies over the hard max size", async () => {
+ const blob = createMockBlob();
+ const s = new TaskPromptStore(col as any, blob as any, 16);
+ const huge = "a".repeat(256 * 1024 + 1);
+ await expect(s.findOrCreate(huge)).rejects.toThrow(/maximum/);
+ });
+
+ it("throws when an over-threshold body has no blob storage configured", async () => {
+ const s = new TaskPromptStore(col as any, undefined, 16);
+ await expect(s.findOrCreate("b".repeat(64))).rejects.toThrow(/BlobStorage/);
+ });
});
// -- get ------------------------------------------------------------------
@@ -222,6 +337,22 @@ describe("TaskPromptStore", () => {
expect(total).toBe(1);
expect(items[0].text).toBe("Azure deployment");
});
+
+ it("defaults to task-typed prompts (and legacy untyped docs)", async () => {
+ await store.findOrCreate("a task prompt");
+ await store.findOrCreate("an agents file", "agents.md");
+ const { items, total } = await store.getAll();
+ expect(total).toBe(1);
+ expect(items[0].text).toBe("a task prompt");
+ });
+
+ it("filters by agents.md type", async () => {
+ await store.findOrCreate("a task prompt");
+ await store.findOrCreate("an agents file", "agents.md");
+ const { items, total } = await store.getAll({ type: "agents.md" });
+ expect(total).toBe(1);
+ expect(items[0].text).toBe("an agents file");
+ });
});
// -- attachFeatures -------------------------------------------------------
diff --git a/packages/shared/src/task-prompts/task-prompt-store.ts b/packages/shared/src/task-prompts/task-prompt-store.ts
index 6996df3b8..93e41a61f 100644
--- a/packages/shared/src/task-prompts/task-prompt-store.ts
+++ b/packages/shared/src/task-prompts/task-prompt-store.ts
@@ -2,25 +2,52 @@
// Licensed under the MIT License.
import { Collection } from 'mongodb';
-import { TaskPromptDocument, PromptFeatureResult, PromptType } from '../types/types.js';
+import { TaskPromptDocument, PromptFeatureResult, PromptType, NON_GATE_PROMPT_TYPES } from '../types/types.js';
import { computePromptId } from './task-prompt-id.js';
+import type { BlobStorage } from '../storage/blob-storage.js';
+
+/** Default inline-vs-blob threshold (UTF-8 bytes). Bodies larger than this are
+ * stored in blob storage; smaller bodies stay inline in Mongo. */
+export const DEFAULT_PROMPT_INLINE_MAX_BYTES = 16 * 1024;
+
+/** Hard guard cap on prompt body size (UTF-8 bytes). Bodies larger than this
+ * are rejected outright, regardless of storage location. */
+export const PROMPT_MAX_BYTES = 256 * 1024;
+
+/** Deterministic blob path for a prompt body that lives in blob storage. */
+export function promptBlobName(id: string): string {
+ return `prompts/${id}.txt`;
+}
/**
* MongoDB-backed store for task prompt entities.
*
* Task prompts are **immutable and content-addressed**: the `_id` is a UUIDv5
- * derived from the trimmed prompt text (and, for non-Select gates, the prompt
- * `type`). The same `(type, text)` always resolves to the same document —
- * `findOrCreate` is idempotent.
+ * derived from the trimmed prompt text (and, for non-Select gates and non-gate
+ * kinds like `agents.md`, the prompt `type`). The same `(type, text)` always
+ * resolves to the same document — `findOrCreate` is idempotent. `type` defaults
+ * to `"select"` so existing task-prompt call sites are unaffected.
*
- * Prompts carry a `type` discriminator — one literal per gate (the Select gate's
- * prompt is the request's task prompt). `type` defaults to `"select"` so
- * existing task-prompt call sites are unaffected.
+ * The body is stored **inline** (`text`) when small, or in **blob storage**
+ * (`contentBlobUrl`) when it exceeds the configured inline threshold. Storage
+ * location is decided purely by size, independent of the prompt `type`.
*
* Documents are soft-deleted (deletedAt) rather than removed.
*/
export class TaskPromptStore {
- constructor(private collection: Collection) {}
+ private readonly inlineMaxBytes: number;
+
+ constructor(
+ private collection: Collection,
+ private blobStorage?: BlobStorage,
+ inlineMaxBytes?: number,
+ ) {
+ this.inlineMaxBytes =
+ inlineMaxBytes ??
+ (process.env.PROMPT_INLINE_MAX_BYTES
+ ? parseInt(process.env.PROMPT_INLINE_MAX_BYTES, 10)
+ : DEFAULT_PROMPT_INLINE_MAX_BYTES);
+ }
/** Get a single task prompt by ID (non-deleted) */
async get(id: string): Promise {
@@ -32,10 +59,34 @@ export class TaskPromptStore {
return this.get(computePromptId(type, text));
}
+ /**
+ * Resolve a prompt document's body to plain text, regardless of storage
+ * location: returns inline `text` when present, otherwise downloads the body
+ * from blob storage. Throws if the document has neither (corrupt) or if blob
+ * storage is needed but not configured.
+ */
+ async resolvePromptText(doc: TaskPromptDocument): Promise {
+ if (doc.text != null) return doc.text;
+ if (doc.contentBlobUrl) {
+ if (!this.blobStorage) {
+ throw new Error(
+ `Task prompt '${doc._id}' body is in blob storage but no BlobStorage is configured`,
+ );
+ }
+ const buf = await this.blobStorage.downloadBlobToBuffer(promptBlobName(doc._id));
+ return buf.toString('utf-8');
+ }
+ throw new Error(`Task prompt '${doc._id}' has neither inline text nor a blob reference`);
+ }
+
/**
* Find an existing task prompt by `(type, text)`, or create a new one.
* Idempotent — the same `(type, text)` always returns the same document.
* `type` defaults to `"select"` (the request task prompt).
+ *
+ * Bodies over the inline threshold are uploaded to blob storage on a create
+ * **miss** (and the doc stores only a `contentBlobUrl`); smaller bodies are
+ * stored inline. The dedup/lookup is by content hash and never touches blob.
*/
async findOrCreate(text: string, type: PromptType = 'select'): Promise {
const trimmed = text.trim();
@@ -62,14 +113,36 @@ export class TaskPromptStore {
return existing;
}
- // Create new document
+ // Enforce the hard size cap before storing anywhere.
+ const byteLength = Buffer.byteLength(trimmed, 'utf-8');
+ if (byteLength > PROMPT_MAX_BYTES) {
+ throw new Error(
+ `Prompt body is ${byteLength} bytes, exceeding the maximum of ${PROMPT_MAX_BYTES} bytes`,
+ );
+ }
+
const doc: TaskPromptDocument = {
_id: id,
type,
- text: trimmed,
createdAt: new Date(),
};
+ // Size-based storage: large bodies go to blob, small bodies stay inline.
+ if (byteLength > this.inlineMaxBytes) {
+ if (!this.blobStorage) {
+ throw new Error(
+ `Prompt body is ${byteLength} bytes (over the ${this.inlineMaxBytes}-byte inline ` +
+ `threshold) but no BlobStorage is configured to store it`,
+ );
+ }
+ doc.contentBlobUrl = await this.blobStorage.uploadText(
+ promptBlobName(id),
+ trimmed,
+ );
+ } else {
+ doc.text = trimmed;
+ }
+
await this.collection.insertOne(doc as any);
return doc;
}
@@ -77,7 +150,8 @@ export class TaskPromptStore {
/**
* List active (non-deleted) task prompts.
* Supports pagination, optional substring search on text, and an optional
- * `type` filter (one prompt type per gate).
+ * `type` filter. With no `type`, prompts of every type are returned; pass a
+ * `type` (e.g. `'select'` or `'agents.md'`) to scope the list.
*/
async getAll(opts?: {
limit?: number;
@@ -92,6 +166,12 @@ export class TaskPromptStore {
}
if (opts?.type) {
filter.type = opts.type;
+ } else {
+ // The default (unfiltered) list shows all gate prompt types and legacy
+ // untyped docs, but hides non-gate types such as auto-generated
+ // 'agents.md' candidates, which can be high-volume noise. `$nin`
+ // also matches docs with no `type` field, so legacy prompts stay visible.
+ filter.type = { $nin: NON_GATE_PROMPT_TYPES };
}
const total = await this.collection.countDocuments(filter);
diff --git a/packages/shared/src/types/types.ts b/packages/shared/src/types/types.ts
index 2e3c72eb3..c157f4d3b 100644
--- a/packages/shared/src/types/types.ts
+++ b/packages/shared/src/types/types.ts
@@ -62,11 +62,24 @@ export function isGateId(value: unknown): value is GateId {
return typeof value === "string" && (GATES as readonly string[]).includes(value);
}
+/** Non-gate prompt kinds — prompt types that are not pipeline gates. */
+export const NON_GATE_PROMPT_TYPES = ["agents.md"] as const;
+
+/** Every prompt-type discriminator: the gates plus non-gate kinds. */
+export const PROMPT_TYPES = [...GATES, ...NON_GATE_PROMPT_TYPES] as const;
+
/**
- * A prompt's type discriminator — one literal per gate. The prompt that drives
- * a gate must have a `type` equal to that gate's id.
+ * A prompt's type discriminator. Either a gate id — the prompt that drives that
+ * gate, where `select` is the request's task prompt — or a non-gate kind such
+ * as `agents.md` (an AGENTS.md instruction file delivered into the agent
+ * workspace). Absent on a document ⇒ legacy `'select'`.
*/
-export type PromptType = GateId;
+export type PromptType = (typeof PROMPT_TYPES)[number];
+
+/** True when `value` is a valid prompt type (a gate id or a non-gate kind). */
+export function isPromptType(value: unknown): value is PromptType {
+ return typeof value === "string" && (PROMPT_TYPES as readonly string[]).includes(value);
+}
/**
* Per-gate configuration on a request. Describes which prompt drives the gate,
@@ -267,6 +280,19 @@ export interface RequestDocument {
deletedAt?: Date; // Soft-delete timestamp (null/absent = active)
taskPromptId?: string; // Materialized UUIDv5 of scenario.task (FK → TaskPromptDocument._id)
promptFeatureExtractionId?: string; // @deprecated — use TaskPromptDocument.features via taskPromptId instead
+ /**
+ * FK → TaskPromptDocument._id of an AGENTS.md-typed prompt to deliver into
+ * the agent's workspace for this run. When set, the worker writes the
+ * resolved content to `/AGENTS.md` before the run starts.
+ */
+ agentsMdPromptId?: string;
+ /**
+ * Lineage edges for the AGENTS.md candidate: the parent AGENTS.md prompt ids
+ * this candidate was derived from. Empty/absent = root (seed); one entry =
+ * reflective mutation; two entries = merge. Recorded on the request so the
+ * full lineage DAG can be reconstructed by querying related requests.
+ */
+ agentsMdParentIds?: string[];
mcpServers?: string[]; // MCP server slugs selected for this run
skillRevisions?: string[]; // Skill revision refs (e.g. "vercel-labs/agent-skills/my-skill@a1b2c3d")
codebaseRevisionId?: string; // FK → CodebaseRevisionDocument._id — seeds the workspace before the agent starts
@@ -735,8 +761,22 @@ export interface InsightDocument {
/** Task prompt document stored in MongoDB. Immutable — text cannot be changed after creation. */
export interface TaskPromptDocument {
_id: string; // UUIDv5 (content-addressed; see computePromptId)
- type?: PromptType; // Which gate this prompt drives. Absent = legacy "select".
- text: string; // Full task prompt text
+ /**
+ * Inline prompt body. Present when the body is small enough to store in
+ * Mongo (≤ PROMPT_INLINE_MAX_BYTES). Mutually exclusive with
+ * `contentBlobUrl` — exactly one is set. Optional so large bodies can live
+ * in blob storage instead.
+ */
+ text?: string;
+ /**
+ * Blob reference to the prompt body when it exceeds the inline size
+ * threshold. Mutually exclusive with `text`. The body is fetched via
+ * `resolvePromptText` server-side.
+ */
+ contentBlobUrl?: string;
+ /** Which gate this prompt drives, or a non-gate kind (e.g. `agents.md`).
+ * Absent ⇒ legacy `'select'` (the request's task prompt). */
+ type?: PromptType;
features?: PromptFeatureResult[]; // Detected prompt features
featuresExtractedAt?: Date; // When features were last extracted
createdAt: Date;
@@ -751,6 +791,12 @@ export interface TaskPromptDocument {
export interface PromptFeatureConfig {
id: string;
prompt: string;
+ /**
+ * Which prompt type this feature applies to. Absent ⇒ `'select'` (backward
+ * compatible). Feature extraction only considers features whose `type`
+ * matches the prompt being extracted.
+ */
+ type?: PromptType;
}
/** Prompt feature document stored in MongoDB (extends PromptFeatureConfig with DB metadata) */
From 95b52f3b4fdd0de6b199306a20e012f9ca2e5759 Mon Sep 17 00:00:00 2001
From: Cedric Vidal
Date: Thu, 25 Jun 2026 12:04:41 -0700
Subject: [PATCH 02/14] feat(api): AGENTS.md request handling and typed
task-prompt/prompt-feature routes
Wire AGENTS.md support into the API (#732): create-request accepts agentsMd (raw text) and findOrCreates an 'agents.md'-typed prompt, storing agentsMdPromptId and agentsMdParentIds; task-prompt and prompt-feature routes become type-aware and expose a prompt content endpoint that resolves inline or blob-backed bodies.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
apps/api/src/endpoints.test.ts | 174 +++++++++++++++++++++++++
apps/api/src/index.ts | 9 +-
apps/api/src/routes/prompt-features.ts | 34 ++++-
apps/api/src/routes/requests/index.ts | 38 +++++-
apps/api/src/routes/task-prompts.ts | 53 +++++++-
apps/api/src/test-helpers.ts | 1 +
6 files changed, 295 insertions(+), 14 deletions(-)
diff --git a/apps/api/src/endpoints.test.ts b/apps/api/src/endpoints.test.ts
index 0cabc44eb..6f20c3b77 100644
--- a/apps/api/src/endpoints.test.ts
+++ b/apps/api/src/endpoints.test.ts
@@ -945,6 +945,44 @@ describe("API Endpoints", () => {
expect(res.status).toBe(400);
});
+
+ it("persists type=agents.md when creating a feature", async () => {
+ (mocks.promptFeatureCollection.findOne as any).mockResolvedValue(null);
+
+ const res = await request(app)
+ .post("/api/v1/prompt-features")
+ .send({ id: "agents_feat", prompt: "Does AGENTS.md mention tests?", type: "agents.md" });
+
+ expect(res.status).toBe(201);
+ expect(res.body).toHaveProperty("type", "agents.md");
+ const doc = (mocks.promptFeatureCollection.insertOne as any).mock.calls[0][0];
+ expect(doc).toHaveProperty("type", "agents.md");
+ });
+ });
+
+ describe("GET /api/v1/prompt-features?type=", () => {
+ it("scopes the query to agents.md features", async () => {
+ (mocks.promptFeatureCollection.find as any).mockReturnValue({
+ toArray: vi.fn().mockResolvedValue([]),
+ });
+
+ const res = await request(app).get("/api/v1/prompt-features?type=agents.md");
+ expect(res.status).toBe(200);
+ const filter = (mocks.promptFeatureCollection.find as any).mock.calls[0][0];
+ expect(JSON.stringify(filter)).toContain("agents.md");
+ });
+
+ it("treats select type as including legacy untyped features", async () => {
+ (mocks.promptFeatureCollection.find as any).mockReturnValue({
+ toArray: vi.fn().mockResolvedValue([]),
+ });
+
+ await request(app).get("/api/v1/prompt-features?type=select");
+ const filter = (mocks.promptFeatureCollection.find as any).mock.calls[0][0];
+ const json = JSON.stringify(filter);
+ expect(json).toContain("$exists");
+ expect(json).toContain("select");
+ });
});
describe("GET /api/v1/prompt-features/:id", () => {
@@ -1032,6 +1070,62 @@ describe("API Endpoints", () => {
});
});
+ // ===================================================================
+ // Typed task prompts (task | agents.md) + content resolution
+ // ===================================================================
+
+ describe("Typed task prompts", () => {
+ it("passes ?type=agents.md through to the store getAll filter", async () => {
+ (mocks.taskPromptStore as any).getAll.mockResolvedValue({ items: [], total: 0 });
+
+ const res = await request(app).get("/api/v1/task-prompts?type=agents.md");
+ expect(res.status).toBe(200);
+ const arg = (mocks.taskPromptStore.getAll as any).mock.calls[0][0];
+ expect(arg).toHaveProperty("type", "agents.md");
+ });
+
+ it("creates a prompt with type=agents.md", async () => {
+ (mocks.taskPromptStore as any).findOrCreate.mockResolvedValue({
+ _id: "agents-1",
+ type: "agents.md",
+ text: "# AGENTS\nBe concise.",
+ createdAt: new Date(),
+ });
+
+ const res = await request(app)
+ .post("/api/v1/task-prompts")
+ .send({ text: "# AGENTS\nBe concise.", type: "agents.md" });
+
+ expect(res.status).toBe(201);
+ expect(res.body).toHaveProperty("type", "agents.md");
+ const arg = (mocks.taskPromptStore.findOrCreate as any).mock.calls[0];
+ expect(arg[0]).toBe("# AGENTS\nBe concise.");
+ expect(arg[1]).toBe("agents.md");
+ });
+
+ it("GET /:id/content resolves the prompt body via resolvePromptText", async () => {
+ (mocks.taskPromptStore as any).get.mockResolvedValue({
+ _id: "agents-1",
+ type: "agents.md",
+ contentBlobUrl: "https://blob/prompts/agents-1.txt",
+ createdAt: new Date(),
+ });
+ (mocks.taskPromptStore as any).resolvePromptText.mockResolvedValue("resolved body");
+
+ const res = await request(app).get("/api/v1/task-prompts/agents-1/content");
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({ id: "agents-1", text: "resolved body" });
+ expect(mocks.taskPromptStore.resolvePromptText).toHaveBeenCalled();
+ });
+
+ it("GET /:id/content returns 404 when the prompt does not exist", async () => {
+ (mocks.taskPromptStore as any).get.mockResolvedValue(null);
+
+ const res = await request(app).get("/api/v1/task-prompts/missing/content");
+ expect(res.status).toBe(404);
+ });
+ });
+
// ===================================================================
// Submit with profile — server-side field resolution
// ===================================================================
@@ -1239,6 +1333,86 @@ describe("API Endpoints", () => {
});
});
+ // ===================================================================
+ // AGENTS.md on request creation
+ // ===================================================================
+
+ describe("POST /api/v1/requests?worker=... (AGENTS.md)", () => {
+ const setupCopilotAgent = () => {
+ (mocks.agentCollection.findOne as any).mockResolvedValue({
+ _id: "coder-acp-copilot",
+ versions: [{ agentVersion: "v1", status: "active", queueName: "queue-coder-acp-copilot", createdAt: new Date() }],
+ supportedModels: ["claude-haiku-4.5"],
+ });
+ };
+
+ it("resolves agentsMd via findOrCreate(agents.md) and stores agentsMdPromptId", async () => {
+ setupCopilotAgent();
+ (mocks.taskPromptStore as any).findOrCreate.mockResolvedValue({
+ _id: "agents-xyz",
+ type: "agents.md",
+ text: "# AGENTS\nBe terse.",
+ createdAt: new Date(),
+ });
+
+ const res = await request(app)
+ .post("/api/v1/requests?worker=coder-acp-copilot")
+ .send({
+ scenario: { task: "Build something", criteria: ["works"] },
+ model: "claude-haiku-4.5",
+ agentsMd: "# AGENTS\nBe terse.",
+ });
+
+ expect(res.status).toBe(201);
+ const foc = (mocks.taskPromptStore.findOrCreate as any).mock.calls.find(
+ (c: any[]) => c[1] === "agents.md"
+ );
+ expect(foc).toBeDefined();
+ expect(foc[0]).toBe("# AGENTS\nBe terse.");
+ const doc = (mocks.collection.insertOne as any).mock.calls[0][0];
+ expect(doc).toHaveProperty("agentsMdPromptId", "agents-xyz");
+ });
+
+ it("persists agentsMdParentIds lineage on the request", async () => {
+ setupCopilotAgent();
+ (mocks.taskPromptStore as any).findOrCreate.mockResolvedValue({
+ _id: "agents-child",
+ type: "agents.md",
+ text: "child",
+ createdAt: new Date(),
+ });
+
+ const res = await request(app)
+ .post("/api/v1/requests?worker=coder-acp-copilot")
+ .send({
+ scenario: { task: "Build something", criteria: ["works"] },
+ model: "claude-haiku-4.5",
+ agentsMd: "child",
+ agentsMdParentIds: ["agents-p1", "agents-p2"],
+ });
+
+ expect(res.status).toBe(201);
+ const doc = (mocks.collection.insertOne as any).mock.calls[0][0];
+ expect(doc).toHaveProperty("agentsMdParentIds", ["agents-p1", "agents-p2"]);
+ });
+
+ it("omits AGENTS.md fields when not provided", async () => {
+ setupCopilotAgent();
+
+ const res = await request(app)
+ .post("/api/v1/requests?worker=coder-acp-copilot")
+ .send({
+ scenario: { task: "Build something", criteria: ["works"] },
+ model: "claude-haiku-4.5",
+ });
+
+ expect(res.status).toBe(201);
+ const doc = (mocks.collection.insertOne as any).mock.calls[0][0];
+ expect(doc.agentsMdPromptId).toBeUndefined();
+ expect(doc.agentsMdParentIds).toBeUndefined();
+ });
+ });
+
// ===================================================================
// Submit with gates — free-text gate prompt materialization
// ===================================================================
diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
index cb4c13bd6..6729ea8c7 100644
--- a/apps/api/src/index.ts
+++ b/apps/api/src/index.ts
@@ -130,7 +130,10 @@ async function initializeClients(): Promise {
mcpServerCollection = db.collection("mcp-servers");
insightsCollection = db.collection("insights");
taskPromptCollection = db.collection("task-prompts");
- taskPromptStore = new TaskPromptStore(taskPromptCollection);
+ // Blob storage is also (re)assigned below for logs/snapshots; construct an
+ // instance here so the task-prompt store can offload over-threshold bodies.
+ blobStorage = new BlobStorage({ storageAccountName, storageConnectionString });
+ taskPromptStore = new TaskPromptStore(taskPromptCollection, blobStorage);
featureFlagCollection = db.collection("feature-flags");
reportTemplateCollection = db.collection("report-templates");
skillCollection = db.collection("skills");
@@ -220,7 +223,9 @@ async function initializeClients(): Promise {
console.log(`Initialized Queue clients for workers: ${Array.from(queueClients.keys()).join(", ")}, report`);
- // Initialize blob storage (used for log persistence and snapshots)
+ // Initialize blob storage (used for log persistence and snapshots).
+ // Already constructed above for the task-prompt store; reassign to keep the
+ // original initialization order/comment intact.
blobStorage = new BlobStorage({ storageAccountName, storageConnectionString });
// Initialize Redis-backed heartbeat store. Workers write per-run
diff --git a/apps/api/src/routes/prompt-features.ts b/apps/api/src/routes/prompt-features.ts
index 6a462d23c..d55f556b3 100644
--- a/apps/api/src/routes/prompt-features.ts
+++ b/apps/api/src/routes/prompt-features.ts
@@ -110,6 +110,7 @@ apiRoute(ctx.app, ctx.registry, {
$setOnInsert: {
id: trimmedId,
prompt: config.prompt.trim(),
+ ...(config.type ? { type: config.type } : {}),
createdAt: new Date(),
},
},
@@ -134,6 +135,7 @@ apiRoute(ctx.app, ctx.registry, {
body: z.object({
text: z.string(),
model: z.string().optional(),
+ type: z.enum(["select", "agents.md"]).optional(),
}),
response: z.object({
features: z.array(PromptFeatureResultSchema),
@@ -145,7 +147,7 @@ apiRoute(ctx.app, ctx.registry, {
503: { description: "LLM not configured" },
},
handler: async (req, res, next) => {
- const { text, model } = req.body;
+ const { text, model, type } = req.body;
if (!text || typeof text !== "string" || !text.trim()) {
res.status(400).json({ error: "Body must contain a non-empty 'text' string" });
return;
@@ -156,8 +158,15 @@ apiRoute(ctx.app, ctx.registry, {
return;
}
+ // Scope candidate features to the requested type (default = select,
+ // including legacy features with no `type`).
+ const promptType = type ?? "select";
+ const typeFilter: Record =
+ promptType === "select"
+ ? { $or: [{ type: "select" }, { type: { $exists: false } }] }
+ : { type: promptType };
const allFeatures = await ctx.promptFeatureCollection
- .find({ deletedAt: { $exists: false } })
+ .find({ deletedAt: { $exists: false }, ...typeFilter })
.toArray();
const featureConfigs = allFeatures.map(f => ({ id: f.id, prompt: f.prompt }));
@@ -185,16 +194,30 @@ apiRoute(ctx.app, ctx.registry, {
path: "/api/v1/prompt-features",
tags: ["Prompt Features"],
summary: "List features",
- query: z.object({ q: z.string().optional() }),
+ query: z.object({ q: z.string().optional(), type: z.enum(["select", "agents.md"]).optional() }),
response: z.array(PromptFeatureResponseSchema),
handler: async (req, res) => {
const q = req.query.q;
+ const type = req.query.type;
const filter: Record = { deletedAt: { $exists: false } };
+ if (type) {
+ // Absent `type` is treated as 'select' for backward compatibility.
+ filter.$and = [
+ type === "select"
+ ? { $or: [{ type: "select" }, { type: { $exists: false } }] }
+ : { type },
+ ];
+ }
if (q) {
- filter.$or = [
+ const search = [
{ id: { $regex: q, $options: "i" } },
{ prompt: { $regex: q, $options: "i" } },
];
+ if (filter.$and) {
+ (filter.$and as unknown[]).push({ $or: search });
+ } else {
+ filter.$or = search;
+ }
}
const features = await ctx.promptFeatureCollection.find(filter).toArray();
features.sort((a, b) => a.id.localeCompare(b.id));
@@ -239,7 +262,7 @@ apiRoute(ctx.app, ctx.registry, {
409: { description: "Feature already exists" },
},
handler: async (req, res) => {
- const { id, prompt } = req.body;
+ const { id, prompt, type } = req.body;
if (!id || typeof id !== "string") {
res.status(400).json({ error: "id is required and must be a string" });
@@ -263,6 +286,7 @@ apiRoute(ctx.app, ctx.registry, {
const doc: PromptFeatureDocument = {
id,
prompt: prompt.trim(),
+ ...(type ? { type } : {}),
createdAt: new Date(),
};
diff --git a/apps/api/src/routes/requests/index.ts b/apps/api/src/routes/requests/index.ts
index b6fcd4b1d..d6be12ac0 100644
--- a/apps/api/src/routes/requests/index.ts
+++ b/apps/api/src/routes/requests/index.ts
@@ -168,9 +168,33 @@ apiRoute(ctx.app, ctx.registry, {
response: z.union([RequestResponseSchema, z.array(RequestResponseSchema)]),
successStatus: 201,
handler: async (req, res) => {
- const { scenario: scenarioObj, persona: personaObj, maxIterations, personaInstructions, count = 1, promptFeatureExtractionId, model: requestedModel, reasoningEffort: requestedReasoningEffort, mcpServers: mcpServerSlugs, skills: skillSlugs, extensions: extensionIds, agentVersion: requestedAgentVersion, profileId: requestedProfileSpec, profileVariations, priority: requestedPriority, gates: requestedGates, codebase: codebaseSpec, codebaseRevisionId: requestedCodebaseRevisionId } = req.body;
+ const { scenario: scenarioObj, persona: personaObj, maxIterations, personaInstructions, count = 1, promptFeatureExtractionId, model: requestedModel, reasoningEffort: requestedReasoningEffort, mcpServers: mcpServerSlugs, skills: skillSlugs, extensions: extensionIds, agentVersion: requestedAgentVersion, profileId: requestedProfileSpec, profileVariations, priority: requestedPriority, agentsMd: requestedAgentsMd, agentsMdParentIds: requestedAgentsMdParentIds, gates: requestedGates, codebase: codebaseSpec, codebaseRevisionId: requestedCodebaseRevisionId } = req.body;
let worker = req.query.worker as string | undefined;
+ // AGENTS.md body + lineage (for any caller that wants to attach an
+ // AGENTS.md instruction file to a run).
+ const agentsMdText =
+ typeof requestedAgentsMd === "string" && requestedAgentsMd.trim()
+ ? requestedAgentsMd
+ : undefined;
+ const agentsMdParentIds = Array.isArray(requestedAgentsMdParentIds)
+ ? requestedAgentsMdParentIds.filter((x: unknown): x is string => typeof x === "string")
+ : undefined;
+
+ // Resolve the AGENTS.md body to an `agents.md`-typed prompt id (idempotent,
+ // content-addressed; large bodies are offloaded to blob by the store).
+ const resolveAgentsMdPromptId = async (): Promise => {
+ if (!agentsMdText) return undefined;
+ const p = await ctx.taskPromptStore.findOrCreate(agentsMdText, "agents.md");
+ return p._id;
+ };
+
+ // Common request fields for AGENTS.md delivery + lineage.
+ const buildAgentsMdFields = (agentsMdPromptId?: string) => ({
+ ...(agentsMdPromptId ? { agentsMdPromptId } : {}),
+ ...(agentsMdParentIds && agentsMdParentIds.length ? { agentsMdParentIds } : {}),
+ });
+
// Resolve the optional per-run codebase selection into a concrete revision id.
// Accepts an already-resolved `codebaseRevisionId`, or a `codebase` spec
// (revision id / `{slug}@r{N}` ref / bare slug). A bare git slug resolves the
@@ -463,6 +487,8 @@ apiRoute(ctx.app, ctx.registry, {
const allNewIds: string[] = [];
const newDocs: RequestDocument[] = [];
const variationResults: Array<{ profileId: string; label?: string; ids: string[] }> = [];
+ const agentsMdPromptId = await resolveAgentsMdPromptId();
+ const agentsMdFields = buildAgentsMdFields(agentsMdPromptId);
for (const r of resolved) {
const newIds: string[] = [];
@@ -495,6 +521,7 @@ apiRoute(ctx.app, ctx.registry, {
profileId: r.profile._id,
profileVersionId: r.profileVersion._id,
submissionId,
+ ...agentsMdFields,
run: { _id: runId, attemptNumber: 1, status: "pending", logsUrl: ctx.blobStorage.getLogsBlobUrl(`${requestId}/runs/${runId}/run.jsonl`) },
};
newDocs.push(requestDoc);
@@ -879,6 +906,10 @@ apiRoute(ctx.app, ctx.registry, {
const taskPrompt = await ctx.taskPromptStore.findOrCreate(scenario.task);
const taskPromptId = taskPrompt._id;
+ // Resolve AGENTS.md lineage once for this submission (shared across count>1).
+ const agentsMdPromptId = await resolveAgentsMdPromptId();
+ const agentsMdFields = buildAgentsMdFields(agentsMdPromptId);
+
// Build the persisted gate configs: order canonically and point the Select
// gate's prompt at the resolved task prompt (docs/design/gates.md §4.3).
const persistedGates: GateConfig[] | undefined = gatesProvided
@@ -922,6 +953,7 @@ apiRoute(ctx.app, ctx.registry, {
...(profileVersionId ? { profileVersionId } : {}),
...(persistedGates ? { gates: persistedGates } : {}),
submissionId,
+ ...agentsMdFields,
// Mint a distinct run id for the first attempt. Blob artifacts
// are scoped under `{requestId}/runs/{runId}/...` so retries
// never overwrite a previous attempt's blobs.
@@ -982,6 +1014,7 @@ apiRoute(ctx.app, ctx.registry, {
...(profileVersionId ? { profileVersionId } : {}),
...(persistedGates ? { gates: persistedGates } : {}),
submissionId,
+ ...agentsMdFields,
// Mint a distinct run id for the first attempt. Blob artifacts
// are scoped under `{requestId}/runs/{runId}/...` so retries
// never overwrite a previous attempt's blobs.
@@ -1619,6 +1652,9 @@ apiRoute(ctx.app, ctx.registry, {
...(original.taskPromptId ? { taskPromptId: original.taskPromptId } : {}),
...(effectiveProfileId ? { profileId: effectiveProfileId } : {}),
...(effectiveProfileVersionId ? { profileVersionId: effectiveProfileVersionId } : {}),
+ // Preserve AGENTS.md lineage across re-submits.
+ ...(original.agentsMdPromptId ? { agentsMdPromptId: original.agentsMdPromptId } : {}),
+ ...(original.agentsMdParentIds && original.agentsMdParentIds.length > 0 ? { agentsMdParentIds: original.agentsMdParentIds } : {}),
submissionId,
// Bulk re-submit creates a brand-new request — mint a distinct
// run id for the first attempt so blob artifacts live under
diff --git a/apps/api/src/routes/task-prompts.ts b/apps/api/src/routes/task-prompts.ts
index 9cc4faa76..45280e437 100644
--- a/apps/api/src/routes/task-prompts.ts
+++ b/apps/api/src/routes/task-prompts.ts
@@ -47,7 +47,10 @@ apiRoute(ctx.app, ctx.registry, {
// Fetch recent task prompts as context (avoid duplicates)
const recentPrompts = await ctx.taskPromptCollection
- .find({ deletedAt: { $exists: false } })
+ .find({
+ deletedAt: { $exists: false },
+ $or: [{ type: "select" }, { type: { $exists: false } }],
+ })
.sort({ createdAt: -1 })
.limit(20)
.project({ text: 1, _id: 0 })
@@ -117,7 +120,33 @@ apiRoute(ctx.app, ctx.registry, {
},
});
-// POST /api/v1/task-prompts — create (or find existing) task prompt. Idempotent.
+// GET /api/v1/task-prompts/:id/content — get the resolved plain-text body
+// (inline or downloaded from blob). Used by workers to fetch AGENTS.md / task text.
+apiRoute(ctx.app, ctx.registry, {
+ method: "get",
+ path: "/api/v1/task-prompts/:id/content",
+ tags: ["Task Prompts"],
+ summary: "Get resolved task prompt content",
+ params: z.object({ id: z.string() }),
+ response: z.object({ id: z.string(), text: z.string() }),
+ errorResponses: {
+ 404: { description: "Task prompt not found" },
+ },
+ handler: async (req, res, next) => {
+ try {
+ const { id } = req.params;
+ const taskPrompt = await ctx.taskPromptStore.get(id);
+ if (!taskPrompt) {
+ res.status(404).json({ error: "Task prompt not found" });
+ return;
+ }
+ const text = await ctx.taskPromptStore.resolvePromptText(taskPrompt);
+ res.json({ id, text });
+ } catch (err) {
+ next(err);
+ }
+ },
+});
apiRoute(ctx.app, ctx.registry, {
method: "post",
path: "/api/v1/task-prompts",
@@ -135,8 +164,12 @@ apiRoute(ctx.app, ctx.registry, {
return;
}
- const taskPrompt = await ctx.taskPromptStore.findOrCreate(text, type);
- res.status(201).json(taskPrompt);
+ try {
+ const taskPrompt = await ctx.taskPromptStore.findOrCreate(text, type);
+ res.status(201).json(taskPrompt);
+ } catch (err) {
+ next(err);
+ }
},
});
@@ -214,13 +247,21 @@ apiRoute(ctx.app, ctx.registry, {
return;
}
+ // Scope candidate features to the prompt's own type: task prompts get task
+ // features; AGENTS.md prompts get agents.md features. Absent type ⇒ select.
+ const promptType = taskPrompt.type ?? "select";
+ const typeFilter: Record =
+ promptType === "select"
+ ? { $or: [{ type: "select" }, { type: { $exists: false } }] }
+ : { type: promptType };
const allFeatures = await ctx.promptFeatureCollection
- .find({ deletedAt: { $exists: false } })
+ .find({ deletedAt: { $exists: false }, ...typeFilter })
.toArray();
const featureConfigs = allFeatures.map(f => ({ id: f.id, prompt: f.prompt }));
try {
- const { results, suggestedFeatures } = await extractPromptFeatures(taskPrompt.text, featureConfigs, model);
+ const promptText = await ctx.taskPromptStore.resolvePromptText(taskPrompt);
+ const { results, suggestedFeatures } = await extractPromptFeatures(promptText, featureConfigs, model);
// Store features on the task prompt entity
const updated = await ctx.taskPromptStore.attachFeatures(id, results);
diff --git a/apps/api/src/test-helpers.ts b/apps/api/src/test-helpers.ts
index 6230d6d02..a35282d41 100644
--- a/apps/api/src/test-helpers.ts
+++ b/apps/api/src/test-helpers.ts
@@ -119,6 +119,7 @@ export function createMockTaskPromptStore(): Record {
createdAt: new Date(),
}),
getAll: vi.fn().mockResolvedValue({ items: [], total: 0 }),
+ resolvePromptText: vi.fn().mockResolvedValue("mock task"),
delete: vi.fn().mockResolvedValue(undefined),
attachFeatures: vi.fn().mockResolvedValue({
_id: "tp-mock-id",
From 666dc2c1897345ca2e5910adfbc0b47ef6dc184d Mon Sep 17 00:00:00 2001
From: Cedric Vidal
Date: Thu, 25 Jun 2026 12:04:49 -0700
Subject: [PATCH 03/14] feat(worker): deliver AGENTS.md into the workspace
before the run starts
The shared queue processor resolves the agentsMdPromptId body (downloading from blob storage when needed) and writes /AGENTS.md once before the first turn. Fails the run loudly if the prompt id is set but cannot be resolved, rather than silently running the baseline (#732).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../shared/src/queue/queue-processor.test.ts | 59 ++++++++++++++++++-
packages/shared/src/queue/queue-processor.ts | 45 ++++++++++++++
2 files changed, 103 insertions(+), 1 deletion(-)
diff --git a/packages/shared/src/queue/queue-processor.test.ts b/packages/shared/src/queue/queue-processor.test.ts
index 3c6511466..a0c1a8a00 100644
--- a/packages/shared/src/queue/queue-processor.test.ts
+++ b/packages/shared/src/queue/queue-processor.test.ts
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { describe, it, expect, vi } from "vitest";
+import { describe, it, expect, vi, afterEach } from "vitest";
import os from "node:os";
import { CodingAgentQueueProcessor } from "./queue-processor.js";
import type { QueueProcessorConfig, WorkerProcessor, WorkerResult } from "../types/types.js";
@@ -82,6 +82,63 @@ describe("CodingAgentQueueProcessor.getVersionFields", () => {
});
});
+// ─── AGENTS.md workspace delivery ────────────────────────────────────────────
+describe("CodingAgentQueueProcessor.writeAgentsMd", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("no-ops when the request has no agentsMdPromptId", async () => {
+ const qp = new CodingAgentQueueProcessor(testConfig, stubProcessor);
+ const log = vi.fn().mockResolvedValue(undefined);
+ const fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+
+ await (qp as any).writeAgentsMd({ _id: "r1" }, "/tmp/does-not-matter", log);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("throws when agentsMdPromptId is set but apiBaseUrl is missing", async () => {
+ // testConfig has no apiBaseUrl.
+ const qp = new CodingAgentQueueProcessor(testConfig, stubProcessor);
+ const log = vi.fn().mockResolvedValue(undefined);
+
+ await expect(
+ (qp as any).writeAgentsMd({ _id: "r1", agentsMdPromptId: "agents-1" }, "/tmp", log),
+ ).rejects.toThrow(/apiBaseUrl/);
+ });
+
+ it("writes the resolved AGENTS.md body into the workspace root", async () => {
+ const { mkdtemp, readFile, rm } = await import("node:fs/promises");
+ const { tmpdir } = await import("node:os");
+ const { join } = await import("node:path");
+ const workspace = await mkdtemp(join(tmpdir(), "agents-md-test-"));
+
+ try {
+ const qp = new CodingAgentQueueProcessor(
+ { ...testConfig, apiBaseUrl: "https://api.example.com" },
+ stubProcessor,
+ );
+ const log = vi.fn().mockResolvedValue(undefined);
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ id: "agents-1", text: "# AGENTS\nBe terse." }),
+ }),
+ );
+
+ await (qp as any).writeAgentsMd({ _id: "r1", agentsMdPromptId: "agents-1" }, workspace, log);
+
+ const written = await readFile(join(workspace, "AGENTS.md"), "utf-8");
+ expect(written).toBe("# AGENTS\nBe terse.");
+ } finally {
+ await rm(workspace, { recursive: true, force: true });
+ }
+ });
+});
+
// ─── Redelivery fail-fast (handleRequest pre-checks) ─────────────────────────
describe("CodingAgentQueueProcessor.handleRequest redelivery handling", () => {
function makeHarness(runStatus: string, runOverrides: Record = {}) {
diff --git a/packages/shared/src/queue/queue-processor.ts b/packages/shared/src/queue/queue-processor.ts
index 52f459aee..f0f48686f 100644
--- a/packages/shared/src/queue/queue-processor.ts
+++ b/packages/shared/src/queue/queue-processor.ts
@@ -32,6 +32,9 @@ import { McpSecretClient } from "../mcp/mcp-secret-client.js";
import { SkillClient } from "../skills/skill-client.js";
import { ExtensionClient } from "../extensions/extension-client.js";
import { extractSkillsToWorkspace } from "../skills/skill-extractor.js";
+import { PromptClient } from "../task-prompts/prompt-client.js";
+import { writeFile } from "node:fs/promises";
+import { join } from "node:path";
import { CodebaseClient } from "../codebases/codebase-client.js";
import { seedCodebaseToWorkspace } from "../codebases/codebase-seeder.js";
@@ -410,6 +413,44 @@ export class CodingAgentQueueProcessor extends BaseQueueProcessor/AGENTS.md` before the first turn. The text is resolved via
+ * the API (`GET /api/v1/task-prompts/:id/content`), which downloads from blob
+ * storage when the prompt is blob-backed — the worker never touches blob.
+ *
+ * Fails loudly (throws) when `agentsMdPromptId` is set but `apiBaseUrl` is
+ * missing or the fetch/write fails, so the run is marked failed rather than
+ * silently evaluating the baseline worker (which would corrupt results).
+ */
+ private async writeAgentsMd(
+ requestDoc: RequestDocument,
+ workspacePath: string,
+ log: (level: LogEvent["level"], msg: string, data?: Record) => Promise,
+ ): Promise {
+ const agentsMdPromptId = requestDoc.agentsMdPromptId;
+ if (!agentsMdPromptId) return;
+
+ const apiBaseUrl = (this.config as QueueProcessorConfig).apiBaseUrl;
+ if (!apiBaseUrl) {
+ throw new Error(
+ `Request has agentsMdPromptId '${agentsMdPromptId}' but no apiBaseUrl is configured; cannot resolve AGENTS.md`,
+ );
+ }
+
+ const promptClient = new PromptClient(apiBaseUrl);
+ const text = await promptClient.getText(agentsMdPromptId);
+ const agentsMdPath = join(workspacePath, "AGENTS.md");
+ await writeFile(agentsMdPath, text, "utf-8");
+ await log("info", `Wrote AGENTS.md to workspace`, {
+ agentsMdPromptId,
+ path: agentsMdPath,
+ bytes: Buffer.byteLength(text, "utf-8"),
+ });
+ }
+
/**
* Resolve a typed prompt entity's text by id via the API. Used for non-Select
* gates whose prompt is referenced by id on the request's gate config. Throws
@@ -596,6 +637,10 @@ export class CodingAgentQueueProcessor extends BaseQueueProcessor
Date: Thu, 25 Jun 2026 12:04:58 -0700
Subject: [PATCH 04/14] feat(cli): --agents-md run flag and type-aware
task-prompt/prompt-feature commands
Add CLI parity for AGENTS.md support (#732): 'run submit --agents-md ' submits a run with an AGENTS.md prompt, and the task-prompt and prompt-feature commands gain a --type option to manage 'agents.md' prompts alongside task prompts.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
apps/cli/src/commands/prompt-feature.test.ts | 101 ++++++++++++++++++
apps/cli/src/commands/prompt-feature.ts | 8 +-
apps/cli/src/commands/run.test.ts | 59 +++++++++++
apps/cli/src/commands/run.ts | 23 +++-
apps/cli/src/commands/task-prompt.test.ts | 106 +++++++++++++++++++
apps/cli/src/commands/task-prompt.ts | 20 ++--
apps/cli/src/utils/gates.ts | 6 +-
7 files changed, 312 insertions(+), 11 deletions(-)
create mode 100644 apps/cli/src/commands/prompt-feature.test.ts
create mode 100644 apps/cli/src/commands/task-prompt.test.ts
diff --git a/apps/cli/src/commands/prompt-feature.test.ts b/apps/cli/src/commands/prompt-feature.test.ts
new file mode 100644
index 000000000..a29e695de
--- /dev/null
+++ b/apps/cli/src/commands/prompt-feature.test.ts
@@ -0,0 +1,101 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { Command } from "commander";
+import { registerPromptFeatureCommands } from "./prompt-feature.js";
+
+function makeProgram(): Command {
+ const program = new Command();
+ registerPromptFeatureCommands(program);
+ return program;
+}
+
+function mockFetchWith(body: unknown): void {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => body }));
+}
+
+function fetchMock(): ReturnType {
+ return fetch as unknown as ReturnType;
+}
+
+describe("prompt-feature list", () => {
+ beforeEach(() => vi.restoreAllMocks());
+ afterEach(() => vi.restoreAllMocks());
+
+ it("passes --type as a query param", async () => {
+ mockFetchWith([]);
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+
+ try {
+ const program = makeProgram();
+ await program.parseAsync(
+ ["prompt-feature", "list", "--type", "agents.md", "-u", "http://localhost:3100"],
+ { from: "user" },
+ );
+ } finally {
+ logSpy.mockRestore();
+ }
+
+ const url = fetchMock().mock.calls[0][0] as string;
+ expect(url).toContain("type=agents.md");
+ });
+
+ it("renders a Type column defaulting to 'select'", async () => {
+ mockFetchWith([{ id: "feat-1", prompt: "detect something" }]);
+ const lines: string[] = [];
+ const logSpy = vi.spyOn(console, "log").mockImplementation((...p: unknown[]) => {
+ lines.push(p.map(String).join(" "));
+ });
+
+ try {
+ const program = makeProgram();
+ await program.parseAsync(["prompt-feature", "list", "-u", "http://localhost:3100"], { from: "user" });
+ } finally {
+ logSpy.mockRestore();
+ }
+
+ expect(lines.join("\n")).toContain("select");
+ });
+});
+
+describe("prompt-feature create", () => {
+ beforeEach(() => vi.restoreAllMocks());
+ afterEach(() => vi.restoreAllMocks());
+
+ it("sends type in the create body when provided", async () => {
+ mockFetchWith({ id: "feat-new" });
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+
+ try {
+ const program = makeProgram();
+ await program.parseAsync(
+ ["prompt-feature", "create", "--id", "my_feat", "--prompt", "detect x", "--type", "agents.md", "-u", "http://localhost:3100"],
+ { from: "user" },
+ );
+ } finally {
+ logSpy.mockRestore();
+ }
+
+ const init = fetchMock().mock.calls[0][1] as { body: string };
+ expect(JSON.parse(init.body)).toEqual({ id: "my_feat", prompt: "detect x", type: "agents.md" });
+ });
+
+ it("omits type when not provided (backward compatible)", async () => {
+ mockFetchWith({ id: "feat-new-2" });
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+
+ try {
+ const program = makeProgram();
+ await program.parseAsync(
+ ["prompt-feature", "create", "--id", "my_feat2", "--prompt", "detect y", "-u", "http://localhost:3100"],
+ { from: "user" },
+ );
+ } finally {
+ logSpy.mockRestore();
+ }
+
+ const init = fetchMock().mock.calls[0][1] as { body: string };
+ expect(JSON.parse(init.body)).toEqual({ id: "my_feat2", prompt: "detect y" });
+ });
+});
diff --git a/apps/cli/src/commands/prompt-feature.ts b/apps/cli/src/commands/prompt-feature.ts
index 49dd6c7d5..e17d7a39e 100644
--- a/apps/cli/src/commands/prompt-feature.ts
+++ b/apps/cli/src/commands/prompt-feature.ts
@@ -29,6 +29,7 @@ promptFeature
.command("list")
.description("List all prompt features")
.option("-q, --query ", "Filter by ID or prompt text")
+ .option("--type ", "Filter by feature type ('select' or 'agents.md')")
.option("-u, --url ", "API base URL", getDefaultApiUrl())
)
.action(async (options) => {
@@ -36,6 +37,7 @@ promptFeature
try {
const params = new URLSearchParams();
if (options.query) params.set("q", options.query);
+ if (options.type) params.set("type", options.type);
const qs = params.toString();
const response = await fetch(`${normalizeUrl(options.url)}/api/v1/prompt-features${qs ? `?${qs}` : ""}`);
@@ -45,7 +47,7 @@ promptFeature
process.exit(1);
}
- const items = await response.json() as Array<{ id: string; prompt: string }>;
+ const items = await response.json() as Array<{ id: string; prompt: string; type?: string }>;
if (items.length === 0) {
if (!isMachineReadable(format)) console.log(warnBanner("No prompt features found."));
return;
@@ -59,6 +61,8 @@ promptFeature
{ key: 'id', label: 'ID',
tableFormatter: (f: any) => value(f.id),
},
+ { key: 'type', label: 'Type', formatter: (f: any) => f.type ?? 'select',
+ tableFormatter: (f: any) => value(f.type ?? 'select') },
{ key: 'prompt', label: 'Prompt', formatter: (f: any) => {
const prompt = f.prompt.replace(/\n/g, ' ');
return prompt.length > 60 ? prompt.substring(0, 60) + '…' : prompt;
@@ -128,6 +132,7 @@ promptFeature
.description("Create a new prompt feature")
.requiredOption("--id ", "Prompt feature ID (lowercase snake_case)")
.requiredOption("--prompt ", "Detection prompt for the feature")
+ .option("--type ", "Feature type ('select' default, or 'agents.md')")
.option("-u, --url ", "API base URL", getDefaultApiUrl())
.action(async (options) => {
try {
@@ -135,6 +140,7 @@ promptFeature
id: options.id,
prompt: options.prompt,
};
+ if (options.type) body.type = options.type;
const response = await fetch(`${normalizeUrl(options.url)}/api/v1/prompt-features`, {
method: "POST",
diff --git a/apps/cli/src/commands/run.test.ts b/apps/cli/src/commands/run.test.ts
index ba2ea253e..aa2af017b 100644
--- a/apps/cli/src/commands/run.test.ts
+++ b/apps/cli/src/commands/run.test.ts
@@ -157,6 +157,65 @@ describe("run list", () => {
});
});
+describe("run submit", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ function captureSubmit(args: string[]): { url: string; body: Record } {
+ const fetchMock = fetch as unknown as ReturnType;
+ const [url, init] = fetchMock.mock.calls[0];
+ return { url: url as string, body: JSON.parse((init as { body: string }).body) };
+ }
+
+ it("includes agentsMd in the submit body", async () => {
+ mockFetchWith({ id: "req-submit-1", workerType: "coder-acp-copilot", status: "queued" });
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+
+ try {
+ const program = makeProgram();
+ await program.parseAsync(
+ [
+ "run", "submit",
+ "-m", "do the thing",
+ "--agents-md", "# Be helpful",
+ "--no-stream",
+ "-u", "http://localhost:3100",
+ ],
+ { from: "user" },
+ );
+ } finally {
+ logSpy.mockRestore();
+ }
+
+ const { url, body } = captureSubmit([]);
+ expect(url).toContain("/api/v1/requests?worker=coder-acp-copilot");
+ expect(body.agentsMd).toBe("# Be helpful");
+ });
+
+ it("omits agentsMd when not provided", async () => {
+ mockFetchWith({ id: "req-submit-2", workerType: "coder-acp-copilot", status: "queued" });
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+
+ try {
+ const program = makeProgram();
+ await program.parseAsync(
+ ["run", "submit", "-m", "plain task", "--no-stream", "-u", "http://localhost:3100"],
+ { from: "user" },
+ );
+ } finally {
+ logSpy.mockRestore();
+ }
+
+ const { body } = captureSubmit([]);
+ expect(body).not.toHaveProperty("agentsMd");
+ });
+});
+
describe("run retry", () => {
beforeEach(() => {
vi.restoreAllMocks();
diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts
index 7da9840ea..a01d428b3 100644
--- a/apps/cli/src/commands/run.ts
+++ b/apps/cli/src/commands/run.ts
@@ -18,6 +18,18 @@ import { runGetAction } from "../run-get-action.js";
import { normalizeUrl, printFollowUpCommands, withOutputOption, getDefaultApiUrl } from "../utils/shared.js";
import { parseGatesOption } from "../utils/gates.js";
+/**
+ * Resolve a CLI option that may be either a literal string or a `@path`
+ * reference to a file whose contents should be read. Used for flags like
+ * `--agents-md` where large bodies are inconvenient to pass inline.
+ */
+function resolveTextOrFile(input: string): string {
+ if (input.startsWith("@")) {
+ return readFileSync(resolve(input.slice(1)), "utf8");
+ }
+ return input;
+}
+
export function registerRunCommands(program: Command): void {
const run = program
.command("run")
@@ -48,11 +60,12 @@ run
.option("--profile ", "Saved profile to apply (supplies worker, model, extensions, etc.)")
.addOption(new Option("--base-profile ", "Deprecated alias for --profile.").hideHelp())
.option("--profile-variations-file ", "Path to JSON file containing profile variation entries")
+ .option("--agents-md ", "AGENTS.md content delivered to the workspace (prefix with @ to read from a file)")
.option("--gates ", "GateConfig[] JSON or path/@path to a JSON file for gated runs")
.option("-u, --url ", "API base URL", process.env.SCOPE_API_URL || "http://localhost:3100")
.option("--no-stream", "Don't stream logs, just submit")
.action(async (options, command) => {
- const { scenario, persona, traits, worker, url, stream, maxIterations, model, reasoningEffort, mcpServers: mcpServerSlugs, skills: skillSlugs, codebase: codebaseRef, extensions: extensionIds, agentVersion, profile, baseProfile, profileVariationsFile, gates: gatesOption } = options;
+ const { scenario, persona, traits, worker, url, stream, maxIterations, model, reasoningEffort, mcpServers: mcpServerSlugs, skills: skillSlugs, codebase: codebaseRef, extensions: extensionIds, agentVersion, profile, baseProfile, profileVariationsFile, gates: gatesOption, agentsMd: agentsMdInput } = options;
// `--profile` is the documented flag; `--base-profile` is kept as a hidden
// back-compat alias. Both resolve to the same request `profileId`.
const profileId = profile ?? baseProfile;
@@ -124,6 +137,14 @@ run
if (profileId) {
body.profileId = profileId;
}
+ if (agentsMdInput) {
+ // `@path` reads the AGENTS.md body from a file; otherwise the value is
+ // treated as the literal content.
+ const agentsMd = resolveTextOrFile(agentsMdInput);
+ if (agentsMd.trim().length > 0) {
+ body.agentsMd = agentsMd;
+ }
+ }
if (gatesOption) {
body.gates = parseGatesOption(gatesOption, maxIterations);
}
diff --git a/apps/cli/src/commands/task-prompt.test.ts b/apps/cli/src/commands/task-prompt.test.ts
new file mode 100644
index 000000000..9265d0909
--- /dev/null
+++ b/apps/cli/src/commands/task-prompt.test.ts
@@ -0,0 +1,106 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { Command } from "commander";
+import { registerTaskPromptCommands } from "./task-prompt.js";
+
+function makeProgram(): Command {
+ const program = new Command();
+ registerTaskPromptCommands(program);
+ return program;
+}
+
+function mockFetchWith(body: unknown): void {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => body }));
+}
+
+function fetchMock(): ReturnType {
+ return fetch as unknown as ReturnType;
+}
+
+describe("task-prompt list", () => {
+ beforeEach(() => vi.restoreAllMocks());
+ afterEach(() => vi.restoreAllMocks());
+
+ it("passes --type as a query param", async () => {
+ mockFetchWith({ items: [], total: 0 });
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+
+ try {
+ const program = makeProgram();
+ await program.parseAsync(
+ ["task-prompt", "list", "--type", "agents.md", "-u", "http://localhost:3100"],
+ { from: "user" },
+ );
+ } finally {
+ logSpy.mockRestore();
+ }
+
+ const url = fetchMock().mock.calls[0][0] as string;
+ expect(url).toContain("type=agents.md");
+ });
+
+ it("renders blob-backed prompts without crashing on missing text", async () => {
+ mockFetchWith({
+ items: [{ _id: "abcdef1234", type: "agents.md", contentBlobUrl: "prompts/abcdef1234.txt", createdAt: new Date().toISOString() }],
+ total: 1,
+ });
+ const lines: string[] = [];
+ const logSpy = vi.spyOn(console, "log").mockImplementation((...p: unknown[]) => {
+ lines.push(p.map(String).join(" "));
+ });
+
+ try {
+ const program = makeProgram();
+ await program.parseAsync(["task-prompt", "list", "-u", "http://localhost:3100"], { from: "user" });
+ } finally {
+ logSpy.mockRestore();
+ }
+
+ const output = lines.join("\n");
+ expect(output).toContain("agents.md");
+ expect(output).toContain("(blob)");
+ });
+});
+
+describe("task-prompt create", () => {
+ beforeEach(() => vi.restoreAllMocks());
+ afterEach(() => vi.restoreAllMocks());
+
+ it("sends type in the create body when provided", async () => {
+ mockFetchWith({ _id: "id-1", type: "agents.md", createdAt: new Date().toISOString() });
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+
+ try {
+ const program = makeProgram();
+ await program.parseAsync(
+ ["task-prompt", "create", "-t", "# Guidance", "--type", "agents.md", "-u", "http://localhost:3100"],
+ { from: "user" },
+ );
+ } finally {
+ logSpy.mockRestore();
+ }
+
+ const init = fetchMock().mock.calls[0][1] as { body: string };
+ expect(JSON.parse(init.body)).toEqual({ text: "# Guidance", type: "agents.md" });
+ });
+
+ it("defaults type to select when not provided", async () => {
+ mockFetchWith({ _id: "id-2", createdAt: new Date().toISOString() });
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+
+ try {
+ const program = makeProgram();
+ await program.parseAsync(
+ ["task-prompt", "create", "-t", "plain task", "-u", "http://localhost:3100"],
+ { from: "user" },
+ );
+ } finally {
+ logSpy.mockRestore();
+ }
+
+ const init = fetchMock().mock.calls[0][1] as { body: string };
+ expect(JSON.parse(init.body)).toEqual({ text: "plain task", type: "select" });
+ });
+});
diff --git a/apps/cli/src/commands/task-prompt.ts b/apps/cli/src/commands/task-prompt.ts
index 5bb3643a6..74e77909a 100644
--- a/apps/cli/src/commands/task-prompt.ts
+++ b/apps/cli/src/commands/task-prompt.ts
@@ -29,7 +29,7 @@ taskPrompt
.command("list")
.description("List all task prompts")
.option("-s, --search ", "Filter by text content")
- .option("--type ", "Filter by prompt type/gate (select, build, test, run, deploy)")
+ .option("--type ", "Filter by prompt type/gate (select, build, test, run, deploy, agents.md)")
.option("-l, --limit ", "Maximum number of results", "50")
.option("--offset ", "Number of results to skip", "0")
.option("-u, --url ", "API base URL", getDefaultApiUrl())
@@ -69,10 +69,12 @@ taskPrompt
},
{ key: 'type', label: 'Type', formatter: (tp: any) => tp.type ?? 'select' },
{ key: 'text', label: 'Text', formatter: (tp: any) => {
- const text = tp.text.replace(/\n/g, ' ');
+ const text = (tp.text ?? '').replace(/\n/g, ' ');
+ if (!text) return tp.contentBlobUrl ? '(blob)' : '';
return text.length > 60 ? text.substring(0, 60) + '…' : text;
}, tableFormatter: (tp: any) => {
- const text = tp.text.replace(/\n/g, ' ');
+ const text = (tp.text ?? '').replace(/\n/g, ' ');
+ if (!text) return dimTimestamp(tp.contentBlobUrl ? '(blob)' : '');
const truncated = text.length > 60 ? text.substring(0, 60) + '…' : text;
return dimTimestamp(truncated);
}},
@@ -110,7 +112,7 @@ taskPrompt
}
const tp = await response.json() as {
- _id: string; text: string; type?: PromptType;
+ _id: string; text?: string; type?: PromptType; contentBlobUrl?: string;
features?: Array<{ featureId: string; detected: boolean; evaluated: boolean }>;
featuresExtractedAt?: string;
createdAt: string; deletedAt?: string;
@@ -139,8 +141,12 @@ taskPrompt
console.log(`${label('Created:')} ${value(tp.createdAt)}`);
if (tp.deletedAt) console.log(`${label('Deleted:')} ${value(tp.deletedAt)}`);
console.log(`${label('Text:')}`);
- for (const line of tp.text.trim().split('\n')) {
- console.log(` ${line}`);
+ if (tp.text) {
+ for (const line of tp.text.trim().split('\n')) {
+ console.log(` ${line}`);
+ }
+ } else {
+ console.log(` ${dimTimestamp('(stored in blob — fetch via /api/v1/task-prompts/:id/content)')}`);
}
if (tp.features && tp.features.length > 0) {
@@ -210,7 +216,7 @@ taskPrompt
process.exit(1);
}
- const tp = await response.json() as { _id: string; text: string; type?: PromptType; createdAt: string };
+ const tp = await response.json() as { _id: string; text?: string; type?: PromptType; createdAt: string };
console.log(successText(`Task prompt registered.`));
console.log(`${label('ID:')} ${value(tp._id)}`);
console.log(`${label('Type:')} ${value(tp.type ?? type)}`);
diff --git a/apps/cli/src/utils/gates.ts b/apps/cli/src/utils/gates.ts
index d368aa993..6f914bc26 100644
--- a/apps/cli/src/utils/gates.ts
+++ b/apps/cli/src/utils/gates.ts
@@ -6,7 +6,9 @@ import { resolve } from "path";
import {
GATES,
isGateId,
+ isPromptType,
orderGates,
+ PROMPT_TYPES,
validateGateConfigs,
type GateConfig,
type GateId,
@@ -63,8 +65,8 @@ export function parseGateListOption(raw: string | string[] | undefined): GateId[
export function parsePromptTypeOption(raw: string | undefined): PromptType | undefined {
if (raw === undefined) return undefined;
const trimmed = raw.trim();
- if (!isGateId(trimmed)) {
- throw new Error(`Invalid prompt type '${raw}'. Valid types: ${GATES.join(", ")}.`);
+ if (!isPromptType(trimmed)) {
+ throw new Error(`Invalid prompt type '${raw}'. Valid types: ${PROMPT_TYPES.join(", ")}.`);
}
return trimmed;
}
From 181bc385e39d015188a9930110b35849b21c8d4b Mon Sep 17 00:00:00 2001
From: Cedric Vidal
Date: Thu, 25 Jun 2026 12:05:11 -0700
Subject: [PATCH 05/14] feat(portal): submit AGENTS.md with a run and manage
agents.md prompts
Add portal surfaces for AGENTS.md support (#732): an AGENTS.md field on the Submit Run page, AGENTS.md display on Run Detail, and type-aware task-prompt and prompt-feature views/pickers for 'agents.md' prompts.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../src/components/PromptFeatureWizard.tsx | 24 ++++++++++-
.../src/components/TaskPromptIdPicker.tsx | 4 +-
.../src/components/TaskPromptPicker.tsx | 8 ++--
apps/portal/src/lib/api.ts | 14 +++++--
apps/portal/src/lib/gates.ts | 11 +++++
apps/portal/src/pages/PromptFeatureList.tsx | 41 ++++++++++++++++---
apps/portal/src/pages/RunDetail.tsx | 39 ++++++++++++++++++
apps/portal/src/pages/SubmitRun.tsx | 18 ++++++++
apps/portal/src/pages/TaskPromptDetail.tsx | 4 +-
apps/portal/src/pages/TaskPromptList.tsx | 6 +--
.../src/pages/TaskPromptPreviewPanel.tsx | 6 ++-
apps/portal/src/types.ts | 13 ++++--
12 files changed, 162 insertions(+), 26 deletions(-)
diff --git a/apps/portal/src/components/PromptFeatureWizard.tsx b/apps/portal/src/components/PromptFeatureWizard.tsx
index fde815683..46a90db34 100644
--- a/apps/portal/src/components/PromptFeatureWizard.tsx
+++ b/apps/portal/src/components/PromptFeatureWizard.tsx
@@ -10,8 +10,9 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Stepper } from "@/components/Stepper";
-import type { PromptFeatureDocument } from "@/types";
+import type { PromptFeatureDocument, PromptType } from "@/types";
import {
ArrowLeft,
ArrowRight,
@@ -35,6 +36,8 @@ export interface PromptFeatureWizardProps {
initialId?: string;
/** Pre-fill detection prompt (skips to step 2 when provided) */
initialPrompt?: string;
+ /** Pre-select the prompt type this feature applies to (default "select") */
+ initialType?: PromptType;
/** Called after successful feature creation */
onCreated?: (feature: PromptFeatureDocument) => void;
/** Called when user clicks Cancel (step 1) or Back at step 1 */
@@ -45,6 +48,7 @@ export function PromptFeatureWizard({
initialBehavior = "",
initialId = "",
initialPrompt = "",
+ initialType = "select",
onCreated,
onCancel,
}: PromptFeatureWizardProps) {
@@ -58,6 +62,7 @@ export function PromptFeatureWizard({
const [id, setId] = useState(initialId);
const [idManuallyEdited, setIdManuallyEdited] = useState(!!initialId);
const [idEditMode, setIdEditMode] = useState(false);
+ const [type, setType] = useState(initialType);
// Step 2 fields
const [prompt, setPrompt] = useState(initialPrompt);
@@ -117,6 +122,7 @@ export function PromptFeatureWizard({
createMutation.mutate({
id: id.trim(),
prompt: prompt.trim(),
+ ...(type !== "select" ? { type } : {}),
});
};
@@ -170,6 +176,22 @@ export function PromptFeatureWizard({
+
+
Applies to
+
setType(v as PromptType)}>
+
+
+
+
+ Task prompts
+ AGENTS.md prompts
+
+
+
+ Features are only extracted against prompts of their own type.
+
+
+
Feature ID{" "}
diff --git a/apps/portal/src/components/TaskPromptIdPicker.tsx b/apps/portal/src/components/TaskPromptIdPicker.tsx
index 95c75880a..a30b72037 100644
--- a/apps/portal/src/components/TaskPromptIdPicker.tsx
+++ b/apps/portal/src/components/TaskPromptIdPicker.tsx
@@ -43,7 +43,7 @@ export function TaskPromptIdPicker({ selected, onChange }: TaskPromptIdPickerPro
const promptMap = useMemo(() => {
const map = new Map();
for (const tp of allPrompts?.items ?? []) {
- map.set(tp._id, tp.text);
+ map.set(tp._id, tp.text ?? "");
}
return map;
}, [allPrompts]);
@@ -162,7 +162,7 @@ export function TaskPromptIdPicker({ selected, onChange }: TaskPromptIdPickerPro
{formatId(tp._id)}
- {truncate(tp.text.replace(/\n/g, " "), 80)}
+ {truncate((tp.text ?? "").replace(/\n/g, " "), 80)}
))}
diff --git a/apps/portal/src/components/TaskPromptPicker.tsx b/apps/portal/src/components/TaskPromptPicker.tsx
index 8583b5f51..6d637a295 100644
--- a/apps/portal/src/components/TaskPromptPicker.tsx
+++ b/apps/portal/src/components/TaskPromptPicker.tsx
@@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Search } from "lucide-react";
import { truncate, formatId } from "@/lib/utils";
-import { GATE_METADATA, type PromptType } from "@/lib/gates";
+import { GATE_METADATA, promptTypeLabel, type PromptType } from "@/lib/gates";
import type { TaskPrompt } from "@/types";
interface TaskPromptPickerProps {
@@ -50,7 +50,7 @@ export function TaskPromptPicker({ onSelect, onSelectPrompt, type, placeholder }
const selectItem = useCallback(
(prompt: TaskPrompt) => {
- onSelect(prompt.text);
+ onSelect(prompt.text ?? "");
onSelectPrompt?.(prompt);
setQuery("");
setOpen(false);
@@ -123,10 +123,10 @@ export function TaskPromptPicker({ onSelect, onSelectPrompt, type, placeholder }
{!type && (
- {GATE_METADATA[tp.type ?? "select"].label}
+ {promptTypeLabel(tp.type)}
)}
- {truncate(tp.text.replace(/\n/g, " "), 80)}
+ {truncate((tp.text ?? "").replace(/\n/g, " "), 80)}
))}
diff --git a/apps/portal/src/lib/api.ts b/apps/portal/src/lib/api.ts
index c3e275f09..99401397e 100644
--- a/apps/portal/src/lib/api.ts
+++ b/apps/portal/src/lib/api.ts
@@ -94,6 +94,8 @@ export const api = {
agentVersion?: string;
profileId?: string;
profileVariations?: string[];
+ agentsMd?: string;
+ agentsMdParentIds?: string[];
gates?: GateConfig[];
codebase?: string;
codebaseRevisionId?: string;
@@ -353,9 +355,10 @@ export const api = {
// ─── Prompt Features ───────────────────────────────────────────────────────
/** List all prompt features, optionally filtered by search query */
- listPromptFeatures: (q?: string): Promise => {
+ listPromptFeatures: (q?: string, type?: PromptType): Promise => {
const params = new URLSearchParams();
if (q) params.set("q", q);
+ if (type) params.set("type", type);
const qs = params.toString();
return request(`/prompt-features${qs ? `?${qs}` : ""}`);
},
@@ -366,7 +369,7 @@ export const api = {
},
/** Create a new prompt feature */
- createPromptFeature: (body: { id: string; prompt: string }): Promise => {
+ createPromptFeature: (body: { id: string; prompt: string; type?: PromptType }): Promise => {
return request("/prompt-features", {
method: "POST",
body: JSON.stringify(body),
@@ -396,7 +399,7 @@ export const api = {
// ─── Task Prompts ──────────────────────────────────────────────────────────
- /** List all task prompts (paginated, optional search) */
+ /** List all task prompts (paginated, optional search + type filter) */
listTaskPrompts: (opts?: { limit?: number; offset?: number; search?: string; type?: PromptType }): Promise<{ items: TaskPrompt[]; total: number }> => {
const params = new URLSearchParams();
if (opts?.limit) params.set("limit", String(opts.limit));
@@ -412,6 +415,11 @@ export const api = {
return request(`/task-prompts/${encodeURIComponent(id)}`);
},
+ /** Resolve a task/AGENTS.md prompt's plain text (downloads blob if blob-backed) */
+ getTaskPromptContent: (id: string): Promise<{ id: string; text: string }> => {
+ return request(`/task-prompts/${encodeURIComponent(id)}/content`);
+ },
+
/** Create (or find existing) task prompt — idempotent */
createTaskPrompt: (text: string, type?: PromptType): Promise => {
return request("/task-prompts", {
diff --git a/apps/portal/src/lib/gates.ts b/apps/portal/src/lib/gates.ts
index e44b61fd0..a59b987c9 100644
--- a/apps/portal/src/lib/gates.ts
+++ b/apps/portal/src/lib/gates.ts
@@ -145,3 +145,14 @@ export function formatGateList(gates: readonly GateId[] | undefined): string {
export function orderGateIds(gates: readonly GateId[]): GateId[] {
return [...gates].sort((a, b) => GATE_ORDER.indexOf(a) - GATE_ORDER.indexOf(b));
}
+
+/**
+ * Display label for any prompt type, including the non-gate `agents.md` type.
+ * Falls back to the `select` gate label for missing/unknown values so callers
+ * can safely render legacy or auto-generated prompts.
+ */
+export function promptTypeLabel(type: string | undefined): string {
+ if (type === "agents.md") return "AGENTS.md";
+ if (isGateId(type)) return GATE_METADATA[type].label;
+ return GATE_METADATA.select.label;
+}
diff --git a/apps/portal/src/pages/PromptFeatureList.tsx b/apps/portal/src/pages/PromptFeatureList.tsx
index 2caf7f7e9..a50103aad 100644
--- a/apps/portal/src/pages/PromptFeatureList.tsx
+++ b/apps/portal/src/pages/PromptFeatureList.tsx
@@ -21,8 +21,13 @@ import {
useListUrlState,
type DataTableColumn,
} from "@/components/list-layout";
+import { Badge } from "@/components/ui/badge";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { HelpTooltip } from "@/components/HelpTooltip";
-import type { PromptFeatureDocument } from "@/types";
+import type { PromptFeatureDocument, PromptType } from "@/types";
+
+const FILTER_KEYS = ["type"] as const;
export function PromptFeatureList() {
const queryClient = useQueryClient();
@@ -30,11 +35,12 @@ export function PromptFeatureList() {
const detailOutlet = useOutlet();
const { id: activeId } = useParams<{ id?: string }>();
- const state = useListUrlState({ defaultPageSize: 25, filterKeys: [] });
+ const state = useListUrlState({ defaultPageSize: 25, filterKeys: FILTER_KEYS });
+ const typeFilter = (state.getFilter("type") as PromptType | null) ?? undefined;
const { data: allFeatures = [], isLoading, isRefetching } = useQuery({
- queryKey: ["prompt-features", state.search],
- queryFn: () => api.listPromptFeatures(state.search || undefined),
+ queryKey: ["prompt-features", state.search, typeFilter],
+ queryFn: () => api.listPromptFeatures(state.search || undefined, typeFilter),
});
const deleteMutation = useMutation({
@@ -82,6 +88,16 @@ export function PromptFeatureList() {
{truncate(f.prompt, 120)}
),
},
+ {
+ id: "type",
+ header: "Type",
+ width: "120px",
+ cell: (f) => (
+
+ {f.type ?? "select"}
+
+ ),
+ },
{
id: "actions",
header: "",
@@ -155,8 +171,21 @@ export function PromptFeatureList() {
/>
}
>
-
- Use the search above to filter by ID or prompt text.
+
+ Type
+ state.setFilter("type", v === "all" ? null : v)}
+ >
+
+
+
+
+ All types
+ Task
+ AGENTS.md
+
+
}
diff --git a/apps/portal/src/pages/RunDetail.tsx b/apps/portal/src/pages/RunDetail.tsx
index eb2732011..768d58700 100644
--- a/apps/portal/src/pages/RunDetail.tsx
+++ b/apps/portal/src/pages/RunDetail.tsx
@@ -300,6 +300,15 @@ export function RunDetail() {
enabled: !!taskPromptId,
});
+ // Fetch the AGENTS.md body (if this run supplied one) for Markdown rendering.
+ // Uses the /content endpoint so blob-backed bodies resolve to plain text.
+ const agentsMdPromptId = run?.agentsMdPromptId;
+ const { data: agentsMdContent } = useQuery({
+ queryKey: ["task-prompt-content", agentsMdPromptId],
+ queryFn: () => api.getTaskPromptContent(agentsMdPromptId!),
+ enabled: !!agentsMdPromptId,
+ });
+
// Fetch reports for this run
const { data: reports, refetch: refetchReports } = useQuery({
queryKey: ["run-reports", id],
@@ -1377,6 +1386,36 @@ export function RunDetail() {
)}
+ {/* AGENTS.md card (if this run supplied one) */}
+ {run.agentsMdPromptId && (
+
+
+
+ AGENTS.md
+
+
+
+
+
+ {formatId(run.agentsMdPromptId)}
+
+ {run.agentsMdParentIds && run.agentsMdParentIds.length > 0 && (
+
+ {run.agentsMdParentIds.length === 1 ? "mutation" : "merge"} · {run.agentsMdParentIds.length} parent{run.agentsMdParentIds.length === 1 ? "" : "s"}
+
+ )}
+
+ {agentsMdContent ? (
+
+ {agentsMdContent.text}
+
+ ) : (
+
+ )}
+
+
+ )}
+
{/* Prompt Features card (if task prompt has features) */}
{taskPrompt?.features && taskPrompt.features.length > 0 && (
diff --git a/apps/portal/src/pages/SubmitRun.tsx b/apps/portal/src/pages/SubmitRun.tsx
index bb40f1fca..40e81bd37 100644
--- a/apps/portal/src/pages/SubmitRun.tsx
+++ b/apps/portal/src/pages/SubmitRun.tsx
@@ -209,6 +209,7 @@ export function SubmitRun() {
}));
const [occurrences, setOccurrences] = useState(5);
const [priority, setPriority] = useState(0);
+ const [agentsMd, setAgentsMd] = useState("");
// Optional add-ons
const [selectedMcpServers, setSelectedMcpServers] = useState([]);
@@ -622,6 +623,7 @@ export function SubmitRun() {
...(gatesEnabled ? { gates: gateConfigs } : {}),
...(priority !== 0 ? { priority } : {}),
...(occurrences > 1 ? { count: occurrences } : {}),
+ ...(agentsMd.trim() ? { agentsMd: agentsMd } : {}),
...(inVariationMode ? {} : { ...(selectedMcpServers.length > 0 ? { mcpServers: selectedMcpServers } : {}) }),
...(inVariationMode ? {} : { ...(selectedSkills.length > 0 ? { skills: selectedSkills } : {}) }),
...(inVariationMode ? {} : { ...(selectedExtensions.length > 0 ? { extensions: selectedExtensions } : {}) }),
@@ -1001,6 +1003,22 @@ export function SubmitRun() {
+ {/* ─── Advanced: AGENTS.md ──────────────── */}
+
+
diff --git a/apps/portal/src/pages/TaskPromptDetail.tsx b/apps/portal/src/pages/TaskPromptDetail.tsx
index 39c0a2317..a2724d1b0 100644
--- a/apps/portal/src/pages/TaskPromptDetail.tsx
+++ b/apps/portal/src/pages/TaskPromptDetail.tsx
@@ -15,7 +15,7 @@ import { ArrowLeft, Trash2, List } from "lucide-react";
import { formatDate } from "@/lib/utils";
import { TaskPromptFeatures } from "@/components/TaskPromptFeatures";
import { Badge } from "@/components/ui/badge";
-import { GATE_METADATA } from "@/lib/gates";
+import { promptTypeLabel } from "@/lib/gates";
export function TaskPromptDetail() {
const { id } = useParams<{ id: string }>();
@@ -111,7 +111,7 @@ export function TaskPromptDetail() {
Type
- {GATE_METADATA[taskPrompt.type ?? "select"].label}
+ {promptTypeLabel(taskPrompt.type)}
diff --git a/apps/portal/src/pages/TaskPromptList.tsx b/apps/portal/src/pages/TaskPromptList.tsx
index 146e12b44..01c41db69 100644
--- a/apps/portal/src/pages/TaskPromptList.tsx
+++ b/apps/portal/src/pages/TaskPromptList.tsx
@@ -24,7 +24,7 @@ import { formatDate, formatId, truncate } from "@/lib/utils";
import { TaskPromptFeatures } from "@/components/TaskPromptFeatures";
import { TaskPromptPicker } from "@/components/TaskPromptPicker";
import { Stepper } from "@/components/Stepper";
-import { GATE_METADATA, type PromptType } from "@/lib/gates";
+import { GATE_METADATA, promptTypeLabel, type PromptType } from "@/lib/gates";
import { useVisibleGates } from "@/hooks/useVisibleGates";
import { KbdBadge } from "@/components/KbdBadge";
import {
@@ -177,7 +177,7 @@ export function TaskPromptList() {
header: "Text",
hidden: visibility.isHidden("text"),
cell: (tp) => (
-
{truncate(tp.text, 80)}
+
{truncate(tp.text ?? "", 80)}
),
},
{
@@ -187,7 +187,7 @@ export function TaskPromptList() {
hidden: visibility.isHidden("type"),
cell: (tp) => (
- {GATE_METADATA[tp.type ?? "select"].label}
+ {promptTypeLabel(tp.type)}
),
},
diff --git a/apps/portal/src/pages/TaskPromptPreviewPanel.tsx b/apps/portal/src/pages/TaskPromptPreviewPanel.tsx
index 15f31acde..0ed5a0ff6 100644
--- a/apps/portal/src/pages/TaskPromptPreviewPanel.tsx
+++ b/apps/portal/src/pages/TaskPromptPreviewPanel.tsx
@@ -50,7 +50,7 @@ export function TaskPromptPreviewPanel() {
return (
{formatId(taskPrompt._id)}}
- subtitle={truncate(taskPrompt.text, 80)}
+ subtitle={truncate(taskPrompt.text ?? "", 80)}
onClose={closePanel}
headerActions={
@@ -74,7 +74,9 @@ export function TaskPromptPreviewPanel() {
- {truncate(taskPrompt.text, 1200)}
+ {taskPrompt.text !== undefined
+ ? truncate(taskPrompt.text, 1200)
+ : "(Stored in blob storage — open the full view to load the body.)"}
diff --git a/apps/portal/src/types.ts b/apps/portal/src/types.ts
index 74918b4bf..5a5d12142 100644
--- a/apps/portal/src/types.ts
+++ b/apps/portal/src/types.ts
@@ -143,6 +143,10 @@ export interface Run {
submissionId?: string;
profileId?: string;
profileVersionId?: string;
+ /** Resolved AGENTS.md prompt id when an AGENTS.md was supplied. */
+ agentsMdPromptId?: string;
+ /** Parent AGENTS.md prompt ids forming the AGENTS.md lineage (mutation/merge edges). */
+ agentsMdParentIds?: string[];
gates?: GateConfig[];
gateSummaries?: GateRunSummary[];
}
@@ -158,7 +162,7 @@ export interface CursorPaginatedResponse
{
}
export type GateId = "select" | "build" | "test" | "run" | "deploy";
-export type PromptType = GateId;
+export type PromptType = GateId | "agents.md";
export interface GateConfig {
gate: GateId;
@@ -230,6 +234,8 @@ export interface GeneratePromptResponse {
export interface PromptFeatureConfig {
id: string;
prompt: string;
+ /** Prompt type this feature applies to (absent ⇒ "select"). */
+ type?: PromptType;
}
export interface PromptFeatureDocument extends PromptFeatureConfig {
@@ -264,8 +270,9 @@ export interface PromptFeatureExtraction {
// Task Prompt types (first-class entity for benchmark task texts)
export interface TaskPrompt {
_id: string; // UUIDv5 content-addressed ID
- text: string;
- type?: PromptType; // Full task prompt text
+ text?: string; // Full task prompt text (absent when blob-backed)
+ type?: PromptType; // Gate id or "agents.md"; absent ⇒ legacy "select"
+ contentBlobUrl?: string; // Blob reference when body exceeds the inline threshold
features?: PromptFeatureResult[]; // Detected prompt features
featuresExtractedAt?: string; // When features were last extracted
createdAt: string;
From e3203d3a552099972eb1656476a10d152a7e1af5 Mon Sep 17 00:00:00 2001
From: Cedric Vidal
Date: Thu, 25 Jun 2026 12:05:20 -0700
Subject: [PATCH 06/14] docs(agents-md): document AGENTS.md delivery and blob
storage; refresh API/CLI snapshots
Document the AGENTS.md delivery flow and size-based prompt storage (PROMPT_INLINE_MAX_BYTES) in app-design.md and ENV_VARIABLES.md, and regenerate the OpenAPI and CLI command snapshots to reflect the agentsMd fields and --agents-md/--type options (#732).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
ENV_VARIABLES.md | 14 ++
.../openapi-snapshot.test.ts.snap | 134 ++++++++++++++----
.../__snapshots__/cli-commands.test.ts.snap | 3 +
docs/architecture/app-design.md | 37 +++++
4 files changed, 160 insertions(+), 28 deletions(-)
diff --git a/ENV_VARIABLES.md b/ENV_VARIABLES.md
index a4159a740..f50c09ab6 100644
--- a/ENV_VARIABLES.md
+++ b/ENV_VARIABLES.md
@@ -136,6 +136,20 @@ Model name / deployment name used by both backends. For Foundry, this must
match the deployment name on the Foundry resource. Examples: `gpt-4.1`,
`gpt-4o`, `gpt-4.1-mini`. Put in `.env.local` (see note above).
+## Prompt Storage Configuration
+
+### PROMPT_INLINE_MAX_BYTES
+**Default:** `16384` (16 KB)
+**Type:** integer (UTF-8 byte length)
+**Used by:** API (`apps/api`)
+
+Threshold deciding where a task/AGENTS.md prompt body is stored. A body whose
+UTF-8 byte length is at/under this value is stored **inline** in Mongo (`text`);
+a larger body is uploaded to blob storage (`prompts/{promptId}.txt`) and the doc
+references it via `contentBlobUrl` with no inline `text`. The decision is purely
+size-based — independent of the prompt's `type`. Small task prompts stay inline
+(today's behavior); large AGENTS.md bodies go to blob automatically.
+
## Judge Strategy Configuration
### JUDGE_MODEL
diff --git a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap
index d99d079de..945862986 100644
--- a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap
+++ b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap
@@ -786,6 +786,9 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"prompt": {
"type": "string",
},
+ "type": {
+ "$ref": "#/components/schemas/PromptType",
+ },
},
"required": [
"id",
@@ -844,6 +847,15 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
},
"CreateRequestInput": {
"properties": {
+ "agentsMd": {
+ "type": "string",
+ },
+ "agentsMdParentIds": {
+ "items": {
+ "type": "string",
+ },
+ "type": "array",
+ },
"codebaseRevisionId": {
"type": "string",
},
@@ -945,14 +957,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"type": "string",
},
"type": {
- "enum": [
- "select",
- "build",
- "test",
- "run",
- "deploy",
- ],
- "type": "string",
+ "$ref": "#/components/schemas/PromptType",
},
},
"required": [
@@ -2012,6 +2017,9 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"prompt": {
"type": "string",
},
+ "type": {
+ "$ref": "#/components/schemas/PromptType",
+ },
"updatedAt": {
"format": "date-time",
"type": [
@@ -2046,6 +2054,17 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
],
"type": "object",
},
+ "PromptType": {
+ "enum": [
+ "select",
+ "build",
+ "test",
+ "run",
+ "deploy",
+ "agents.md",
+ ],
+ "type": "string",
+ },
"RegisterAgentVersionInput": {
"properties": {
"agentVersion": {
@@ -2383,6 +2402,15 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"agentVersion": {
"type": "string",
},
+ "agentsMdParentIds": {
+ "items": {
+ "type": "string",
+ },
+ "type": "array",
+ },
+ "agentsMdPromptId": {
+ "type": "string",
+ },
"codebaseRevisionId": {
"type": "string",
},
@@ -2944,6 +2972,9 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"_id": {
"type": "string",
},
+ "contentBlobUrl": {
+ "type": "string",
+ },
"createdAt": {
"format": "date-time",
"type": [
@@ -2959,9 +2990,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
],
},
"features": {
- "items": {
- "$ref": "#/components/schemas/PromptFeatureResult",
- },
+ "items": {},
"type": "array",
},
"featuresExtractedAt": {
@@ -2975,19 +3004,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"type": "string",
},
"type": {
- "enum": [
- "select",
- "build",
- "test",
- "run",
- "deploy",
- ],
- "type": "string",
+ "$ref": "#/components/schemas/PromptType",
},
},
"required": [
"_id",
- "text",
"createdAt",
],
"type": "object",
@@ -6158,6 +6179,18 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"type": "string",
},
},
+ {
+ "in": "query",
+ "name": "type",
+ "required": false,
+ "schema": {
+ "enum": [
+ "select",
+ "agents.md",
+ ],
+ "type": "string",
+ },
+ },
],
"responses": {
"200": {
@@ -6226,6 +6259,13 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"text": {
"type": "string",
},
+ "type": {
+ "enum": [
+ "select",
+ "agents.md",
+ ],
+ "type": "string",
+ },
},
"required": [
"text",
@@ -9191,14 +9231,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"name": "type",
"required": false,
"schema": {
- "enum": [
- "select",
- "build",
- "test",
- "run",
- "deploy",
- ],
- "type": "string",
+ "$ref": "#/components/schemas/PromptType",
},
},
],
@@ -9396,6 +9429,51 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
],
},
},
+ "/api/v1/task-prompts/{id}/content": {
+ "get": {
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string",
+ },
+ },
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "id": {
+ "type": "string",
+ },
+ "text": {
+ "type": "string",
+ },
+ },
+ "required": [
+ "id",
+ "text",
+ ],
+ "type": "object",
+ },
+ },
+ },
+ "description": "Success",
+ },
+ "404": {
+ "description": "Task prompt not found",
+ },
+ },
+ "summary": "Get resolved task prompt content",
+ "tags": [
+ "Task Prompts",
+ ],
+ },
+ },
"/api/v1/task-prompts/{id}/extract-features": {
"post": {
"parameters": [
diff --git a/apps/cli/src/__snapshots__/cli-commands.test.ts.snap b/apps/cli/src/__snapshots__/cli-commands.test.ts.snap
index 065ced3aa..a047a3693 100644
--- a/apps/cli/src/__snapshots__/cli-commands.test.ts.snap
+++ b/apps/cli/src/__snapshots__/cli-commands.test.ts.snap
@@ -422,6 +422,7 @@ exports[`CLI command registration > full command tree matches snapshot 1`] = `
"options": [
"--id",
"--prompt",
+ "--type",
"--url",
],
},
@@ -458,6 +459,7 @@ exports[`CLI command registration > full command tree matches snapshot 1`] = `
"options": [
"--output",
"--query",
+ "--type",
"--url",
],
},
@@ -651,6 +653,7 @@ exports[`CLI command registration > full command tree matches snapshot 1`] = `
"submit": {
"options": [
"--agent-version",
+ "--agents-md",
"--base-profile",
"--codebase",
"--criteria",
diff --git a/docs/architecture/app-design.md b/docs/architecture/app-design.md
index a5babb604..f2e92e8fc 100644
--- a/docs/architecture/app-design.md
+++ b/docs/architecture/app-design.md
@@ -53,6 +53,43 @@ erDiagram
- **Codebase** — Mutable first-class project entity in `codebases`, with a unique slug, source type (`git` or `archive`), optional GitHub source/default branch, revision counter, latest revision pointer, and soft-delete metadata.
- **CodebaseRevision** — Immutable snapshot in `codebase-revisions`. Every Git resolution or archive upload creates a fresh UUID revision with the next per-codebase `revisionNumber` and canonical `{slug}@r{N}` ref.
+### Typed prompts, AGENTS.md, and size-based storage
+
+Task prompts live in a single `task-prompts` collection that is now **typed** and
+shared by two prompt kinds:
+
+- `TaskPromptDocument.type?: 'task' | 'agents.md'` — absent ⇒ `'task'` (backward
+ compatible; existing docs are untouched).
+- **`_id` is the content hash.** `computeTaskPromptId(text, type?)` hashes the
+ trimmed text for `task` (or absent) — identical to the legacy hash, so every
+ existing task prompt keeps its `_id` — and namespaces non-task types as
+ `hash(type + '\n' + text)` so an AGENTS.md prompt never collides with a task
+ prompt of the same text. `findOrCreate` deduplicates on this hash.
+- **Body storage is decided by size, not type.** A prompt body at/under
+ `PROMPT_INLINE_MAX_BYTES` (default 16 KB, UTF-8) is stored inline as `text`;
+ larger bodies are uploaded to blob (`prompts/{promptId}.txt`) and the doc carries
+ `contentBlobUrl` with no inline `text`. Exactly one of `text` / `contentBlobUrl`
+ is set. `resolvePromptText(doc)` returns the inline body or downloads the blob, so
+ callers (feature extraction, the worker text endpoint) get plain text regardless
+ of location.
+- **Prompt features are typed the same way.** `PromptFeatureDocument.type?:
+ 'task' | 'agents.md'` (absent ⇒ `'task'`); feature extraction selects only
+ features of the prompt's type.
+
+### AGENTS.md delivery
+
+To support submitting an AGENTS.md prompt with a run, the request carries:
+
+- `RequestDocument.agentsMdPromptId?: string` — set when the create-request body
+ includes `agentsMd` (raw text); the API `findOrCreate`s an `agents.md`-typed
+ prompt and stores its id. Before the run starts, the shared queue-processor
+ resolves the text (downloading from blob if needed) and writes
+ `/AGENTS.md` once (constant for the whole run). It **fails the run**
+ if the prompt id is set but cannot be resolved — never silently runs the baseline.
+- `RequestDocument.agentsMdParentIds?: string[]` — best-effort lineage edges
+ (`[]`/absent = root, `[p]` = mutation, `[i, j]` = merge) for callers that know
+ parentage at submit time.
+
## Judge Pipeline
The judge evaluates coding agent output against criteria. Two strategies are supported:
From e36b5f2f7fce168898a6426da1db810d295a1b85 Mon Sep 17 00:00:00 2001
From: Cedric Vidal
Date: Thu, 25 Jun 2026 13:11:51 -0700
Subject: [PATCH 07/14] fix(shared): break task-prompt/prompt-feature schema
import cycle
task-prompt.ts and prompt-feature.ts imported each other (task-prompt
needs PromptFeatureResultSchema; prompt-feature needs PromptTypeSchema),
forming a cycle. At ESM runtime the schemas barrel evaluates
prompt-feature first, so task-prompt's `z.array(PromptFeatureResultSchema)`
ran while the binding was still in the temporal dead zone, crashing the
API on boot with a ReferenceError. tsc and vitest tolerated the cycle, so
it only surfaced under the API's tsx runtime; under vitest the unresolved
binding also silently emitted an empty OpenAPI `features.items: {}`.
Extract PromptTypeSchema into its own neutral prompt-type.ts that both
modules depend on, removing the back-edge from prompt-feature to
task-prompt. task-prompt re-exports PromptTypeSchema so the barrel surface
and existing consumers are unchanged. Regenerate the OpenAPI snapshot,
which now correctly refs PromptFeatureResult.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../__snapshots__/openapi-snapshot.test.ts.snap | 4 +++-
packages/shared/src/schemas/prompt-feature.ts | 2 +-
packages/shared/src/schemas/prompt-type.ts | 15 +++++++++++++++
packages/shared/src/schemas/task-prompt.ts | 4 ++--
4 files changed, 21 insertions(+), 4 deletions(-)
create mode 100644 packages/shared/src/schemas/prompt-type.ts
diff --git a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap
index 945862986..26db63e9a 100644
--- a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap
+++ b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap
@@ -2990,7 +2990,9 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
],
},
"features": {
- "items": {},
+ "items": {
+ "$ref": "#/components/schemas/PromptFeatureResult",
+ },
"type": "array",
},
"featuresExtractedAt": {
diff --git a/packages/shared/src/schemas/prompt-feature.ts b/packages/shared/src/schemas/prompt-feature.ts
index b42215007..2e7cdf25a 100644
--- a/packages/shared/src/schemas/prompt-feature.ts
+++ b/packages/shared/src/schemas/prompt-feature.ts
@@ -3,7 +3,7 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
-import { PromptTypeSchema } from "./task-prompt.js";
+import { PromptTypeSchema } from "./prompt-type.js";
extendZodWithOpenApi(z);
diff --git a/packages/shared/src/schemas/prompt-type.ts b/packages/shared/src/schemas/prompt-type.ts
new file mode 100644
index 000000000..cabc7430c
--- /dev/null
+++ b/packages/shared/src/schemas/prompt-type.ts
@@ -0,0 +1,15 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { z } from "zod";
+import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
+import { PROMPT_TYPES } from "../types/types.js";
+
+extendZodWithOpenApi(z);
+
+// Defined in its own module so that `task-prompt.ts` and `prompt-feature.ts`
+// can both depend on it without importing each other. Those two schema modules
+// otherwise form an import cycle (task-prompt needs PromptFeatureResultSchema,
+// prompt-feature needs PromptTypeSchema) that throws a temporal-dead-zone
+// ReferenceError at ESM runtime depending on which side is evaluated first.
+export const PromptTypeSchema = z.enum(PROMPT_TYPES).openapi("PromptType");
diff --git a/packages/shared/src/schemas/task-prompt.ts b/packages/shared/src/schemas/task-prompt.ts
index bfbc8c05f..8ad36de05 100644
--- a/packages/shared/src/schemas/task-prompt.ts
+++ b/packages/shared/src/schemas/task-prompt.ts
@@ -4,11 +4,11 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
import { PromptFeatureResultSchema } from "./prompt-feature.js";
-import { PROMPT_TYPES } from "../types/types.js";
+import { PromptTypeSchema } from "./prompt-type.js";
extendZodWithOpenApi(z);
-export const PromptTypeSchema = z.enum(PROMPT_TYPES).openapi("PromptType");
+export { PromptTypeSchema };
export const CreateTaskPromptInputSchema = z
.object({
From f7da956e58e0cd0f7209e8a1501fa270d266fe00 Mon Sep 17 00:00:00 2001
From: Cedric Vidal
Date: Thu, 25 Jun 2026 18:27:06 -0700
Subject: [PATCH 08/14] feat(portal): discreet AGENTS.md field with
prompt-library lookup in New Run
The AGENTS.md input was always rendered as a prominent 6-row textarea even
when unused. Make it discreet behind an "Add AGENTS.md" trigger that unveils
a bordered card, mirroring the existing Codebase disclosure. Inside the card,
add a TaskPromptPicker(type="agents.md") so an existing agents.md prompt can
be looked up from the prompt library, just like the Task and gate prompts;
selecting one fills the editor and the API dedups it by content on submit.
Also restore AGENTS.md when prefilling from a recent run (resolve
agentsMdPromptId to text) and surface an "AGENTS.md" chip in the settings
summary.
TaskPromptPicker previously imported the gates-only PromptType and indexed
GATE_METADATA[type] for its placeholder, which has no agents.md entry. Switch
to the broad PromptType from @/types and use promptTypeLabel(type) so the
non-gate type renders safely; identical output for gate types.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../src/components/TaskPromptPicker.tsx | 6 +-
apps/portal/src/pages/SubmitRun.tsx | 79 +++++++++++++++----
2 files changed, 66 insertions(+), 19 deletions(-)
diff --git a/apps/portal/src/components/TaskPromptPicker.tsx b/apps/portal/src/components/TaskPromptPicker.tsx
index 6d637a295..197ac9f4f 100644
--- a/apps/portal/src/components/TaskPromptPicker.tsx
+++ b/apps/portal/src/components/TaskPromptPicker.tsx
@@ -8,8 +8,8 @@ import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Search } from "lucide-react";
import { truncate, formatId } from "@/lib/utils";
-import { GATE_METADATA, promptTypeLabel, type PromptType } from "@/lib/gates";
-import type { TaskPrompt } from "@/types";
+import { promptTypeLabel } from "@/lib/gates";
+import type { TaskPrompt, PromptType } from "@/types";
interface TaskPromptPickerProps {
onSelect: (text: string) => void;
@@ -81,7 +81,7 @@ export function TaskPromptPicker({ onSelect, onSelectPrompt, type, placeholder }
{
setQuery(e.target.value);
diff --git a/apps/portal/src/pages/SubmitRun.tsx b/apps/portal/src/pages/SubmitRun.tsx
index 40e81bd37..90c0335f4 100644
--- a/apps/portal/src/pages/SubmitRun.tsx
+++ b/apps/portal/src/pages/SubmitRun.tsx
@@ -14,7 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Badge } from "@/components/ui/badge";
import {
Send, Loader2, Server, Info, BookOpen, Sparkles, Puzzle, SlidersHorizontal,
- X, Save, Plus, ChevronDown, FilePlus2, History, ArrowLeft, Check, FolderGit2,
+ X, Save, Plus, ChevronDown, FilePlus2, History, ArrowLeft, Check, FolderGit2, FileText,
} from "lucide-react";
import {
WORKER_TYPES, type CodingAgent, type McpServerDocument,
@@ -242,6 +242,7 @@ export function SubmitRun() {
const [mcpOpen, setMcpOpen] = useState(false);
const [skillsOpen, setSkillsOpen] = useState(false);
const [codebaseOpen, setCodebaseOpen] = useState(false);
+ const [agentsMdOpen, setAgentsMdOpen] = useState(false);
const [extensionsOpen, setExtensionsOpen] = useState(false);
const [graphSelection, setGraphSelection] = useState({ kind: "base" });
@@ -505,6 +506,12 @@ export function SubmitRun() {
setSelectedCodebaseSpec(run.codebaseRevisionId);
setCodebaseOpen(true);
}
+ if (run.agentsMdPromptId) {
+ setAgentsMdOpen(true);
+ api.getTaskPromptContent(run.agentsMdPromptId)
+ .then((content) => setAgentsMd(content.text))
+ .catch(() => undefined);
+ }
if (run.extensions && run.extensions.length > 0) {
setSelectedExtensions(run.extensions);
setExtensionsOpen(true);
@@ -726,6 +733,7 @@ export function SubmitRun() {
selectedMcpServers.length > 0 ? `${selectedMcpServers.length} MCP` : "",
selectedSkills.length > 0 ? `${selectedSkills.length} skill${selectedSkills.length === 1 ? "" : "s"}` : "",
selectedCodebaseSpec ? `codebase ${selectedCodebaseSpec}` : "",
+ agentsMd.trim() ? "AGENTS.md" : "",
selectedExtensions.length > 0 ? `${selectedExtensions.length} ext` : "",
].filter(Boolean);
@@ -1003,21 +1011,60 @@ export function SubmitRun() {
- {/* ─── Advanced: AGENTS.md ──────────────── */}
-
+ {/* ─── AGENTS.md (optional, discreet) ──────────────────────────── */}
+ {!(agentsMdOpen || agentsMd.trim()) ? (
+
setAgentsMdOpen(true)}
+ >
+
+ Add AGENTS.md
+
+ ) : (
+
+
+
+
+ AGENTS.md{" "}
+ (optional)
+
+
+
+
{
+ setAgentsMd("");
+ setAgentsMdOpen(false);
+ }}
+ >
+
+ Remove
+
+
+
setAgentsMd(text)}
+ />
+
+ )}
From e3bad364acb70f88f7516177c5c9df863532af5e Mon Sep 17 00:00:00 2001
From: Cedric Vidal
Date: Fri, 26 Jun 2026 12:22:46 -0700
Subject: [PATCH 09/14] feat(portal): surface AGENTS.md prompts in the Prompt
Library
Runs submitted with an AGENTS.md create an 'agents.md' task prompt, but the
Prompt Library hid it: TaskPromptStore.getAll excludes NON_GATE_PROMPT_TYPES
from the default (unfiltered) list. That guard is right for the report-template
trigger picker, so instead of flipping the default this adds an opt-in
'includeNonGate' flag that the Prompt Library uses to list agents.md prompts
alongside task/gate prompts. The library also gains an AGENTS.md type filter and
create option; the CLI 'task-prompt list' gets a matching --include-non-gate flag.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../__snapshots__/openapi-snapshot.test.ts.snap | 11 +++++++++++
apps/api/src/routes/task-prompts.ts | 4 +++-
.../src/__snapshots__/cli-commands.test.ts.snap | 1 +
apps/cli/src/commands/task-prompt.ts | 2 ++
apps/portal/src/lib/api.ts | 3 ++-
apps/portal/src/pages/TaskPromptList.tsx | 12 ++++++++----
.../src/task-prompts/task-prompt-store.test.ts | 16 ++++++++++++++++
.../shared/src/task-prompts/task-prompt-store.ts | 13 ++++++++++---
8 files changed, 53 insertions(+), 9 deletions(-)
diff --git a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap
index 26db63e9a..5f8da17e8 100644
--- a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap
+++ b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap
@@ -9236,6 +9236,17 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"$ref": "#/components/schemas/PromptType",
},
},
+ {
+ "in": "query",
+ "name": "includeNonGate",
+ "required": false,
+ "schema": {
+ "type": [
+ "boolean",
+ "null",
+ ],
+ },
+ },
],
"responses": {
"200": {
diff --git a/apps/api/src/routes/task-prompts.ts b/apps/api/src/routes/task-prompts.ts
index 45280e437..6af56bbd2 100644
--- a/apps/api/src/routes/task-prompts.ts
+++ b/apps/api/src/routes/task-prompts.ts
@@ -80,6 +80,7 @@ apiRoute(ctx.app, ctx.registry, {
offset: z.coerce.number().optional(),
search: z.string().optional(),
type: PromptTypeSchema.optional(),
+ includeNonGate: z.coerce.boolean().optional(),
}),
response: z.object({
items: z.array(TaskPromptResponseSchema),
@@ -92,8 +93,9 @@ apiRoute(ctx.app, ctx.registry, {
const offset = req.query.offset ?? 0;
const search = req.query.search;
const type = req.query.type;
+ const includeNonGate = req.query.includeNonGate;
- const { items, total } = await ctx.taskPromptStore.getAll({ limit, offset, search, type });
+ const { items, total } = await ctx.taskPromptStore.getAll({ limit, offset, search, type, includeNonGate });
res.json({ items, total, limit, offset });
},
});
diff --git a/apps/cli/src/__snapshots__/cli-commands.test.ts.snap b/apps/cli/src/__snapshots__/cli-commands.test.ts.snap
index a047a3693..c54aecb78 100644
--- a/apps/cli/src/__snapshots__/cli-commands.test.ts.snap
+++ b/apps/cli/src/__snapshots__/cli-commands.test.ts.snap
@@ -779,6 +779,7 @@ exports[`CLI command registration > full command tree matches snapshot 1`] = `
},
"list": {
"options": [
+ "--include-non-gate",
"--limit",
"--offset",
"--output",
diff --git a/apps/cli/src/commands/task-prompt.ts b/apps/cli/src/commands/task-prompt.ts
index 74e77909a..963600414 100644
--- a/apps/cli/src/commands/task-prompt.ts
+++ b/apps/cli/src/commands/task-prompt.ts
@@ -30,6 +30,7 @@ taskPrompt
.description("List all task prompts")
.option("-s, --search ", "Filter by text content")
.option("--type ", "Filter by prompt type/gate (select, build, test, run, deploy, agents.md)")
+ .option("--include-non-gate", "Include non-gate prompt types (e.g. agents.md) in the default list")
.option("-l, --limit ", "Maximum number of results", "50")
.option("--offset ", "Number of results to skip", "0")
.option("-u, --url ", "API base URL", getDefaultApiUrl())
@@ -41,6 +42,7 @@ taskPrompt
if (options.search) params.set("search", options.search);
const type = parsePromptTypeOption(options.type);
if (type) params.set("type", type);
+ if (options.includeNonGate) params.set("includeNonGate", "true");
if (options.limit) params.set("limit", options.limit);
if (options.offset) params.set("offset", options.offset);
const qs = params.toString();
diff --git a/apps/portal/src/lib/api.ts b/apps/portal/src/lib/api.ts
index 99401397e..ae7035090 100644
--- a/apps/portal/src/lib/api.ts
+++ b/apps/portal/src/lib/api.ts
@@ -400,12 +400,13 @@ export const api = {
// ─── Task Prompts ──────────────────────────────────────────────────────────
/** List all task prompts (paginated, optional search + type filter) */
- listTaskPrompts: (opts?: { limit?: number; offset?: number; search?: string; type?: PromptType }): Promise<{ items: TaskPrompt[]; total: number }> => {
+ listTaskPrompts: (opts?: { limit?: number; offset?: number; search?: string; type?: PromptType; includeNonGate?: boolean }): Promise<{ items: TaskPrompt[]; total: number }> => {
const params = new URLSearchParams();
if (opts?.limit) params.set("limit", String(opts.limit));
if (opts?.offset) params.set("offset", String(opts.offset));
if (opts?.search) params.set("search", opts.search);
if (opts?.type) params.set("type", opts.type);
+ if (opts?.includeNonGate) params.set("includeNonGate", "true");
const qs = params.toString();
return request(`/task-prompts${qs ? `?${qs}` : ""}`);
},
diff --git a/apps/portal/src/pages/TaskPromptList.tsx b/apps/portal/src/pages/TaskPromptList.tsx
index 01c41db69..d7dfbd2da 100644
--- a/apps/portal/src/pages/TaskPromptList.tsx
+++ b/apps/portal/src/pages/TaskPromptList.tsx
@@ -6,7 +6,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useOutlet, useParams } from "react-router-dom";
import { toast } from "sonner";
import { api } from "@/lib/api";
-import type { TaskPrompt } from "@/types";
+import type { TaskPrompt, PromptType } from "@/types";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -24,7 +24,7 @@ import { formatDate, formatId, truncate } from "@/lib/utils";
import { TaskPromptFeatures } from "@/components/TaskPromptFeatures";
import { TaskPromptPicker } from "@/components/TaskPromptPicker";
import { Stepper } from "@/components/Stepper";
-import { GATE_METADATA, promptTypeLabel, type PromptType } from "@/lib/gates";
+import { GATE_METADATA, promptTypeLabel } from "@/lib/gates";
import { useVisibleGates } from "@/hooks/useVisibleGates";
import { KbdBadge } from "@/components/KbdBadge";
import {
@@ -79,7 +79,7 @@ export function TaskPromptList() {
const { data, isLoading } = useQuery({
queryKey: ["task-prompts", state.search, typeFilter],
- queryFn: () => api.listTaskPrompts({ search: state.search || undefined, type: typeFilter }),
+ queryFn: () => api.listTaskPrompts({ search: state.search || undefined, type: typeFilter, includeNonGate: true }),
});
const items = data?.items ?? [];
@@ -314,6 +314,7 @@ export function TaskPromptList() {
{GATE_METADATA[gate].label}
))}
+ {promptTypeLabel("agents.md")}
@@ -399,7 +400,10 @@ export function TaskPromptList() {
>
({ value: gate, label: GATE_METADATA[gate].label }))}
+ options={[
+ ...visibleGates.map((gate) => ({ value: gate, label: GATE_METADATA[gate].label })),
+ { value: "agents.md", label: promptTypeLabel("agents.md") },
+ ]}
selected={selectedTypes}
onToggle={(value) => state.setFilter("type", selectedTypes.includes(value as PromptType) ? [] : [value])}
/>
diff --git a/packages/shared/src/task-prompts/task-prompt-store.test.ts b/packages/shared/src/task-prompts/task-prompt-store.test.ts
index a0521e807..9e32840b2 100644
--- a/packages/shared/src/task-prompts/task-prompt-store.test.ts
+++ b/packages/shared/src/task-prompts/task-prompt-store.test.ts
@@ -346,6 +346,22 @@ describe("TaskPromptStore", () => {
expect(items[0].text).toBe("a task prompt");
});
+ it("includes non-gate types when includeNonGate is set", async () => {
+ await store.findOrCreate("a task prompt");
+ await store.findOrCreate("an agents file", "agents.md");
+ const { items, total } = await store.getAll({ includeNonGate: true });
+ expect(total).toBe(2);
+ expect(items.map((i) => i.text).sort()).toEqual(["a task prompt", "an agents file"]);
+ });
+
+ it("ignores includeNonGate when an explicit type is given", async () => {
+ await store.findOrCreate("a task prompt");
+ await store.findOrCreate("an agents file", "agents.md");
+ const { items, total } = await store.getAll({ type: "agents.md", includeNonGate: true });
+ expect(total).toBe(1);
+ expect(items[0].text).toBe("an agents file");
+ });
+
it("filters by agents.md type", async () => {
await store.findOrCreate("a task prompt");
await store.findOrCreate("an agents file", "agents.md");
diff --git a/packages/shared/src/task-prompts/task-prompt-store.ts b/packages/shared/src/task-prompts/task-prompt-store.ts
index 93e41a61f..9c6273a2a 100644
--- a/packages/shared/src/task-prompts/task-prompt-store.ts
+++ b/packages/shared/src/task-prompts/task-prompt-store.ts
@@ -150,14 +150,21 @@ export class TaskPromptStore {
/**
* List active (non-deleted) task prompts.
* Supports pagination, optional substring search on text, and an optional
- * `type` filter. With no `type`, prompts of every type are returned; pass a
- * `type` (e.g. `'select'` or `'agents.md'`) to scope the list.
+ * `type` filter. Pass a `type` (e.g. `'select'` or `'agents.md'`) to scope the
+ * list to a single type.
+ *
+ * With no `type`, the default list shows all gate prompt types and legacy
+ * untyped docs, but hides non-gate types such as `agents.md` (which can be
+ * high-volume when auto-generated). Set `includeNonGate` to also surface those
+ * non-gate types in the unfiltered list — used by the Prompt Library, which
+ * manages AGENTS.md prompts alongside task/gate prompts.
*/
async getAll(opts?: {
limit?: number;
offset?: number;
search?: string;
type?: PromptType;
+ includeNonGate?: boolean;
}): Promise<{ items: TaskPromptDocument[]; total: number }> {
const filter: Record = { deletedAt: { $exists: false } };
@@ -166,7 +173,7 @@ export class TaskPromptStore {
}
if (opts?.type) {
filter.type = opts.type;
- } else {
+ } else if (!opts?.includeNonGate) {
// The default (unfiltered) list shows all gate prompt types and legacy
// untyped docs, but hides non-gate types such as auto-generated
// 'agents.md' candidates, which can be high-volume noise. `$nin`
From ee3dc44e004e84ca81eba7018a8320ffd1771dd9 Mon Sep 17 00:00:00 2001
From: Cedric Vidal
Date: Fri, 26 Jun 2026 12:22:53 -0700
Subject: [PATCH 10/14] feat(portal): rename 'Tasks' nav item to 'Prompts'
The left-nav Library entry for /task-prompts pointed at the Prompt Library but
was labeled 'Tasks', which read as a task list. Rename it to 'Prompts' to match
the page title and the fact that it now manages task, gate, and AGENTS.md prompts.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
apps/portal/src/components/Layout.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/portal/src/components/Layout.tsx b/apps/portal/src/components/Layout.tsx
index 0630e3453..429601caf 100644
--- a/apps/portal/src/components/Layout.tsx
+++ b/apps/portal/src/components/Layout.tsx
@@ -85,7 +85,7 @@ const navGroups: NavGroup[] = [
id: "library",
label: "Library",
items: [
- { to: "/task-prompts", label: "Tasks", icon: MessageSquareText },
+ { to: "/task-prompts", label: "Prompts", icon: MessageSquareText },
{ to: "/criteria", label: "Criteria", icon: FlaskConical },
{ to: "/prompt-features", label: "Features", icon: Tags },
{ to: "/profiles", label: "Profiles", icon: SlidersHorizontal, featureKey: "profiles" },
From f470340f6a327093b888c2c3a89a290ddea3c721 Mon Sep 17 00:00:00 2001
From: Cedric Vidal
Date: Fri, 26 Jun 2026 12:50:17 -0700
Subject: [PATCH 11/14] feat(prompts): always list all prompt types, including
AGENTS.md
The task-prompt list previously hid non-gate types (agents.md) from the
default unfiltered list, requiring an opt-in flag to surface them. Remove
that exclusion entirely so the Prompt Library, report-template pickers,
API, and CLI always return every prompt type.
Drops the includeNonGate option/flag/query param across the store, API
route, portal client, and CLI, and regenerates the OpenAPI and CLI
snapshots accordingly.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../openapi-snapshot.test.ts.snap | 11 -----------
apps/api/src/routes/task-prompts.ts | 4 +---
.../__snapshots__/cli-commands.test.ts.snap | 1 -
apps/cli/src/commands/task-prompt.ts | 2 --
apps/portal/src/lib/api.ts | 3 +--
apps/portal/src/pages/TaskPromptList.tsx | 2 +-
.../src/task-prompts/task-prompt-store.test.ts | 18 +-----------------
.../src/task-prompts/task-prompt-store.ts | 18 +++---------------
8 files changed, 7 insertions(+), 52 deletions(-)
diff --git a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap
index 5f8da17e8..26db63e9a 100644
--- a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap
+++ b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap
@@ -9236,17 +9236,6 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = `
"$ref": "#/components/schemas/PromptType",
},
},
- {
- "in": "query",
- "name": "includeNonGate",
- "required": false,
- "schema": {
- "type": [
- "boolean",
- "null",
- ],
- },
- },
],
"responses": {
"200": {
diff --git a/apps/api/src/routes/task-prompts.ts b/apps/api/src/routes/task-prompts.ts
index 6af56bbd2..45280e437 100644
--- a/apps/api/src/routes/task-prompts.ts
+++ b/apps/api/src/routes/task-prompts.ts
@@ -80,7 +80,6 @@ apiRoute(ctx.app, ctx.registry, {
offset: z.coerce.number().optional(),
search: z.string().optional(),
type: PromptTypeSchema.optional(),
- includeNonGate: z.coerce.boolean().optional(),
}),
response: z.object({
items: z.array(TaskPromptResponseSchema),
@@ -93,9 +92,8 @@ apiRoute(ctx.app, ctx.registry, {
const offset = req.query.offset ?? 0;
const search = req.query.search;
const type = req.query.type;
- const includeNonGate = req.query.includeNonGate;
- const { items, total } = await ctx.taskPromptStore.getAll({ limit, offset, search, type, includeNonGate });
+ const { items, total } = await ctx.taskPromptStore.getAll({ limit, offset, search, type });
res.json({ items, total, limit, offset });
},
});
diff --git a/apps/cli/src/__snapshots__/cli-commands.test.ts.snap b/apps/cli/src/__snapshots__/cli-commands.test.ts.snap
index c54aecb78..a047a3693 100644
--- a/apps/cli/src/__snapshots__/cli-commands.test.ts.snap
+++ b/apps/cli/src/__snapshots__/cli-commands.test.ts.snap
@@ -779,7 +779,6 @@ exports[`CLI command registration > full command tree matches snapshot 1`] = `
},
"list": {
"options": [
- "--include-non-gate",
"--limit",
"--offset",
"--output",
diff --git a/apps/cli/src/commands/task-prompt.ts b/apps/cli/src/commands/task-prompt.ts
index 963600414..74e77909a 100644
--- a/apps/cli/src/commands/task-prompt.ts
+++ b/apps/cli/src/commands/task-prompt.ts
@@ -30,7 +30,6 @@ taskPrompt
.description("List all task prompts")
.option("-s, --search ", "Filter by text content")
.option("--type ", "Filter by prompt type/gate (select, build, test, run, deploy, agents.md)")
- .option("--include-non-gate", "Include non-gate prompt types (e.g. agents.md) in the default list")
.option("-l, --limit ", "Maximum number of results", "50")
.option("--offset ", "Number of results to skip", "0")
.option("-u, --url ", "API base URL", getDefaultApiUrl())
@@ -42,7 +41,6 @@ taskPrompt
if (options.search) params.set("search", options.search);
const type = parsePromptTypeOption(options.type);
if (type) params.set("type", type);
- if (options.includeNonGate) params.set("includeNonGate", "true");
if (options.limit) params.set("limit", options.limit);
if (options.offset) params.set("offset", options.offset);
const qs = params.toString();
diff --git a/apps/portal/src/lib/api.ts b/apps/portal/src/lib/api.ts
index ae7035090..99401397e 100644
--- a/apps/portal/src/lib/api.ts
+++ b/apps/portal/src/lib/api.ts
@@ -400,13 +400,12 @@ export const api = {
// ─── Task Prompts ──────────────────────────────────────────────────────────
/** List all task prompts (paginated, optional search + type filter) */
- listTaskPrompts: (opts?: { limit?: number; offset?: number; search?: string; type?: PromptType; includeNonGate?: boolean }): Promise<{ items: TaskPrompt[]; total: number }> => {
+ listTaskPrompts: (opts?: { limit?: number; offset?: number; search?: string; type?: PromptType }): Promise<{ items: TaskPrompt[]; total: number }> => {
const params = new URLSearchParams();
if (opts?.limit) params.set("limit", String(opts.limit));
if (opts?.offset) params.set("offset", String(opts.offset));
if (opts?.search) params.set("search", opts.search);
if (opts?.type) params.set("type", opts.type);
- if (opts?.includeNonGate) params.set("includeNonGate", "true");
const qs = params.toString();
return request(`/task-prompts${qs ? `?${qs}` : ""}`);
},
diff --git a/apps/portal/src/pages/TaskPromptList.tsx b/apps/portal/src/pages/TaskPromptList.tsx
index d7dfbd2da..e69211eb7 100644
--- a/apps/portal/src/pages/TaskPromptList.tsx
+++ b/apps/portal/src/pages/TaskPromptList.tsx
@@ -79,7 +79,7 @@ export function TaskPromptList() {
const { data, isLoading } = useQuery({
queryKey: ["task-prompts", state.search, typeFilter],
- queryFn: () => api.listTaskPrompts({ search: state.search || undefined, type: typeFilter, includeNonGate: true }),
+ queryFn: () => api.listTaskPrompts({ search: state.search || undefined, type: typeFilter }),
});
const items = data?.items ?? [];
diff --git a/packages/shared/src/task-prompts/task-prompt-store.test.ts b/packages/shared/src/task-prompts/task-prompt-store.test.ts
index 9e32840b2..22d19b0f1 100644
--- a/packages/shared/src/task-prompts/task-prompt-store.test.ts
+++ b/packages/shared/src/task-prompts/task-prompt-store.test.ts
@@ -338,30 +338,14 @@ describe("TaskPromptStore", () => {
expect(items[0].text).toBe("Azure deployment");
});
- it("defaults to task-typed prompts (and legacy untyped docs)", async () => {
+ it("defaults to all prompt types (including agents.md and legacy untyped docs)", async () => {
await store.findOrCreate("a task prompt");
await store.findOrCreate("an agents file", "agents.md");
const { items, total } = await store.getAll();
- expect(total).toBe(1);
- expect(items[0].text).toBe("a task prompt");
- });
-
- it("includes non-gate types when includeNonGate is set", async () => {
- await store.findOrCreate("a task prompt");
- await store.findOrCreate("an agents file", "agents.md");
- const { items, total } = await store.getAll({ includeNonGate: true });
expect(total).toBe(2);
expect(items.map((i) => i.text).sort()).toEqual(["a task prompt", "an agents file"]);
});
- it("ignores includeNonGate when an explicit type is given", async () => {
- await store.findOrCreate("a task prompt");
- await store.findOrCreate("an agents file", "agents.md");
- const { items, total } = await store.getAll({ type: "agents.md", includeNonGate: true });
- expect(total).toBe(1);
- expect(items[0].text).toBe("an agents file");
- });
-
it("filters by agents.md type", async () => {
await store.findOrCreate("a task prompt");
await store.findOrCreate("an agents file", "agents.md");
diff --git a/packages/shared/src/task-prompts/task-prompt-store.ts b/packages/shared/src/task-prompts/task-prompt-store.ts
index 9c6273a2a..da3f9e682 100644
--- a/packages/shared/src/task-prompts/task-prompt-store.ts
+++ b/packages/shared/src/task-prompts/task-prompt-store.ts
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import { Collection } from 'mongodb';
-import { TaskPromptDocument, PromptFeatureResult, PromptType, NON_GATE_PROMPT_TYPES } from '../types/types.js';
+import { TaskPromptDocument, PromptFeatureResult, PromptType } from '../types/types.js';
import { computePromptId } from './task-prompt-id.js';
import type { BlobStorage } from '../storage/blob-storage.js';
@@ -151,20 +151,14 @@ export class TaskPromptStore {
* List active (non-deleted) task prompts.
* Supports pagination, optional substring search on text, and an optional
* `type` filter. Pass a `type` (e.g. `'select'` or `'agents.md'`) to scope the
- * list to a single type.
- *
- * With no `type`, the default list shows all gate prompt types and legacy
- * untyped docs, but hides non-gate types such as `agents.md` (which can be
- * high-volume when auto-generated). Set `includeNonGate` to also surface those
- * non-gate types in the unfiltered list — used by the Prompt Library, which
- * manages AGENTS.md prompts alongside task/gate prompts.
+ * list to a single type. With no `type`, all prompt types are returned — gate
+ * prompts, non-gate types such as `agents.md`, and legacy untyped docs.
*/
async getAll(opts?: {
limit?: number;
offset?: number;
search?: string;
type?: PromptType;
- includeNonGate?: boolean;
}): Promise<{ items: TaskPromptDocument[]; total: number }> {
const filter: Record = { deletedAt: { $exists: false } };
@@ -173,12 +167,6 @@ export class TaskPromptStore {
}
if (opts?.type) {
filter.type = opts.type;
- } else if (!opts?.includeNonGate) {
- // The default (unfiltered) list shows all gate prompt types and legacy
- // untyped docs, but hides non-gate types such as auto-generated
- // 'agents.md' candidates, which can be high-volume noise. `$nin`
- // also matches docs with no `type` field, so legacy prompts stay visible.
- filter.type = { $nin: NON_GATE_PROMPT_TYPES };
}
const total = await this.collection.countDocuments(filter);
From f86594636ffb2ccdee7d80146dbbafe4504d473f Mon Sep 17 00:00:00 2001
From: Cedric Vidal
Date: Fri, 26 Jun 2026 13:05:22 -0700
Subject: [PATCH 12/14] feat(portal): adapt prompt detail heading to the prompt
type
The detail page hard-coded "Task Prompt" / "Task Text", which is wrong for
non-gate prompts such as AGENTS.md. Render the identity card title from the
prompt type (e.g. "AGENTS.md Prompt", "Select Prompt") and rename the body
card to the generic "Prompt Text".
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
apps/portal/src/pages/TaskPromptDetail.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/portal/src/pages/TaskPromptDetail.tsx b/apps/portal/src/pages/TaskPromptDetail.tsx
index a2724d1b0..b7d72690f 100644
--- a/apps/portal/src/pages/TaskPromptDetail.tsx
+++ b/apps/portal/src/pages/TaskPromptDetail.tsx
@@ -102,7 +102,7 @@ export function TaskPromptDetail() {
{/* Identity card */}
- Task Prompt
+ {promptTypeLabel(taskPrompt.type)} Prompt
{taskPrompt._id}
@@ -130,7 +130,7 @@ export function TaskPromptDetail() {
{/* Task text card */}
- Task Text
+ Prompt Text
Immutable — content determines the ID
From 2444d7c80d34a39bda8ad6e5164a84068b800a25 Mon Sep 17 00:00:00 2001
From: Cedric Vidal
Date: Fri, 26 Jun 2026 13:27:27 -0700
Subject: [PATCH 13/14] test(portal): cover AGENTS.md prompt UI surfaces
Adds focused unit tests for the portal changes in the AGENTS.md feature
that previously had no coverage:
- Layout: the Library nav item is labelled 'Prompts' and links to
/task-prompts (renamed from 'Tasks').
- TaskPromptDetail: the heading adapts to the prompt type
('AGENTS.md Prompt' / 'Select Prompt', legacy untyped falls back to
'Select Prompt') and the body card is the generic 'Prompt Text'.
- TaskPromptPicker: the AGENTS.md library lookup scopes the query to
type=agents.md, fills the field with the selected prompt text, and
shows an empty state when there are no matches.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
apps/portal/src/components/Layout.test.tsx | 12 +++
.../src/components/TaskPromptPicker.test.tsx | 78 ++++++++++++++++++
.../src/pages/TaskPromptDetail.test.tsx | 80 +++++++++++++++++++
3 files changed, 170 insertions(+)
create mode 100644 apps/portal/src/components/TaskPromptPicker.test.tsx
create mode 100644 apps/portal/src/pages/TaskPromptDetail.test.tsx
diff --git a/apps/portal/src/components/Layout.test.tsx b/apps/portal/src/components/Layout.test.tsx
index 97ceac2de..4780ba3dd 100644
--- a/apps/portal/src/components/Layout.test.tsx
+++ b/apps/portal/src/components/Layout.test.tsx
@@ -53,4 +53,16 @@ describe("Layout", () => {
expect(screen.getByRole("button", { name: "Collapse sidebar" })).toBeTruthy();
expect(within(sidebar).getByText("Activity")).toBeTruthy();
});
+
+ it("labels the prompt library nav item 'Prompts' linking to /task-prompts", () => {
+ localStorage.setItem("scope:layout:sidebar-expanded", "1");
+
+ renderLayout();
+
+ const sidebar = screen.getByLabelText("Primary navigation");
+ const promptsLink = within(sidebar).getByRole("link", { name: "Prompts" });
+ expect(promptsLink.getAttribute("href")).toBe("/task-prompts");
+ // The legacy "Tasks" label must be gone.
+ expect(within(sidebar).queryByRole("link", { name: "Tasks" })).toBeNull();
+ });
});
diff --git a/apps/portal/src/components/TaskPromptPicker.test.tsx b/apps/portal/src/components/TaskPromptPicker.test.tsx
new file mode 100644
index 000000000..0cb85cb28
--- /dev/null
+++ b/apps/portal/src/components/TaskPromptPicker.test.tsx
@@ -0,0 +1,78 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+// @vitest-environment happy-dom
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { TaskPrompt } from "@/types";
+
+const listTaskPrompts = vi.fn(
+ async () => ({ items: [] as TaskPrompt[], total: 0 }),
+);
+
+vi.mock("@/lib/api", () => ({
+ api: {
+ listTaskPrompts: (...args: unknown[]) => listTaskPrompts(...(args as [])),
+ },
+}));
+
+import { TaskPromptPicker } from "./TaskPromptPicker";
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+const AGENTS_PROMPTS: TaskPrompt[] = [
+ { _id: "41f57458-aaaa", text: "When creating REST APIs, use Typescript.", type: "agents.md", createdAt: "2026-01-01T00:00:00.000Z" },
+ { _id: "3d9e1d0f-bbbb", text: "When creating REST APIs, use Flask.", type: "agents.md", createdAt: "2026-01-01T00:00:00.000Z" },
+];
+
+function renderPicker(onSelect = vi.fn()) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ render(
+
+
+ ,
+ );
+ return { onSelect };
+}
+
+describe("TaskPromptPicker (AGENTS.md library lookup)", () => {
+ it("scopes the prompt-library query to the agents.md type", async () => {
+ listTaskPrompts.mockResolvedValue({ items: AGENTS_PROMPTS, total: 2 });
+
+ renderPicker();
+ fireEvent.focus(screen.getByPlaceholderText(/AGENTS\.md/));
+
+ // Wait for the dropdown to populate from the (mocked) library.
+ expect(await screen.findByText(/use Typescript/)).toBeTruthy();
+ expect(listTaskPrompts).toHaveBeenCalledWith({ search: undefined, limit: 8, type: "agents.md" });
+ });
+
+ it("fills the field with the selected prompt's text on click", async () => {
+ listTaskPrompts.mockResolvedValue({ items: AGENTS_PROMPTS, total: 2 });
+
+ const { onSelect } = renderPicker();
+ fireEvent.focus(screen.getByPlaceholderText(/AGENTS\.md/));
+
+ const item = await screen.findByText(/use Typescript/);
+ fireEvent.click(item);
+
+ expect(onSelect).toHaveBeenCalledWith("When creating REST APIs, use Typescript.");
+ });
+
+ it("shows an empty-state message when the library has no matches", async () => {
+ listTaskPrompts.mockResolvedValue({ items: [], total: 0 });
+
+ renderPicker();
+ fireEvent.change(screen.getByPlaceholderText(/AGENTS\.md/), { target: { value: "nope" } });
+
+ expect(await screen.findByText(/No matching task prompts/)).toBeTruthy();
+ });
+});
diff --git a/apps/portal/src/pages/TaskPromptDetail.test.tsx b/apps/portal/src/pages/TaskPromptDetail.test.tsx
new file mode 100644
index 000000000..006da3354
--- /dev/null
+++ b/apps/portal/src/pages/TaskPromptDetail.test.tsx
@@ -0,0 +1,80 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+// @vitest-environment happy-dom
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { MemoryRouter, Routes, Route } from "react-router-dom";
+import type { TaskPrompt } from "@/types";
+
+const getTaskPrompt = vi.fn(async () => ({}) as TaskPrompt);
+
+vi.mock("@/lib/api", () => ({
+ api: {
+ getTaskPrompt: (...args: unknown[]) => getTaskPrompt(...(args as [])),
+ deleteTaskPrompt: vi.fn(),
+ },
+}));
+
+// The Features card fetches on its own; stub it out so the test stays focused
+// on the heading/type-label behavior.
+vi.mock("@/components/TaskPromptFeatures", () => ({
+ TaskPromptFeatures: () => null,
+}));
+
+import { TaskPromptDetail } from "./TaskPromptDetail";
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+function renderDetail() {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+
+ } />
+
+
+ ,
+ );
+}
+
+const basePrompt: TaskPrompt = {
+ _id: "abc",
+ text: "When creating REST APIs, use Typescript.",
+ createdAt: "2026-01-01T00:00:00.000Z",
+};
+
+describe("TaskPromptDetail heading adapts to prompt type", () => {
+ it("renders 'AGENTS.md Prompt' for an agents.md prompt", async () => {
+ getTaskPrompt.mockResolvedValue({ ...basePrompt, type: "agents.md" });
+
+ renderDetail();
+
+ expect(await screen.findByText(/AGENTS\.md Prompt/)).toBeTruthy();
+ // Generic content card label (renamed from "Task Text").
+ expect(screen.getByText("Prompt Text")).toBeTruthy();
+ // The legacy hard-coded "Task Prompt" heading must be gone.
+ expect(screen.queryByText("Task Prompt")).toBeNull();
+ });
+
+ it("renders 'Select Prompt' for a select gate prompt", async () => {
+ getTaskPrompt.mockResolvedValue({ ...basePrompt, type: "select" });
+
+ renderDetail();
+
+ expect(await screen.findByText(/Select Prompt/)).toBeTruthy();
+ });
+
+ it("falls back to 'Select Prompt' for a legacy untyped prompt", async () => {
+ getTaskPrompt.mockResolvedValue({ ...basePrompt, type: undefined });
+
+ renderDetail();
+
+ expect(await screen.findByText(/Select Prompt/)).toBeTruthy();
+ });
+});
From 75f4525f33b9deac38d8c1300d716434ca6c9d7d Mon Sep 17 00:00:00 2001
From: Cedric Vidal
Date: Sat, 27 Jun 2026 10:56:17 -0700
Subject: [PATCH 14/14] docs(api): document trusted-network assumption on
prompt /content route
Address review feedback: the GET /task-prompts/:id/content endpoint returns any
prompt body by ID with no authorization check, consistent with the API's current
no-auth/no-tenancy model. Since prompts are globally content-addressed and
deduplicated via findOrCreate, document that this assumes a trusted-network,
single-tenant deployment and must be revisited if auth/multi-tenancy lands.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
apps/api/src/routes/task-prompts.ts | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/apps/api/src/routes/task-prompts.ts b/apps/api/src/routes/task-prompts.ts
index 45280e437..a9447c7e7 100644
--- a/apps/api/src/routes/task-prompts.ts
+++ b/apps/api/src/routes/task-prompts.ts
@@ -122,6 +122,14 @@ apiRoute(ctx.app, ctx.registry, {
// GET /api/v1/task-prompts/:id/content — get the resolved plain-text body
// (inline or downloaded from blob). Used by workers to fetch AGENTS.md / task text.
+//
+// NOTE: This returns any prompt body by ID with no authorization check. That is
+// consistent with the rest of the API, which has no auth/tenancy model today
+// (see index.ts: just cors() + express.json()). Because prompts are globally
+// content-addressed and deduplicated via taskPromptStore.findOrCreate, this route
+// implicitly assumes a trusted-network, single-tenant deployment. If auth or
+// multi-tenancy is ever introduced, revisit this: the dedup store is NOT tenant-safe
+// and this endpoint would leak prompt bodies across tenants.
apiRoute(ctx.app, ctx.registry, {
method: "get",
path: "/api/v1/task-prompts/:id/content",