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..26db63e9a 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": [ @@ -2975,19 +3006,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 +6181,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 +6261,13 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "text": { "type": "string", }, + "type": { + "enum": [ + "select", + "agents.md", + ], + "type": "string", + }, }, "required": [ "text", @@ -9191,14 +9233,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 +9431,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/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..a9447c7e7 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,41 @@ 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. +// +// 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", + 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 +172,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 +255,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", 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/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; } 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/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" }, 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({

+
+ + +

+ Features are only extracted against prompts of their own type. +

+
+
+ {/* ─── AGENTS.md (optional, discreet) ──────────────────────────── */} + {!(agentsMdOpen || agentsMd.trim()) ? ( + + ) : ( +
+
+
+ + +
+ +
+ setAgentsMd(text)} + /> +