mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-01 07:33:12 +08:00
Build a fresh object literal at an async record result
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<Name, ToolDefinition> 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0c6a6d1db5
commit
830ee3ddf3
@@ -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<unknown>` 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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Schema, Details>`
|
||||
// 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<Schema, Details>` 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<T>` 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
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/5799-async-return-record-literal-slot.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -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<Loaded>`) 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<D> {
|
||||
name: string;
|
||||
search(query: string): Promise<D>;
|
||||
execute(id: string, query: string): Promise<Result<D>>;
|
||||
}
|
||||
interface Result<D> {
|
||||
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<Loaded> {
|
||||
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<any>;
|
||||
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(","));
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 1800, "san": 1900 }
|
||||
Reference in New Issue
Block a user