@agent-native/core 0.132.0 → 0.132.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +43 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/action.ts +52 -11
- package/corpus/core/src/agent/production-agent.ts +192 -28
- package/corpus/core/src/chat-threads/store.ts +42 -0
- package/corpus/core/src/client/use-chat-threads.ts +76 -16
- package/corpus/core/src/server/agent-chat-plugin.ts +16 -1
- package/corpus/templates/clips/changelog/2026-07-30-meeting-microphone-transcription-works-reliably-from-the-fir.md +6 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +13 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +2 -0
- package/corpus/templates/clips/desktop/src-tauri/src/system_audio.rs +25 -132
- package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +5 -0
- package/corpus/templates/design/app/components/design/KeyboardShortcutsPanel.tsx +5 -3
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +23 -0
- package/corpus/templates/design/app/components/design/keyboard-shortcuts.ts +3 -0
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +11 -7
- package/corpus/templates/design/changelog/2026-07-30-fixed-the-keyboard-shortcuts-panel-not-opening-with-ctrl-shi.md +6 -0
- package/dist/action.d.ts.map +1 -1
- package/dist/action.js +41 -11
- package/dist/action.js.map +1 -1
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +177 -29
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/chat-threads/store.d.ts +13 -0
- package/dist/chat-threads/store.d.ts.map +1 -1
- package/dist/chat-threads/store.js +35 -0
- package/dist/chat-threads/store.js.map +1 -1
- package/dist/client/use-chat-threads.d.ts.map +1 -1
- package/dist/client/use-chat-threads.js +66 -16
- package/dist/client/use-chat-threads.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/mcp/screen-memory-stdio.d.ts +7 -7
- package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
- package/dist/notifications/routes.d.ts +6 -6
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +13 -2
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
- package/src/action.ts +52 -11
- package/src/agent/production-agent.ts +192 -28
- package/src/chat-threads/store.ts +42 -0
- package/src/client/use-chat-threads.ts +76 -16
- package/src/server/agent-chat-plugin.ts +16 -1
package/corpus/README.md
CHANGED
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# @agent-native/core
|
|
2
2
|
|
|
3
|
+
## 0.132.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 3aa3c49: Keep each resource's agent chat to itself instead of showing one chat everywhere.
|
|
8
|
+
|
|
9
|
+
A chat thread's `scope` carried two meanings at once: "general chat, visible in
|
|
10
|
+
every resource" and "nobody has told the server this thread's scope yet". Because
|
|
11
|
+
those were indistinguishable, a thread that lost its scope silently became a
|
|
12
|
+
permanent global chat — it followed the user into every design/deck/form, and
|
|
13
|
+
because an unscoped chat is allowed to stay visible, no per-resource chat was ever
|
|
14
|
+
started.
|
|
15
|
+
|
|
16
|
+
Two paths dropped the scope. The server created the row on the first message
|
|
17
|
+
without one (`persistSubmittedUserMessage`), even though the client already sends
|
|
18
|
+
it and `production-agent` had already normalized it onto
|
|
19
|
+
`RequestRunContext.chatScope` — nothing read that field. The client then asserted
|
|
20
|
+
`scope: null` on every save for any thread missing from its local list, which the
|
|
21
|
+
`PUT` applies unconditionally, cementing the null.
|
|
22
|
+
|
|
23
|
+
Now the run's scope is used when the row is created, a thread with no scope adopts
|
|
24
|
+
the scope of the resource it is used in (`resolveRunThreadScope`, which never
|
|
25
|
+
retags or clears an already-scoped thread), and the client only mirrors a scope it
|
|
26
|
+
actually knows. Adoption also heals threads already stored with `scope: null`, and
|
|
27
|
+
claims the row with a compare-and-set on the unscoped state so two workers racing
|
|
28
|
+
to adopt the same legacy thread cannot retag it to the wrong resource.
|
|
29
|
+
|
|
30
|
+
Scope now rides only on thread creation: a periodic save no longer sends it, so a
|
|
31
|
+
stale client guess cannot move an existing thread between resources, and
|
|
32
|
+
`detachThread` is the only client path that clears one. A restored active-chat
|
|
33
|
+
pointer is checked against the thread's real scope on a direct mount as well as
|
|
34
|
+
when moving between resources — and because the thread list is one page, a pointer
|
|
35
|
+
naming a thread the page did not reach is resolved by id rather than assumed to be
|
|
36
|
+
a never-messaged local tab.
|
|
37
|
+
|
|
38
|
+
Genuinely general chats are unaffected until they are used inside a resource.
|
|
39
|
+
|
|
40
|
+
## 0.132.1
|
|
41
|
+
|
|
42
|
+
### Patch Changes
|
|
43
|
+
|
|
44
|
+
- 548844d: Fix agent tool calls failing against discriminated-union action schemas. Gateway-supplied empty placeholders are now stripped from nested objects and union branches (not just top-level fields), `oneOf` validation errors report only the branch the discriminator selects, and the expected-signature hint expands array items and union branches so nested enums are spelled out.
|
|
45
|
+
|
|
3
46
|
## 0.132.0
|
|
4
47
|
|
|
5
48
|
### Minor Changes
|
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.132.
|
|
3
|
+
"version": "0.132.2",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
ActionTool,
|
|
9
9
|
AgentChatAttachment,
|
|
10
10
|
AgentChatEvent,
|
|
11
|
+
AgentNativeJsonSchema,
|
|
11
12
|
} from "./agent/types.js";
|
|
12
13
|
import { normalizeAuditConfig, resolveAuditAttach } from "./audit/config.js";
|
|
13
14
|
import type { ActionAuditConfig } from "./audit/types.js";
|
|
@@ -1445,6 +1446,52 @@ function coerceGatewayStringifiedArgs(
|
|
|
1445
1446
|
return out ?? args;
|
|
1446
1447
|
}
|
|
1447
1448
|
|
|
1449
|
+
// The enums and required members that actually explain a rejection are usually
|
|
1450
|
+
// nested inside `items`/`oneOf` (a discriminated union of operations), not on
|
|
1451
|
+
// the top-level property, so rendering only the top level prints `operations*:
|
|
1452
|
+
// array` — true and useless. Bounded depth keeps a deep schema from crowding out
|
|
1453
|
+
// the validation errors it is meant to explain.
|
|
1454
|
+
const MAX_SIGNATURE_DEPTH = 4;
|
|
1455
|
+
const MAX_SIGNATURE_LENGTH = 1200;
|
|
1456
|
+
|
|
1457
|
+
function describeSchemaType(
|
|
1458
|
+
spec: AgentNativeJsonSchema | undefined,
|
|
1459
|
+
depth: number,
|
|
1460
|
+
): string {
|
|
1461
|
+
if (!spec) return "any";
|
|
1462
|
+
if (spec.const !== undefined) return JSON.stringify(spec.const);
|
|
1463
|
+
if (Array.isArray(spec.enum)) {
|
|
1464
|
+
return spec.enum.map((value) => JSON.stringify(value)).join("|");
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
const branches = spec.oneOf ?? spec.anyOf;
|
|
1468
|
+
if (branches?.length) {
|
|
1469
|
+
if (depth >= MAX_SIGNATURE_DEPTH) return "object";
|
|
1470
|
+
return branches
|
|
1471
|
+
.map((branch) => describeSchemaType(branch, depth))
|
|
1472
|
+
.join(" | ");
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
if (spec.properties && Object.keys(spec.properties).length > 0) {
|
|
1476
|
+
if (depth >= MAX_SIGNATURE_DEPTH) return "object";
|
|
1477
|
+
const required = new Set(spec.required ?? []);
|
|
1478
|
+
const members = Object.entries(spec.properties)
|
|
1479
|
+
.map(
|
|
1480
|
+
([key, value]) =>
|
|
1481
|
+
`${key}${required.has(key) ? "*" : "?"}: ${describeSchemaType(value, depth + 1)}`,
|
|
1482
|
+
)
|
|
1483
|
+
.join(", ");
|
|
1484
|
+
return `{ ${members} }`;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
if (spec.items) {
|
|
1488
|
+
if (depth >= MAX_SIGNATURE_DEPTH) return "array";
|
|
1489
|
+
return `array<${describeSchemaType(spec.items, depth + 1)}>`;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
return Array.isArray(spec.type) ? spec.type.join("|") : (spec.type ?? "any");
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1448
1495
|
/**
|
|
1449
1496
|
* Compact signature of an action's parameters, e.g.
|
|
1450
1497
|
* `{ deckId*: string, operation*: "edit"|"replace", slideId?: string }` where
|
|
@@ -1469,19 +1516,13 @@ export function describeToolParameterSignature(
|
|
|
1469
1516
|
(key) => !only?.length || only.includes(key),
|
|
1470
1517
|
);
|
|
1471
1518
|
const sig = (keys.length ? keys : Object.keys(properties))
|
|
1472
|
-
.map(
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
? spec.enum.map((value) => JSON.stringify(value)).join("|")
|
|
1477
|
-
: Array.isArray(spec.type)
|
|
1478
|
-
? spec.type.join("|")
|
|
1479
|
-
: (spec.type ?? "any");
|
|
1480
|
-
return `${key}${mark}: ${type}`;
|
|
1481
|
-
})
|
|
1519
|
+
.map(
|
|
1520
|
+
(key) =>
|
|
1521
|
+
`${key}${required.has(key) ? "*" : "?"}: ${describeSchemaType(properties[key], 1)}`,
|
|
1522
|
+
)
|
|
1482
1523
|
.join(", ");
|
|
1483
1524
|
if (!sig) return null;
|
|
1484
|
-
return `{ ${sig.length >
|
|
1525
|
+
return `{ ${sig.length > MAX_SIGNATURE_LENGTH ? `${sig.slice(0, MAX_SIGNATURE_LENGTH)}…` : sig} }`;
|
|
1485
1526
|
}
|
|
1486
1527
|
|
|
1487
1528
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
|
|
3
|
-
import Ajv, { type ValidateFunction } from "ajv";
|
|
3
|
+
import Ajv, { type ErrorObject, type ValidateFunction } from "ajv";
|
|
4
4
|
import {
|
|
5
5
|
defineEventHandler,
|
|
6
6
|
getHeader,
|
|
@@ -3596,6 +3596,9 @@ const rawToolInputAjv = new Ajv({
|
|
|
3596
3596
|
coerceTypes: true,
|
|
3597
3597
|
useDefaults: false,
|
|
3598
3598
|
removeAdditional: false,
|
|
3599
|
+
// `parentSchema`/`data` on each error are what let us drop the branches of a
|
|
3600
|
+
// `oneOf` the caller never meant to match.
|
|
3601
|
+
verbose: true,
|
|
3599
3602
|
});
|
|
3600
3603
|
|
|
3601
3604
|
const rawToolInputValidatorCache = new WeakMap<object, ValidateFunction>();
|
|
@@ -3636,17 +3639,83 @@ function isStructurallyEmptyToolValue(value: unknown): boolean {
|
|
|
3636
3639
|
);
|
|
3637
3640
|
}
|
|
3638
3641
|
|
|
3642
|
+
function compileToolValueValidator(schema: object): ValidateFunction | null {
|
|
3643
|
+
const cached = optionalPlaceholderValidatorCache.get(schema);
|
|
3644
|
+
if (cached) return cached;
|
|
3645
|
+
try {
|
|
3646
|
+
const validator = optionalPlaceholderAjv.compile(schema);
|
|
3647
|
+
optionalPlaceholderValidatorCache.set(schema, validator);
|
|
3648
|
+
return validator;
|
|
3649
|
+
} catch {
|
|
3650
|
+
return null;
|
|
3651
|
+
}
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3639
3654
|
function schemaAcceptsToolValue(schema: object, value: unknown): boolean {
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3655
|
+
const validator = compileToolValueValidator(schema);
|
|
3656
|
+
return validator ? Boolean(validator(value)) : false;
|
|
3657
|
+
}
|
|
3658
|
+
|
|
3659
|
+
/**
|
|
3660
|
+
* True only when the sub-schema compiled AND rejected the value. A sub-schema
|
|
3661
|
+
* that cannot be compiled standalone — a `$ref` into the root `$defs`, say — is
|
|
3662
|
+
* unknown, not invalid; counting it as invalid would let one unreadable
|
|
3663
|
+
* sub-schema delete the caller's intentional empty values everywhere else in
|
|
3664
|
+
* the same object.
|
|
3665
|
+
*/
|
|
3666
|
+
function schemaRejectsToolValue(schema: object, value: unknown): boolean {
|
|
3667
|
+
const validator = compileToolValueValidator(schema);
|
|
3668
|
+
return validator ? !validator(value) : false;
|
|
3669
|
+
}
|
|
3670
|
+
|
|
3671
|
+
/** The `const`/single-value-`enum` a discriminated-union branch pins a key to. */
|
|
3672
|
+
function schemaDiscriminatorValue(
|
|
3673
|
+
schema: AgentNativeJsonSchema | undefined,
|
|
3674
|
+
): unknown {
|
|
3675
|
+
if (!schema) return undefined;
|
|
3676
|
+
if (schema.const !== undefined) return schema.const;
|
|
3677
|
+
if (Array.isArray(schema.enum) && schema.enum.length === 1) {
|
|
3678
|
+
return schema.enum[0];
|
|
3648
3679
|
}
|
|
3649
|
-
return
|
|
3680
|
+
return undefined;
|
|
3681
|
+
}
|
|
3682
|
+
|
|
3683
|
+
/**
|
|
3684
|
+
* Index of the `oneOf`/`anyOf` branch whose discriminator constants all match
|
|
3685
|
+
* `value`, or -1 when the union is not discriminated or nothing matches.
|
|
3686
|
+
*/
|
|
3687
|
+
function discriminatedBranchIndex(
|
|
3688
|
+
branches: readonly AgentNativeJsonSchema[],
|
|
3689
|
+
value: unknown,
|
|
3690
|
+
): number {
|
|
3691
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return -1;
|
|
3692
|
+
const record = value as Record<string, unknown>;
|
|
3693
|
+
return branches.findIndex((branch) => {
|
|
3694
|
+
const properties = branch.properties;
|
|
3695
|
+
if (!properties) return false;
|
|
3696
|
+
const discriminators = Object.entries(properties).filter(
|
|
3697
|
+
([, spec]) => schemaDiscriminatorValue(spec) !== undefined,
|
|
3698
|
+
);
|
|
3699
|
+
if (discriminators.length === 0) return false;
|
|
3700
|
+
return discriminators.every(
|
|
3701
|
+
([key, spec]) => record[key] === schemaDiscriminatorValue(spec),
|
|
3702
|
+
);
|
|
3703
|
+
});
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3706
|
+
/**
|
|
3707
|
+
* The object schema that actually governs `value` — the union branch its
|
|
3708
|
+
* discriminator selects, or the schema itself. Undefined when a union cannot be
|
|
3709
|
+
* resolved, since guessing a branch would strip the wrong keys.
|
|
3710
|
+
*/
|
|
3711
|
+
function resolveObjectSchemaBranch(
|
|
3712
|
+
schema: RawJsonSchema,
|
|
3713
|
+
value: unknown,
|
|
3714
|
+
): RawJsonSchema | undefined {
|
|
3715
|
+
const branches = schema.oneOf ?? schema.anyOf;
|
|
3716
|
+
if (!branches?.length) return schema;
|
|
3717
|
+
const index = discriminatedBranchIndex(branches, value);
|
|
3718
|
+
return index >= 0 ? branches[index] : undefined;
|
|
3650
3719
|
}
|
|
3651
3720
|
|
|
3652
3721
|
/**
|
|
@@ -3654,35 +3723,82 @@ function schemaAcceptsToolValue(schema: object, value: unknown): boolean {
|
|
|
3654
3723
|
* instead of omitting it. One schema-invalid empty value proves that pattern;
|
|
3655
3724
|
* only then strip the other optional empty/default sentinels from the call.
|
|
3656
3725
|
* Calls whose empty values are all schema-valid keep their intentional clears.
|
|
3726
|
+
*
|
|
3727
|
+
* The proof is evaluated per object, and the walk descends through array items
|
|
3728
|
+
* and discriminated-union branches: gateways pre-fill the leaf object they are
|
|
3729
|
+
* building (`operations[0].fields`), which a top-level-only sweep never reaches.
|
|
3657
3730
|
*/
|
|
3658
|
-
function
|
|
3731
|
+
function stripOptionalToolPlaceholders(
|
|
3659
3732
|
schema: RawJsonSchema | undefined,
|
|
3660
|
-
|
|
3661
|
-
): {
|
|
3662
|
-
if (!schema
|
|
3663
|
-
|
|
3733
|
+
value: unknown,
|
|
3734
|
+
): { value: unknown; changed: boolean } {
|
|
3735
|
+
if (!schema) return { value, changed: false };
|
|
3736
|
+
|
|
3737
|
+
if (Array.isArray(value)) {
|
|
3738
|
+
const itemSchema = schema.items;
|
|
3739
|
+
if (!itemSchema) return { value, changed: false };
|
|
3740
|
+
let changed = false;
|
|
3741
|
+
const items = value.map((item) => {
|
|
3742
|
+
const result = stripOptionalToolPlaceholders(itemSchema, item);
|
|
3743
|
+
if (result.changed) changed = true;
|
|
3744
|
+
return result.value;
|
|
3745
|
+
});
|
|
3746
|
+
return changed
|
|
3747
|
+
? { value: items, changed: true }
|
|
3748
|
+
: { value, changed: false };
|
|
3664
3749
|
}
|
|
3665
|
-
|
|
3750
|
+
|
|
3751
|
+
if (!value || typeof value !== "object") return { value, changed: false };
|
|
3752
|
+
|
|
3753
|
+
const objectSchema = resolveObjectSchemaBranch(schema, value);
|
|
3754
|
+
const properties = objectSchema?.properties;
|
|
3755
|
+
if (!properties) return { value, changed: false };
|
|
3666
3756
|
|
|
3667
3757
|
const required = new Set(
|
|
3668
|
-
Array.isArray(
|
|
3758
|
+
Array.isArray(objectSchema.required) ? objectSchema.required : [],
|
|
3669
3759
|
);
|
|
3760
|
+
const record = value as Record<string, unknown>;
|
|
3670
3761
|
const placeholders: string[] = [];
|
|
3671
3762
|
let hasSchemaInvalidPlaceholder = false;
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3763
|
+
let normalized: Record<string, unknown> | null = null;
|
|
3764
|
+
|
|
3765
|
+
for (const [key, entry] of Object.entries(record)) {
|
|
3766
|
+
const propertySchema = properties[key];
|
|
3675
3767
|
if (!propertySchema || typeof propertySchema !== "object") continue;
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3768
|
+
if (!required.has(key) && isStructurallyEmptyToolValue(entry)) {
|
|
3769
|
+
placeholders.push(key);
|
|
3770
|
+
if (schemaRejectsToolValue(propertySchema, entry)) {
|
|
3771
|
+
hasSchemaInvalidPlaceholder = true;
|
|
3772
|
+
}
|
|
3773
|
+
continue;
|
|
3774
|
+
}
|
|
3775
|
+
const nested = stripOptionalToolPlaceholders(propertySchema, entry);
|
|
3776
|
+
if (nested.changed) {
|
|
3777
|
+
normalized ??= { ...record };
|
|
3778
|
+
normalized[key] = nested.value;
|
|
3679
3779
|
}
|
|
3680
3780
|
}
|
|
3681
|
-
if (!hasSchemaInvalidPlaceholder) return { input, changed: false };
|
|
3682
3781
|
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3782
|
+
if (hasSchemaInvalidPlaceholder) {
|
|
3783
|
+
normalized ??= { ...record };
|
|
3784
|
+
for (const key of placeholders) delete normalized[key];
|
|
3785
|
+
}
|
|
3786
|
+
|
|
3787
|
+
return normalized
|
|
3788
|
+
? { value: normalized, changed: true }
|
|
3789
|
+
: { value, changed: false };
|
|
3790
|
+
}
|
|
3791
|
+
|
|
3792
|
+
function normalizeOptionalToolPlaceholders(
|
|
3793
|
+
schema: RawJsonSchema | undefined,
|
|
3794
|
+
input: unknown,
|
|
3795
|
+
): { input: unknown; changed: boolean } {
|
|
3796
|
+
if (!schema?.properties || !input || typeof input !== "object") {
|
|
3797
|
+
return { input, changed: false };
|
|
3798
|
+
}
|
|
3799
|
+
if (Array.isArray(input)) return { input, changed: false };
|
|
3800
|
+
const result = stripOptionalToolPlaceholders(schema, input);
|
|
3801
|
+
return { input: result.value, changed: result.changed };
|
|
3686
3802
|
}
|
|
3687
3803
|
|
|
3688
3804
|
/**
|
|
@@ -3754,6 +3870,54 @@ function shouldValidateRawToolParameters(entry: ActionEntry): boolean {
|
|
|
3754
3870
|
return !maybeSchema?.["~standard"] && Boolean(entry.tool.parameters);
|
|
3755
3871
|
}
|
|
3756
3872
|
|
|
3873
|
+
/**
|
|
3874
|
+
* With `allErrors`, a failing union reports every branch, so a five-branch
|
|
3875
|
+
* union answers "your `patch-deck-fields` op has a bad enum" with eight
|
|
3876
|
+
* complaints about `slideId`/`orderedIds` from the four branches the caller
|
|
3877
|
+
* never meant. The model reads the loudest, wrong advice and re-sends the same
|
|
3878
|
+
* arguments until the identical-error breaker ends the turn. When the
|
|
3879
|
+
* discriminator names a branch, report only that branch's errors.
|
|
3880
|
+
*
|
|
3881
|
+
* Both union keywords are load-bearing for the same Zod schema: Zod v4's own
|
|
3882
|
+
* `toJSONSchema` emits `oneOf` for a discriminated union, while the manual
|
|
3883
|
+
* fallback converter in `action.ts` emits `anyOf`.
|
|
3884
|
+
*
|
|
3885
|
+
* Scoped by `instancePath` as well as `schemaPath`: every element of an array
|
|
3886
|
+
* shares one `items` schema, so `operations[0]` and `operations[1]` produce
|
|
3887
|
+
* branch errors under the same `schemaPath` and only the instance path says
|
|
3888
|
+
* which element they belong to.
|
|
3889
|
+
*/
|
|
3890
|
+
function isWithinInstancePath(candidate: string, root: string): boolean {
|
|
3891
|
+
return candidate === root || candidate.startsWith(`${root}/`);
|
|
3892
|
+
}
|
|
3893
|
+
|
|
3894
|
+
function narrowUnionBranchErrors(
|
|
3895
|
+
errors: ErrorObject[] | null | undefined,
|
|
3896
|
+
): ErrorObject[] | null | undefined {
|
|
3897
|
+
if (!errors?.length) return errors;
|
|
3898
|
+
let kept = errors;
|
|
3899
|
+
for (const error of errors) {
|
|
3900
|
+
const keyword = error.keyword;
|
|
3901
|
+
if (keyword !== "oneOf" && keyword !== "anyOf") continue;
|
|
3902
|
+
const branches = (error.parentSchema as RawJsonSchema | undefined)?.[
|
|
3903
|
+
keyword
|
|
3904
|
+
];
|
|
3905
|
+
if (!branches?.length) continue;
|
|
3906
|
+
const index = discriminatedBranchIndex(branches, error.data);
|
|
3907
|
+
if (index < 0) continue;
|
|
3908
|
+
const branchPrefix = `${error.schemaPath}/`;
|
|
3909
|
+
kept = kept.filter((candidate) => {
|
|
3910
|
+
if (candidate === error) return false;
|
|
3911
|
+
if (!isWithinInstancePath(candidate.instancePath, error.instancePath)) {
|
|
3912
|
+
return true;
|
|
3913
|
+
}
|
|
3914
|
+
if (!candidate.schemaPath.startsWith(branchPrefix)) return true;
|
|
3915
|
+
return candidate.schemaPath.startsWith(`${branchPrefix}${index}/`);
|
|
3916
|
+
});
|
|
3917
|
+
}
|
|
3918
|
+
return kept.length ? kept : errors;
|
|
3919
|
+
}
|
|
3920
|
+
|
|
3757
3921
|
function validateRawToolInput(
|
|
3758
3922
|
entry: ActionEntry,
|
|
3759
3923
|
input: unknown,
|
|
@@ -3768,7 +3932,7 @@ function validateRawToolInput(
|
|
|
3768
3932
|
return `tool schema is invalid: ${sanitizeToolErrorValue(err)}`;
|
|
3769
3933
|
}
|
|
3770
3934
|
if (validator(input === undefined ? {} : input)) return null;
|
|
3771
|
-
return rawToolInputAjv.errorsText(validator.errors, {
|
|
3935
|
+
return rawToolInputAjv.errorsText(narrowUnionBranchErrors(validator.errors), {
|
|
3772
3936
|
separator: "; ",
|
|
3773
3937
|
dataVar: "input",
|
|
3774
3938
|
});
|
|
@@ -766,6 +766,48 @@ export async function searchThreads(
|
|
|
766
766
|
.filter((r): r is ChatThreadSummary => r !== null);
|
|
767
767
|
}
|
|
768
768
|
|
|
769
|
+
/**
|
|
770
|
+
* Scope a thread should carry after a run inside a resource: adopt when it has
|
|
771
|
+
* none, otherwise keep what it has. An unscoped thread reads as general, and a
|
|
772
|
+
* general chat renders inside every resource — so never retag, never clear.
|
|
773
|
+
*/
|
|
774
|
+
export function resolveRunThreadScope(
|
|
775
|
+
existing: ChatThreadScope | null,
|
|
776
|
+
incoming: ChatThreadScope | null | undefined,
|
|
777
|
+
): ChatThreadScope | null {
|
|
778
|
+
if (existing) return existing;
|
|
779
|
+
return incoming ?? null;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Claim an unscoped thread for `scope`, returning the scope it actually ends up
|
|
784
|
+
* with. `withThreadDataLock` only serializes one process, so two workers can
|
|
785
|
+
* both read the same unscoped row; the `scope_type IS NULL` guard makes the
|
|
786
|
+
* first writer win and the loser reports the winner instead of retagging.
|
|
787
|
+
*/
|
|
788
|
+
export async function adoptThreadScopeIfUnscoped(
|
|
789
|
+
id: string,
|
|
790
|
+
scope: ChatThreadScope,
|
|
791
|
+
): Promise<ChatThreadScope | null> {
|
|
792
|
+
await ensureTable();
|
|
793
|
+
const client = getDbExec();
|
|
794
|
+
const result = await client.execute({
|
|
795
|
+
sql: `UPDATE chat_threads SET scope_type = ?, scope_id = ?, scope_label = ?, updated_at = ? WHERE id = ? AND scope_type IS NULL`,
|
|
796
|
+
args: [
|
|
797
|
+
scope.type,
|
|
798
|
+
scope.id,
|
|
799
|
+
scope.label ?? null,
|
|
800
|
+
Math.max(Date.now(), 1),
|
|
801
|
+
id,
|
|
802
|
+
],
|
|
803
|
+
});
|
|
804
|
+
if (result.rowsAffected > 0) {
|
|
805
|
+
emitChatThreadChange(id);
|
|
806
|
+
return scope;
|
|
807
|
+
}
|
|
808
|
+
return (await getThread(id))?.scope ?? null;
|
|
809
|
+
}
|
|
810
|
+
|
|
769
811
|
/**
|
|
770
812
|
* Detach or rebind a chat's scope. Used by the UI's "Detach from <resource>"
|
|
771
813
|
* action and by templates that need to retag a chat after a rename. Pass
|
|
@@ -91,6 +91,25 @@ async function fetchThreadListPage(
|
|
|
91
91
|
});
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Look up one thread the list page did not carry. Distinguishes the three states
|
|
96
|
+
* the caller must not collapse: the thread (found), `null` (the server denies it
|
|
97
|
+
* exists), and `undefined` (unreachable — nothing was learned).
|
|
98
|
+
*/
|
|
99
|
+
async function fetchThreadById(
|
|
100
|
+
apiUrl: string,
|
|
101
|
+
id: string,
|
|
102
|
+
): Promise<ChatThreadSummary | null | undefined> {
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch(`${apiUrl}/threads/${encodeURIComponent(id)}`);
|
|
105
|
+
if (res.status === 404) return null;
|
|
106
|
+
if (!res.ok) return undefined;
|
|
107
|
+
return (await res.json()) as ChatThreadSummary;
|
|
108
|
+
} catch {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
94
113
|
function emitThreadsUpdated() {
|
|
95
114
|
if (typeof window === "undefined") return;
|
|
96
115
|
window.dispatchEvent(new CustomEvent(THREADS_UPDATED_EVENT));
|
|
@@ -411,6 +430,17 @@ export function useChatThreads(
|
|
|
411
430
|
} catch {
|
|
412
431
|
nextActiveThreadId = null;
|
|
413
432
|
}
|
|
433
|
+
// Only a known mismatch disqualifies the pointer — an unresolved scope
|
|
434
|
+
// must not be read as "belongs here".
|
|
435
|
+
if (nextActiveThreadId) {
|
|
436
|
+
const savedScope = readKnownThreadScope(nextActiveThreadId);
|
|
437
|
+
if (
|
|
438
|
+
savedScope !== undefined &&
|
|
439
|
+
!threadCanStayVisibleInScope(savedScope, scopeRef.current)
|
|
440
|
+
) {
|
|
441
|
+
nextActiveThreadId = null;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
414
444
|
if (!nextActiveThreadId && autoCreate) {
|
|
415
445
|
nextActiveThreadId = createLocalThreadId();
|
|
416
446
|
newlyCreatedRef.current.add(nextActiveThreadId);
|
|
@@ -583,7 +613,7 @@ export function useChatThreads(
|
|
|
583
613
|
|
|
584
614
|
(async () => {
|
|
585
615
|
const loadedThreads = await fetchThreads();
|
|
586
|
-
const
|
|
616
|
+
const restoredId = activeThreadIdRef.current;
|
|
587
617
|
if (loadedThreads === undefined) {
|
|
588
618
|
// Thread-list fetch failed. Do not reclassify a saved id as a new
|
|
589
619
|
// optimistic tab; AssistantChat should still get a chance to restore
|
|
@@ -591,8 +621,40 @@ export function useChatThreads(
|
|
|
591
621
|
setIsLoading(false);
|
|
592
622
|
return;
|
|
593
623
|
}
|
|
624
|
+
// Exempts route-owned threads (the URL names what the user asked for) and
|
|
625
|
+
// ids this client generated, which have never reached the server.
|
|
626
|
+
const lookupRestored = Boolean(
|
|
627
|
+
restoredId &&
|
|
628
|
+
!routeControlsActiveThread &&
|
|
629
|
+
!newlyCreatedRef.current.has(restoredId),
|
|
630
|
+
);
|
|
631
|
+
const restoredOnPage = restoredId
|
|
632
|
+
? loadedThreads.find((t) => t.id === restoredId)
|
|
633
|
+
: undefined;
|
|
634
|
+
// One page, so absence from it is not absence from the server — this is what
|
|
635
|
+
// separates an older real thread from the ghost tab reclassified below.
|
|
636
|
+
const restoredThread =
|
|
637
|
+
lookupRestored && !restoredOnPage
|
|
638
|
+
? await fetchThreadById(apiUrl, restoredId!)
|
|
639
|
+
: restoredOnPage;
|
|
640
|
+
if (restoredThread === undefined && lookupRestored && !restoredOnPage) {
|
|
641
|
+
// Lookup unreachable. Reclassifying now would stamp this thread with the
|
|
642
|
+
// current scope on a guess; leave it untouched for the next mount.
|
|
643
|
+
setIsLoading(false);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const restoredBelongsElsewhere = Boolean(
|
|
647
|
+
restoredThread &&
|
|
648
|
+
!threadCanStayVisibleInScope(
|
|
649
|
+
restoredThread.scope ?? null,
|
|
650
|
+
scopeRef.current,
|
|
651
|
+
),
|
|
652
|
+
);
|
|
653
|
+
if (restoredBelongsElsewhere) setActiveThreadId(null);
|
|
654
|
+
const savedId = restoredBelongsElsewhere ? null : restoredId;
|
|
594
655
|
const loadedHasSavedId = Boolean(
|
|
595
|
-
savedId &&
|
|
656
|
+
savedId &&
|
|
657
|
+
(restoredThread || loadedThreads.some((t) => t.id === savedId)),
|
|
596
658
|
);
|
|
597
659
|
const savedIdCameFromRoute =
|
|
598
660
|
Boolean(savedId) &&
|
|
@@ -646,6 +708,7 @@ export function useChatThreads(
|
|
|
646
708
|
setIsLoading(false);
|
|
647
709
|
})();
|
|
648
710
|
}, [
|
|
711
|
+
apiUrl,
|
|
649
712
|
fetchThreads,
|
|
650
713
|
addOptimisticThread,
|
|
651
714
|
autoCreate,
|
|
@@ -948,12 +1011,9 @@ export function useChatThreads(
|
|
|
948
1011
|
[apiUrl, clearUserRenamedThread, createThread],
|
|
949
1012
|
);
|
|
950
1013
|
|
|
951
|
-
//
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
// detachThread / fetchThreads — saveThreadData just mirrors it on
|
|
955
|
-
// every save so the server eventually catches up after
|
|
956
|
-
// persistSubmittedUserMessage creates the row sans scope.
|
|
1014
|
+
// Reads scope through refs so this callback survives every setThreads. Scope
|
|
1015
|
+
// rides only on creation: a periodic save must never move an existing thread
|
|
1016
|
+
// between resources, however stale this client's guess is.
|
|
957
1017
|
const saveThreadData = useCallback(
|
|
958
1018
|
async (
|
|
959
1019
|
id: string,
|
|
@@ -968,7 +1028,7 @@ export function useChatThreads(
|
|
|
968
1028
|
try {
|
|
969
1029
|
const { titleSource, ...threadDataPayload } = data;
|
|
970
1030
|
const localThread = threadsRef.current.find((t) => t.id === id);
|
|
971
|
-
const
|
|
1031
|
+
const knownScope = readKnownThreadScope(id) ?? null;
|
|
972
1032
|
const preserveUserTitle = userRenamedThreadIdsRef.current.has(id);
|
|
973
1033
|
const title = nextThreadTitle(
|
|
974
1034
|
localThread?.title,
|
|
@@ -977,11 +1037,7 @@ export function useChatThreads(
|
|
|
977
1037
|
titleSource,
|
|
978
1038
|
{ preserveUserTitle },
|
|
979
1039
|
);
|
|
980
|
-
const payload = {
|
|
981
|
-
...threadDataPayload,
|
|
982
|
-
title,
|
|
983
|
-
scope: localScope,
|
|
984
|
-
};
|
|
1040
|
+
const payload = { ...threadDataPayload, title };
|
|
985
1041
|
let response = await fetch(
|
|
986
1042
|
`${apiUrl}/threads/${encodeURIComponent(id)}`,
|
|
987
1043
|
{
|
|
@@ -997,7 +1053,11 @@ export function useChatThreads(
|
|
|
997
1053
|
const created = await fetch(`${apiUrl}/threads`, {
|
|
998
1054
|
method: "POST",
|
|
999
1055
|
headers: { "Content-Type": "application/json" },
|
|
1000
|
-
body: JSON.stringify({
|
|
1056
|
+
body: JSON.stringify({
|
|
1057
|
+
id,
|
|
1058
|
+
title,
|
|
1059
|
+
...(knownScope ? { scope: knownScope } : {}),
|
|
1060
|
+
}),
|
|
1001
1061
|
});
|
|
1002
1062
|
if (!created.ok) return;
|
|
1003
1063
|
response = await fetch(
|
|
@@ -1059,7 +1119,7 @@ export function useChatThreads(
|
|
|
1059
1119
|
});
|
|
1060
1120
|
} catch {}
|
|
1061
1121
|
},
|
|
1062
|
-
[apiUrl],
|
|
1122
|
+
[apiUrl, readKnownThreadScope],
|
|
1063
1123
|
);
|
|
1064
1124
|
|
|
1065
1125
|
const generateTitle = useCallback(
|
|
@@ -107,10 +107,12 @@ import type {
|
|
|
107
107
|
import { readAppStateForCurrentTab } from "../application-state/script-helpers.js";
|
|
108
108
|
import { runChatThreadDataMigrations } from "../chat-threads/migrations.js";
|
|
109
109
|
import {
|
|
110
|
+
adoptThreadScopeIfUnscoped,
|
|
110
111
|
createThread,
|
|
111
112
|
forkThread,
|
|
112
113
|
getThread,
|
|
113
114
|
registerChatThreadsShareable,
|
|
115
|
+
resolveRunThreadScope,
|
|
114
116
|
resolveThreadAccess,
|
|
115
117
|
listThreads,
|
|
116
118
|
searchThreads,
|
|
@@ -2538,11 +2540,16 @@ export function createAgentChatPlugin(
|
|
|
2538
2540
|
getRequestRunContext()?.owner ?? getRequestUserEmail();
|
|
2539
2541
|
if (!ownerEmail) return;
|
|
2540
2542
|
|
|
2543
|
+
const runScope = getRequestRunContext()?.chatScope ?? null;
|
|
2544
|
+
|
|
2541
2545
|
await withThreadDataLock(threadId, async () => {
|
|
2542
2546
|
let thread = await getThread(threadId);
|
|
2543
2547
|
if (!thread) {
|
|
2544
2548
|
try {
|
|
2545
|
-
thread = await createThread(ownerEmail, {
|
|
2549
|
+
thread = await createThread(ownerEmail, {
|
|
2550
|
+
id: threadId,
|
|
2551
|
+
scope: runScope,
|
|
2552
|
+
});
|
|
2546
2553
|
} catch {
|
|
2547
2554
|
thread = await getThread(threadId);
|
|
2548
2555
|
}
|
|
@@ -2566,6 +2573,14 @@ export function createAgentChatPlugin(
|
|
|
2566
2573
|
});
|
|
2567
2574
|
}
|
|
2568
2575
|
|
|
2576
|
+
const nextScope = resolveRunThreadScope(thread.scope, runScope);
|
|
2577
|
+
if (nextScope && nextScope !== thread.scope) {
|
|
2578
|
+
thread = {
|
|
2579
|
+
...thread,
|
|
2580
|
+
scope: await adoptThreadScopeIfUnscoped(threadId, nextScope),
|
|
2581
|
+
};
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2569
2584
|
let repo: any;
|
|
2570
2585
|
try {
|
|
2571
2586
|
repo = JSON.parse(thread.threadData || "{}");
|