From ed5309be867095c275131854354ef87bc657322e Mon Sep 17 00:00:00 2001 From: Antoni Sobkowicz Date: Mon, 14 Sep 2026 12:18:50 +0200 Subject: [PATCH] fix(context): validate each find_teamspaces candidate's ticket custom type CODE-1224: find_teamspaces only fetched gid/name from Asana's typeahead endpoint, so every candidate carried the same blanket schema_validated: false regardless of whether it was an actual Command Teamspace. An agent could pick an ordinary project and only discover it was wrong on the next call, failing with schema_incompatible. Move schema_validated onto each candidate and populate it with one cheap customTypes lookup per candidate (not full schema discovery), reusing the same resolveTicketCustomType check get_context relies on. This is a breaking change to find_teamspaces' output shape. Co-Authored-By: Claude Sonnet 5 --- README.md | 2 +- src/schema_discovery.ts | 18 +++++++ src/tool_definitions/context.ts | 2 +- src/tools/context.ts | 28 ++++++++--- tests/context.test.ts | 70 ++++++++++++++++++++++++-- tests/schema_discovery.test.ts | 34 +++++++++++++ tests/tool_definitions/context.test.ts | 20 +++++--- 7 files changed, 155 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 5724cb7..a5d302b 100644 --- a/README.md +++ b/README.md @@ -279,7 +279,7 @@ The descriptions below are the exact strings advertised through MCP tool discove | --- | --- | --- | | `get_context` | Read | Confirm one selected Teamspace at the start of an Asana workflow or when diagnosing schema warnings; do not call before every tool. | | `list_workspaces` | Read | List workspaces accessible to the configured Asana identity for Teamspace discovery or access diagnosis. | -| `find_teamspaces` | Read | Find recent or query-matched Teamspace candidates in one workspace; candidates are not schema-validated. | +| `find_teamspaces` | Read | Find recent or query-matched Teamspace candidates in one workspace; each candidate reports whether it has a resolvable Command ticket custom type. | | `get_teamspace_schema` | Read | Return the freshly discovered Command schema used for this tool call. | | `read_ticket` | Read | Read one Command ticket by Asana GID, Command short ID, or Asana task URL. | | `list_tickets` | Read | Enumerate tickets in the selected Teamspace with bounded type, label, assignee, Release, and completion-status filtering plus opaque pagination. Use search_tickets instead for completion-date ranges. | diff --git a/src/schema_discovery.ts b/src/schema_discovery.ts index e94b67d..f0ea19b 100644 --- a/src/schema_discovery.ts +++ b/src/schema_discovery.ts @@ -209,6 +209,24 @@ function resolveTicketCustomType(customTypes: CustomType[]): CustomType { schemaAmbiguous("Multiple ticket custom types found", customTypes); } +export async function hasResolvableTicketCustomType( + executor: AsanaRequestExecutorPort, + teamspaceId: string, + options: AsanaRequestOptions, + trace: AsanaRequestTrace, +): Promise { + try { + const customTypes = await collectCustomTypes(executor, teamspaceId, options, trace); + resolveTicketCustomType(customTypes); + return true; + } catch (error) { + if (error instanceof CommandError) { + return false; + } + throw error; + } +} + function fieldCandidate(field: CustomField): Candidate { return { gid: field.gid, name: field.name }; } diff --git a/src/tool_definitions/context.ts b/src/tool_definitions/context.ts index f30a99d..2ca94a9 100644 --- a/src/tool_definitions/context.ts +++ b/src/tool_definitions/context.ts @@ -49,7 +49,7 @@ const findTeamspaces = defineUnscopedTool({ name: "find_teamspaces", title: "Find Command Teamspaces", description: - "Find recent or query-matched Teamspace candidates in one workspace; candidates are not schema-validated.", + "Find recent or query-matched Teamspace candidates in one workspace; each candidate reports whether it has a resolvable Command ticket custom type.", input: FindTeamspacesInputSchema, output: TeamspaceCandidatesSchema, readOnly: true, diff --git a/src/tools/context.ts b/src/tools/context.ts index 4da2224..fdb7154 100644 --- a/src/tools/context.ts +++ b/src/tools/context.ts @@ -14,7 +14,7 @@ import type { import { commandTeamspaceUrl } from "../asana_url.js"; import { CommandError } from "../errors.js"; import { collectPages } from "../pagination/scanner.js"; -import type { DiscoveryResult } from "../schema_discovery.js"; +import { type DiscoveryResult, hasResolvableTicketCustomType } from "../schema_discovery.js"; import { TeamspaceReferenceSchema } from "../teamspace_identity.js"; import { createGitHubUpdateChecker, type UpdateChecker } from "../update_check.js"; @@ -24,11 +24,15 @@ export const WorkspaceListSchema = z.object({ const TeamspaceCandidateReferenceSchema = TeamspaceReferenceSchema.extend({ url: z.string().url(), + schema_validated: z + .boolean() + .describe( + "Whether this candidate has a resolvable Command ticket custom type. False means a scoped tool call such as get_context will fail with schema_incompatible or schema_ambiguous. True increases confidence but does not guarantee every other schema field is valid.", + ), }); export const TeamspaceCandidatesSchema = z.object({ candidates: z.array(TeamspaceCandidateReferenceSchema), - schema_validated: z.literal(false), truncated: z.boolean(), }); @@ -128,13 +132,25 @@ async function findTeamspaces( trace, ); - return { - candidates: page.items.map((candidate) => ({ + // Checked sequentially, one Asana call per candidate, to keep concurrency bounded and stay + // within the caller's deadline rather than bursting up to `limit` (max 20) requests at once. + const candidates: TeamspaceCandidates["candidates"] = []; + for (const candidate of page.items) { + candidates.push({ gid: candidate.gid, name: candidate.name, url: commandTeamspaceUrl(input.workspaceGid, candidate.gid), - })), - schema_validated: false, + schema_validated: await hasResolvableTicketCustomType( + executor, + candidate.gid, + options, + trace, + ), + }); + } + + return { + candidates, truncated: page.items.length === input.limit, }; } diff --git a/tests/context.test.ts b/tests/context.test.ts index 1824acf..cd1e3b7 100644 --- a/tests/context.test.ts +++ b/tests/context.test.ts @@ -43,17 +43,22 @@ function createThrowingApi(apiName: string): T { type ResourceMethods = { getWorkspaces?: WorkspacesApi["getWorkspacesWithHttpInfo"]; typeahead?: TypeaheadApi["typeaheadForWorkspaceWithHttpInfo"]; + getCustomTypes?: CustomTypesApi["getCustomTypesWithHttpInfo"]; }; function createResourceBundle(methods: ResourceMethods): AsanaResourceBundle { const workspaces = createThrowingApi("workspaces"); const typeahead = createThrowingApi("typeahead"); + const customTypes = createThrowingApi("customTypes"); if (methods.getWorkspaces !== undefined) { workspaces.getWorkspacesWithHttpInfo = methods.getWorkspaces; } if (methods.typeahead !== undefined) { typeahead.typeaheadForWorkspaceWithHttpInfo = methods.typeahead; } + if (methods.getCustomTypes !== undefined) { + customTypes.getCustomTypesWithHttpInfo = methods.getCustomTypes; + } return { tasks: createThrowingApi("tasks"), @@ -61,12 +66,25 @@ function createResourceBundle(methods: ResourceMethods): AsanaResourceBundle { stories: createThrowingApi("stories"), attachments: createThrowingApi("attachments"), customFieldSettings: createThrowingApi("customFieldSettings"), - customTypes: createThrowingApi("customTypes"), + customTypes, typeahead, workspaces, }; } +function customTypesFor( + resolvableGids: ReadonlySet, +): CustomTypesApi["getCustomTypesWithHttpInfo"] { + return async (opts) => { + const projectGid = (opts as { project?: string } | undefined)?.project; + const types = + projectGid !== undefined && resolvableGids.has(projectGid) + ? [{ gid: "1800000000000001", name: "Dev ticket" }] + : []; + return collectionResult(types); + }; +} + function collectionResult(items: unknown[], nextOffset?: string): AsanaHttpResult { return { response: { headers: {} }, @@ -175,7 +193,14 @@ describe("context service", () => { { gid: "1600000000000002", name: "Mobile" }, ]); }; - const service = createContextService(createExecutor(createResourceBundle({ typeahead }))); + const service = createContextService( + createExecutor( + createResourceBundle({ + typeahead, + getCustomTypes: customTypesFor(new Set([TEAMSPACE_GID, "1600000000000002"])), + }), + ), + ); const result = await service.findTeamspaces({ workspaceGid: WORKSPACE_GID, @@ -197,18 +222,49 @@ describe("context service", () => { gid: TEAMSPACE_GID, name: "Platform", url: `https://app.asana.com/1/${WORKSPACE_GID}/dev/space/${TEAMSPACE_GID}`, + schema_validated: true, }, { gid: "1600000000000002", name: "Mobile", url: `https://app.asana.com/1/${WORKSPACE_GID}/dev/space/1600000000000002`, + schema_validated: true, }, ], - schema_validated: false, truncated: true, }); }); + it("distinguishes candidates with and without a resolvable ticket custom type", async () => { + const validGid = TEAMSPACE_GID; + const invalidGid = "1600000000000002"; + const observedProjectGids: Array = []; + const typeahead: TypeaheadApi["typeaheadForWorkspaceWithHttpInfo"] = async () => + collectionResult([ + { gid: validGid, name: "Platform" }, + { gid: invalidGid, name: "Ordinary project" }, + ]); + const getCustomTypes: CustomTypesApi["getCustomTypesWithHttpInfo"] = async (opts) => { + observedProjectGids.push((opts as { project?: string } | undefined)?.project); + return customTypesFor(new Set([validGid]))(opts); + }; + const service = createContextService( + createExecutor(createResourceBundle({ typeahead, getCustomTypes })), + ); + + const result = await service.findTeamspaces({ + workspaceGid: WORKSPACE_GID, + limit: 2, + deadlineMs: DEADLINE_MS, + }); + + expect(observedProjectGids).toEqual([validGid, invalidGid]); + expect(result.candidates).toEqual([ + expect.objectContaining({ gid: validGid, schema_validated: true }), + expect.objectContaining({ gid: invalidGid, schema_validated: false }), + ]); + }); + it("omits an absent typeahead query and is not truncated below the requested limit", async () => { let observedOptions: Record | null = null; const typeahead: TypeaheadApi["typeaheadForWorkspaceWithHttpInfo"] = async ( @@ -219,7 +275,11 @@ describe("context service", () => { observedOptions = options ?? {}; return collectionResult([{ gid: TEAMSPACE_GID, name: "Platform" }]); }; - const service = createContextService(createExecutor(createResourceBundle({ typeahead }))); + const service = createContextService( + createExecutor( + createResourceBundle({ typeahead, getCustomTypes: customTypesFor(new Set()) }), + ), + ); const result = await service.findTeamspaces({ workspaceGid: WORKSPACE_GID, @@ -232,7 +292,7 @@ describe("context service", () => { opt_fields: "gid,name", }); expect(observedOptions).not.toHaveProperty("query"); - expect(result.schema_validated).toBe(false); + expect(result.candidates[0]?.schema_validated).toBe(false); expect(result.truncated).toBe(false); }); diff --git a/tests/schema_discovery.test.ts b/tests/schema_discovery.test.ts index 3b2eaf0..ef12341 100644 --- a/tests/schema_discovery.test.ts +++ b/tests/schema_discovery.test.ts @@ -11,6 +11,7 @@ import { computeDiscoveryFingerprint, discoverTeamspaceSchema, type FieldDefinition, + hasResolvableTicketCustomType, readReferencedReleaseGids, } from "../src/schema_discovery.js"; import { parseEnvelopeData } from "./helpers/tool_test_helpers.js"; @@ -281,6 +282,39 @@ function completeTeamspaceState( const requestOptions: AsanaRequestOptions = { deadlineMs: deadlineAfter(10_000) }; +describe("hasResolvableTicketCustomType", () => { + it("returns true when exactly one ticket custom type resolves", async () => { + const executor = createFakeExecutor(completeTeamspaceState()); + + await expect( + hasResolvableTicketCustomType(executor, TEAMSPACE_ID, requestOptions, executor.createTrace()), + ).resolves.toBe(true); + }); + + it("returns false when no custom types are found", async () => { + const executor = createFakeExecutor(completeTeamspaceState({ customTypes: [] })); + + await expect( + hasResolvableTicketCustomType(executor, TEAMSPACE_ID, requestOptions, executor.createTrace()), + ).resolves.toBe(false); + }); + + it("returns false when multiple ambiguous custom types are found", async () => { + const executor = createFakeExecutor( + completeTeamspaceState({ + customTypes: [ + { gid: "1800000000000001", name: "Alpha" }, + { gid: "1800000000000002", name: "Beta" }, + ], + }), + ); + + await expect( + hasResolvableTicketCustomType(executor, TEAMSPACE_ID, requestOptions, executor.createTrace()), + ).resolves.toBe(false); + }); +}); + describe("discoverTeamspaceSchema", () => { it("resolves every required field for a complete Teamspace", async () => { const state = completeTeamspaceState(); diff --git a/tests/tool_definitions/context.test.ts b/tests/tool_definitions/context.test.ts index 1892552..00d57c3 100644 --- a/tests/tool_definitions/context.test.ts +++ b/tests/tool_definitions/context.test.ts @@ -97,7 +97,7 @@ describe("context tool definitions", () => { name: "find_teamspaces", title: "Find Command Teamspaces", description: - "Find recent or query-matched Teamspace candidates in one workspace; candidates are not schema-validated.", + "Find recent or query-matched Teamspace candidates in one workspace; each candidate reports whether it has a resolvable Command ticket custom type.", }, { name: "get_teamspace_schema", @@ -143,14 +143,25 @@ describe("context tool definitions", () => { it("requires a URL on every find_teamspaces candidate", () => { const output = findTool("find_teamspaces").outputSchema; const resultWithoutUrl = output.safeParse({ - candidates: [{ gid: TEAMSPACE_ID, name: "Engineering Teamspace" }], - schema_validated: false, + candidates: [{ gid: TEAMSPACE_ID, name: "Engineering Teamspace", schema_validated: true }], truncated: false, }); expect(resultWithoutUrl.success).toBe(false); }); + it("requires a schema_validated flag on every find_teamspaces candidate", () => { + const output = findTool("find_teamspaces").outputSchema; + const resultWithoutFlag = output.safeParse({ + candidates: [ + { gid: TEAMSPACE_ID, name: "Engineering Teamspace", url: "https://app.asana.com/x" }, + ], + truncated: false, + }); + + expect(resultWithoutFlag.success).toBe(false); + }); + it("lists workspaces without triggering schema discovery", async () => { let observedDeadline: number | null = null; const context = createUnexpectedContextService({ @@ -191,7 +202,6 @@ describe("context tool definitions", () => { observedInput = input; return { candidates: [], - schema_validated: false, truncated: false, }; }, @@ -202,7 +212,6 @@ describe("context tool definitions", () => { tool.execute({ workspace_gid: WORKSPACE_GID }, callContext(createServices({ context }))), ).resolves.toEqual({ candidates: [], - schema_validated: false, truncated: false, }); expect(observedInput).toEqual({ @@ -219,7 +228,6 @@ describe("context tool definitions", () => { observedInput = input; return { candidates: [], - schema_validated: false, truncated: false, }; },