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 }