From 830ee3ddf3633b31ebcf00927df2cc4d1dc18762 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 22:09:31 -0700 Subject: [PATCH] Build a fresh object literal at an async record result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unannotated async method inside a contextually typed object literal is checked against the contextual signature's result, while its own inferred return type stays the returned literal's narrower record. The async return lowered the literal untyped and then coerced that wider record into the declared result, which refused on `content`: the wider content-part union array does not lift into the inferred text-record array. A ToolDefinition execute reached through a Record hit the same SC2002. A fresh object literal returned into a record result is not a thenable, so when the ordinary construction cannot convert it is now built at the result slot, in both the synchronous and the promise-adopting return paths. The field-set comparison also considers the literal's own checker type, not only the contextual type it was checked against. Both the return-path rule and the object-literal rule ask the same question first — does the record the ordinary path builds have a width plan into the result? — so a literal that converts today, and every value that is not a fresh literal, keeps its exact lowering. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/object-literals.ts | 32 ++++++-- .../compiler/src/frontend/lowering/lowerer.ts | 43 ++++++++++- ...9-async-return-record-literal-slot.ts.json | 6 ++ .../5799-async-return-record-literal-slot.ts | 76 +++++++++++++++++++ ...9-async-return-record-literal-slot.ts.json | 1 + 5 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5799-async-return-record-literal-slot.ts.json create mode 100644 tests/corpus/5799-async-return-record-literal-slot.ts create mode 100644 tests/harness/corpus-costs/5799-async-return-record-literal-slot.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 7947f2830..32caedcbf 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -1346,20 +1346,38 @@ export function lowerObjectLiteral( ) { const own = lowerer.shapes.get(type.shapeId)!; const target = lowerer.shapes.get(expected.shapeId)!; - if ( - !own.tuple && + // The field set to compare is the LITERAL's: its checker type. The + // contextual type a few lines up can be wider than what the literal + // writes (an unannotated method checked against a generic slot's + // signature: `{ content, details }` under a contextual + // `AgentToolResult` whose optional members the literal never + // mentions), and the destination still names the same fields as the + // literal, so the build can construct them directly. Only when the + // contextual build has no conversion into the destination at all: a + // value that converts today keeps its exact construction. + const ownType = lowerer.mapTypeOf(lowerer.typeOf(expr)); + const ownChecked = + ownType?.kind === "record" && + ownType.shapeId !== type.shapeId && + lowerer.widthLiftPlan(type, expected) === null + ? lowerer.shapes.get(ownType.shapeId) + : undefined; + const fits = (from: IrRecordShape): boolean => + !from.tuple && !target.tuple && - !own.indexValue && + !from.indexValue && !target.indexValue && - !shapeHasAccessorSlots(own) && + !shapeHasAccessorSlots(from) && !shapeHasAccessorSlots(target) && target.fields.every( (field) => - own.fields.some((candidate) => candidate.name === field.name) || + from.fields.some((candidate) => candidate.name === field.name) || (field.type.kind === "union" && lowerer.armTag(field.type.unionId, UNDEFINED_T) >= 0), ) && - own.fields.every((field) => target.fields.some((candidate) => candidate.name === field.name)) - ) { + from.fields.every((field) => + target.fields.some((candidate) => candidate.name === field.name), + ); + if (fits(own) || (ownChecked !== undefined && fits(ownChecked))) { type = { kind: "record", shapeId: expected.shapeId }; } } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 7700b7406..ad7ca5fc5 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -14204,9 +14204,49 @@ export class Lowerer { if (this.returnsLiteralIntoDyn(node, expected)) return this.lowerExprExpecting(node, expected); if (this.returnsFreshCollection(node) && expected.kind !== "void") return this.lowerExprExpecting(node, expected); + // A fresh object literal into a record result: the same + // destination-directed construction. An unannotated method inside a + // contextually typed literal is checked against the CONTEXTUAL + // signature's result while its own return type stays the literal's + // inferred (narrower) record — `ToolDefinition` + // methods returning `{ content, details }`. Building at the + // contextual type would then coerce that wider record into the + // declared result and refuse; the result slot is the layout. + if (this.returnsFreshRecord(node, expected)) return this.lowerExprExpecting(node, expected); return this.asyncReturnValueOf(node, this.lowerExpr(node), expected); } + /** A fresh object literal returned into a RECORD result whose ordinary + * construction cannot reach that result: not a thenable, so it is built + * at the result type like a synchronous return. An unannotated method + * inside a contextually typed literal is checked against the CONTEXTUAL + * signature's result while its own return type stays the literal's + * inferred (narrower) record — `ToolDefinition` methods + * returning `{ content, details }`. Building at the contextual type + * would then coerce that wider record into the declared result and + * refuse; the result slot is the layout. A literal that declares `then` + * is awaited instead, and a literal whose contextual build does convert + * keeps the ordinary lowering, so no convertible value changes. */ + private returnsFreshRecord(node: ts.Expression, expected: IrType): boolean { + if (expected.kind !== "record") return false; + let e = node; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (!ts.isObjectLiteralExpression(e)) return false; + if (this.checker.getPropertyOfType(this.typeOf(e), "then") !== undefined) return false; + // The record the ordinary path builds: the literal's context, with an + // async return's `T | PromiseLike` stripped to the awaited record + // (object-literals.ts does the same before mapping). + let context = this.checker.getContextualType(e) ?? this.typeOf(e); + if ( + context.isUnionType() && + ts.constituentTypes(context).some((t) => ts.symbolNameOf(t.getSymbol()) === "PromiseLike") + ) + context = this.checker.getAwaitedType(context) ?? context; + const built = this.mapTypeOf(context); + if (!built || built.kind !== "record") return false; + return this.widthLiftPlan(built, expected) === null; + } + /** An array literal or a `new Map(...)`, `new Set(...)` or `new * Array(...)` of the builtins: a fresh collection, never a thenable, so * an async return takes the declared result type as its construction @@ -14381,7 +14421,8 @@ export class Lowerer { if ( this.adoptsReturnedPromise() && !this.returnsLiteralIntoDyn(node, expected) && - !this.returnsFreshCollection(node) + !this.returnsFreshCollection(node) && + !this.returnsFreshRecord(node, expected) ) { // The async return lowers its operand untyped first (a promise // adopts before its payload converts); a maybe-promise union needs diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5799-async-return-record-literal-slot.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5799-async-return-record-literal-slot.ts.json new file mode 100644 index 000000000..11487dedc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5799-async-return-record-literal-slot.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5799-async-return-record-literal-slot.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5799-async-return-record-literal-slot.ts b/tests/corpus/5799-async-return-record-literal-slot.ts new file mode 100644 index 000000000..fa5824046 --- /dev/null +++ b/tests/corpus/5799-async-return-record-literal-slot.ts @@ -0,0 +1,76 @@ +// A tool definition whose `execute` is an unannotated async method inside a +// contextually typed object literal. tsc checks the method against the +// contextual signature's result (`AgentToolResult`) but INFERS its +// own return type from the returned literal (`{ content: { type, text }[]; +// details: { loaded } }`). The fresh literal must construct at the declared +// result slot, not at the contextual type it was checked against: the +// contextual type's `content` is the wider content-part union array, which +// no copy narrows back into the inferred element record. +interface TextContent { + type: string; + text: string; + textSignature?: string; +} +interface ImageContent { + type: string; + data: string; + mimeType: string; +} +interface Candidate { + name: string; + score: number; +} +interface Definition { + name: string; + search(query: string): Promise; + execute(id: string, query: string): Promise>; +} +interface Result { + content: (TextContent | ImageContent)[]; + details: D; + structuredContent?: unknown; + isError?: boolean; +} +interface Loaded { + loaded: string[]; +} + +function rank(query: string, names: string[]): Candidate[] { + return names.filter((name) => name.includes(query)).map((name) => ({ name, score: name.length })); +} + +function makeDefinition(names: string[]): Definition { + return { + name: "search", + async search(query: string) { + return { loaded: rank(query, names).map((candidate) => candidate.name) }; + }, + async execute(_id: string, query: string) { + return { + content: [{ type: "text", text: rank(query, names).map((c) => c.name).join(",") }], + details: { loaded: rank(query, names).map((c) => c.name) }, + }; + }, + }; +} + +// The same shape through a record of definitions, each with its own details. +type AnyDefinition = Definition; +function makeNamed(names: string[]): Record<"search", AnyDefinition> { + return { search: makeDefinition(names) }; +} + +const definition = makeDefinition(["find-file", "grep-tree", "read-file"]); +definition + .search("file") + .then((loaded) => { + console.log("search:", loaded.loaded.join(",")); + return definition.execute("call-1", "file"); + }) + .then((result) => { + console.log("execute:", result.content.length, result.content[0]!.type, result.details.loaded.join(",")); + return makeNamed(["read-file"]).search.execute("call-2", "file"); + }) + .then((second) => { + console.log("second:", second.content.length, second.details.loaded.join(",")); + }); diff --git a/tests/harness/corpus-costs/5799-async-return-record-literal-slot.ts.json b/tests/harness/corpus-costs/5799-async-return-record-literal-slot.ts.json new file mode 100644 index 000000000..4203ad979 --- /dev/null +++ b/tests/harness/corpus-costs/5799-async-return-record-literal-slot.ts.json @@ -0,0 +1 @@ +{ "plain": 1800, "san": 1900 }