@f5-sales-demo/xcsh 19.92.1 → 19.93.0
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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "19.
|
|
4
|
+
"version": "19.93.0",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -56,13 +56,13 @@
|
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@agentclientprotocol/sdk": "1.3.0",
|
|
58
58
|
"@mozilla/readability": "^0.6",
|
|
59
|
-
"@f5-sales-demo/xcsh-stats": "19.
|
|
60
|
-
"@f5-sales-demo/pi-agent-core": "19.
|
|
61
|
-
"@f5-sales-demo/pi-ai": "19.
|
|
62
|
-
"@f5-sales-demo/pi-natives": "19.
|
|
63
|
-
"@f5-sales-demo/pi-resource-management": "19.
|
|
64
|
-
"@f5-sales-demo/pi-tui": "19.
|
|
65
|
-
"@f5-sales-demo/pi-utils": "19.
|
|
59
|
+
"@f5-sales-demo/xcsh-stats": "19.93.0",
|
|
60
|
+
"@f5-sales-demo/pi-agent-core": "19.93.0",
|
|
61
|
+
"@f5-sales-demo/pi-ai": "19.93.0",
|
|
62
|
+
"@f5-sales-demo/pi-natives": "19.93.0",
|
|
63
|
+
"@f5-sales-demo/pi-resource-management": "19.93.0",
|
|
64
|
+
"@f5-sales-demo/pi-tui": "19.93.0",
|
|
65
|
+
"@f5-sales-demo/pi-utils": "19.93.0",
|
|
66
66
|
"@sinclair/typebox": "^0.34",
|
|
67
67
|
"@xterm/headless": "^6.0",
|
|
68
68
|
"ajv": "^8.20",
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
normalizeHostToolDefinitions,
|
|
11
11
|
RpcHostToolBridge,
|
|
12
12
|
} from "../host-tools";
|
|
13
|
+
import { extractReferences } from "../references";
|
|
13
14
|
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
|
|
14
15
|
import {
|
|
15
16
|
type ChatDelta,
|
|
@@ -17,7 +18,6 @@ import {
|
|
|
17
18
|
type ChatError,
|
|
18
19
|
type ChatErrorReason,
|
|
19
20
|
type ChatKeepalive,
|
|
20
|
-
type ChatReference,
|
|
21
21
|
type ChatRequest,
|
|
22
22
|
type Configure,
|
|
23
23
|
type ConfigureAck,
|
|
@@ -680,39 +680,6 @@ function composeBrowserPageContext(parts: string[], context: PageContextSnapshot
|
|
|
680
680
|
parts.push("---");
|
|
681
681
|
}
|
|
682
682
|
|
|
683
|
-
export function classifyReferenceKind(url: string): "doc" | "console" {
|
|
684
|
-
try {
|
|
685
|
-
const parsed = new URL(url);
|
|
686
|
-
if (/\.console\.ves\.volterra\.io$/.test(parsed.hostname)) return "console";
|
|
687
|
-
if (parsed.hostname === "docs.cloud.f5.com" || parsed.pathname.startsWith("/docs")) return "doc";
|
|
688
|
-
} catch {
|
|
689
|
-
/* malformed URL — default to doc */
|
|
690
|
-
}
|
|
691
|
-
return "doc";
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
function titleFromUrl(url: string): string {
|
|
695
|
-
try {
|
|
696
|
-
const parsed = new URL(url);
|
|
697
|
-
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
698
|
-
return segments.length > 0 ? segments[segments.length - 1] : parsed.hostname;
|
|
699
|
-
} catch {
|
|
700
|
-
return url;
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
/**
|
|
705
|
-
* Trailing characters a bare-URL match may greedily swallow at a markdown/prose
|
|
706
|
-
* boundary — markdown emphasis + code (`*_~` and backtick) and sentence/wrap
|
|
707
|
-
* punctuation. A real URL effectively never ends in these, so trimming them yields
|
|
708
|
-
* the intended link (e.g. `**https://…/llms.txt**` or a code-wrapped
|
|
709
|
-
* `` `https://…/llms.txt` `` → `https://…/llms.txt`). The markdown-link branch is
|
|
710
|
-
* bounded by its closing `)` and needs no trimming.
|
|
711
|
-
*/
|
|
712
|
-
function trimTrailingMarkup(url: string): string {
|
|
713
|
-
return url.replace(/[*_~`,.;:!?'")\]}>]+$/, "");
|
|
714
|
-
}
|
|
715
|
-
|
|
716
683
|
/** Panel-facing labels for provider-side tools, used for the activity rows (#2340). */
|
|
717
684
|
function serverToolLabels(toolName: string): { running: string; done: string } {
|
|
718
685
|
switch (toolName) {
|
|
@@ -724,47 +691,3 @@ function serverToolLabels(toolName: string): { running: string; done: string } {
|
|
|
724
691
|
return { running: `${toolName}: running`, done: toolName };
|
|
725
692
|
}
|
|
726
693
|
}
|
|
727
|
-
|
|
728
|
-
export function extractReferences(msg: AssistantMessage): ChatReference[] {
|
|
729
|
-
const refs: ChatReference[] = [];
|
|
730
|
-
const seen = new Set<string>();
|
|
731
|
-
|
|
732
|
-
// STRUCTURED CITATIONS FIRST (#2340): a provider-side search reports the real page title, so
|
|
733
|
-
// it beats the regex scrape below — which can only guess a title from the URL path, and finds
|
|
734
|
-
// nothing at all when the model cites a source without printing its URL in the prose. Done as
|
|
735
|
-
// its own pass so a citation in a later block still wins over a scrape in an earlier one.
|
|
736
|
-
for (const block of msg.content) {
|
|
737
|
-
if (block.type !== "text" || !block.citations) continue;
|
|
738
|
-
for (const citation of block.citations) {
|
|
739
|
-
const url = citation.url;
|
|
740
|
-
if (!url || seen.has(url)) continue;
|
|
741
|
-
seen.add(url);
|
|
742
|
-
refs.push({
|
|
743
|
-
kind: classifyReferenceKind(url),
|
|
744
|
-
title: citation.title?.trim() || titleFromUrl(url),
|
|
745
|
-
url,
|
|
746
|
-
});
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
for (const block of msg.content) {
|
|
751
|
-
if (block.type !== "text") continue;
|
|
752
|
-
|
|
753
|
-
const mdLinkRegex = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
|
|
754
|
-
for (let match = mdLinkRegex.exec(block.text); match !== null; match = mdLinkRegex.exec(block.text)) {
|
|
755
|
-
const [, title, url] = match;
|
|
756
|
-
if (seen.has(url)) continue;
|
|
757
|
-
seen.add(url);
|
|
758
|
-
refs.push({ kind: classifyReferenceKind(url), title, url });
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
const bareUrlRegex = /(?<!\()(https?:\/\/[^\s)>\]]+)/g;
|
|
762
|
-
for (let match = bareUrlRegex.exec(block.text); match !== null; match = bareUrlRegex.exec(block.text)) {
|
|
763
|
-
const url = trimTrailingMarkup(match[1]);
|
|
764
|
-
if (seen.has(url)) continue;
|
|
765
|
-
seen.add(url);
|
|
766
|
-
refs.push({ kind: classifyReferenceKind(url), title: titleFromUrl(url), url });
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
return refs;
|
|
770
|
-
}
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "19.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.93.0",
|
|
21
|
+
"commit": "212247c75cbe621350eda99e20ca752b44cd730f",
|
|
22
|
+
"shortCommit": "212247c",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-
|
|
24
|
+
"tag": "v19.93.0",
|
|
25
|
+
"commitDate": "2026-07-26T05:23:45Z",
|
|
26
|
+
"buildDate": "2026-07-26T05:48:10.440Z",
|
|
27
27
|
"dirty": true,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5-sales-demo/xcsh",
|
|
30
30
|
"repoSlug": "f5-sales-demo/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/212247c75cbe621350eda99e20ca752b44cd730f",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.93.0"
|
|
33
33
|
};
|
|
@@ -148,6 +148,22 @@ export const TERRAFORM_INDEX: TerraformIndex = {
|
|
|
148
148
|
"workload_flavor",
|
|
149
149
|
],
|
|
150
150
|
},
|
|
151
|
+
{
|
|
152
|
+
name: "Uncategorized",
|
|
153
|
+
slug: "uncategorized",
|
|
154
|
+
description: "Resources pending categorization",
|
|
155
|
+
resource_count: 8,
|
|
156
|
+
resources: [
|
|
157
|
+
"application_profiles",
|
|
158
|
+
"authorization_server",
|
|
159
|
+
"bot_infrastructure",
|
|
160
|
+
"domain",
|
|
161
|
+
"mitigated_domain",
|
|
162
|
+
"protected_application",
|
|
163
|
+
"protected_domain",
|
|
164
|
+
"registration_approval",
|
|
165
|
+
],
|
|
166
|
+
},
|
|
151
167
|
{
|
|
152
168
|
name: "DNS",
|
|
153
169
|
slug: "dns",
|
|
@@ -163,21 +179,6 @@ export const TERRAFORM_INDEX: TerraformIndex = {
|
|
|
163
179
|
"dns_zone",
|
|
164
180
|
],
|
|
165
181
|
},
|
|
166
|
-
{
|
|
167
|
-
name: "Uncategorized",
|
|
168
|
-
slug: "uncategorized",
|
|
169
|
-
description: "Resources pending categorization",
|
|
170
|
-
resource_count: 7,
|
|
171
|
-
resources: [
|
|
172
|
-
"application_profiles",
|
|
173
|
-
"authorization_server",
|
|
174
|
-
"bot_infrastructure",
|
|
175
|
-
"mitigated_domain",
|
|
176
|
-
"protected_application",
|
|
177
|
-
"protected_domain",
|
|
178
|
-
"registration_approval",
|
|
179
|
-
],
|
|
180
|
-
},
|
|
181
182
|
{
|
|
182
183
|
name: "API Security",
|
|
183
184
|
slug: "api-security",
|
|
@@ -185,13 +186,6 @@ export const TERRAFORM_INDEX: TerraformIndex = {
|
|
|
185
186
|
resource_count: 5,
|
|
186
187
|
resources: ["api_crawler", "api_definition", "api_discovery", "api_testing", "app_api_group"],
|
|
187
188
|
},
|
|
188
|
-
{
|
|
189
|
-
name: "Monitoring",
|
|
190
|
-
slug: "monitoring",
|
|
191
|
-
description: "Log receivers, alert policies, APM, and global logging configuration",
|
|
192
|
-
resource_count: 5,
|
|
193
|
-
resources: ["alert_receiver", "alert_template", "apm", "global_log_receiver", "log_receiver"],
|
|
194
|
-
},
|
|
195
189
|
{
|
|
196
190
|
name: "Applications",
|
|
197
191
|
slug: "applications",
|
|
@@ -213,6 +207,13 @@ export const TERRAFORM_INDEX: TerraformIndex = {
|
|
|
213
207
|
resource_count: 4,
|
|
214
208
|
resources: ["certificate", "certificate_chain", "crl", "trusted_ca_list"],
|
|
215
209
|
},
|
|
210
|
+
{
|
|
211
|
+
name: "Monitoring",
|
|
212
|
+
slug: "monitoring",
|
|
213
|
+
description: "Log receivers, alert policies, APM, and global logging configuration",
|
|
214
|
+
resource_count: 4,
|
|
215
|
+
resources: ["alert_receiver", "alert_template", "global_log_receiver", "log_receiver"],
|
|
216
|
+
},
|
|
216
217
|
{
|
|
217
218
|
name: "VPN",
|
|
218
219
|
slug: "vpn",
|
|
@@ -386,16 +387,6 @@ export const TERRAFORM_INDEX: TerraformIndex = {
|
|
|
386
387
|
},
|
|
387
388
|
import_syntax: "terraform import xcsh_api_testing.example namespace/name",
|
|
388
389
|
},
|
|
389
|
-
apm: {
|
|
390
|
-
category: "monitoring",
|
|
391
|
-
description: "New APM as a service with configured parameters",
|
|
392
|
-
required: ["name", "namespace"],
|
|
393
|
-
minimal_config: 'resource "xcsh_apm" "example" {\n name = "example-apm"\n namespace = "staging"\n}',
|
|
394
|
-
dependencies: {
|
|
395
|
-
requires: [],
|
|
396
|
-
},
|
|
397
|
-
import_syntax: "terraform import xcsh_apm.example namespace/name",
|
|
398
|
-
},
|
|
399
390
|
app_api_group: {
|
|
400
391
|
category: "api-security",
|
|
401
392
|
description: "App_api_group creates a new object in the storage backend for metadata.namespace",
|
|
@@ -531,7 +522,7 @@ export const TERRAFORM_INDEX: TerraformIndex = {
|
|
|
531
522
|
bgp_routing_policy: {
|
|
532
523
|
category: "security",
|
|
533
524
|
description:
|
|
534
|
-
"Bgp routing policy is a list of rules containing match criteria and action to be applied. these rules help
|
|
525
|
+
"Bgp routing policy is a list of rules containing match criteria and action to be applied. these rules help control routes which are imported or exported to bgp peers. configuration",
|
|
535
526
|
required: ["name", "namespace"],
|
|
536
527
|
minimal_config:
|
|
537
528
|
'resource "xcsh_bgp_routing_policy" "example" {\n name = "example-bgp-routing-policy"\n namespace = "staging"\n}',
|
|
@@ -857,6 +848,17 @@ export const TERRAFORM_INDEX: TerraformIndex = {
|
|
|
857
848
|
},
|
|
858
849
|
import_syntax: "terraform import xcsh_dns_zone.example namespace/name",
|
|
859
850
|
},
|
|
851
|
+
domain: {
|
|
852
|
+
category: "uncategorized",
|
|
853
|
+
description: "Allowed domain",
|
|
854
|
+
required: ["name", "namespace", "allowed_domain"],
|
|
855
|
+
minimal_config:
|
|
856
|
+
'resource "xcsh_domain" "example" {\n name = "example-domain"\n namespace = "staging"\n\n allowed_domain = "example-value"\n}',
|
|
857
|
+
dependencies: {
|
|
858
|
+
requires: [],
|
|
859
|
+
},
|
|
860
|
+
import_syntax: "terraform import xcsh_domain.example namespace/name",
|
|
861
|
+
},
|
|
860
862
|
endpoint: {
|
|
861
863
|
category: "load-balancing",
|
|
862
864
|
description: "Endpoint will create the object in the storage backend for namespace metadata.namespace",
|
|
@@ -1636,9 +1638,9 @@ export const TERRAFORM_INDEX: TerraformIndex = {
|
|
|
1636
1638
|
udp_loadbalancer: {
|
|
1637
1639
|
category: "load-balancing",
|
|
1638
1640
|
description: "Load balancing UDP traffic across origin pools",
|
|
1639
|
-
required: ["name", "namespace", "dns_volterra_managed", "
|
|
1641
|
+
required: ["name", "namespace", "dns_volterra_managed", "idle_timeout"],
|
|
1640
1642
|
minimal_config:
|
|
1641
|
-
'resource "xcsh_udp_loadbalancer" "example" {\n name = "example-udp-loadbalancer"\n namespace = "staging"\n\n domains
|
|
1643
|
+
'resource "xcsh_udp_loadbalancer" "example" {\n name = "example-udp-loadbalancer"\n namespace = "staging"\n\n domains = ["example-value"]\n dns_volterra_managed = true\n idle_timeout = 1\n}',
|
|
1642
1644
|
dependencies: {
|
|
1643
1645
|
requires: ["namespace", "origin_pool"],
|
|
1644
1646
|
},
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
RpcHostToolBridge,
|
|
25
25
|
} from "../../host-tools";
|
|
26
26
|
import { type Theme, theme } from "../../modes/theme/theme";
|
|
27
|
+
import { referencesEventFor } from "../../references";
|
|
27
28
|
import type { AgentSession } from "../../session/agent-session";
|
|
28
29
|
import { mapContextStatus, runWelcomeChecks } from "../components/welcome-checks";
|
|
29
30
|
import type {
|
|
@@ -497,6 +498,11 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|
|
497
498
|
// Output all agent events as JSON
|
|
498
499
|
session.subscribe(event => {
|
|
499
500
|
output(event);
|
|
501
|
+
// Citations for the host's Sources chips, mirroring `chat_done.references` on
|
|
502
|
+
// the WS bridge. The turn-boundary rule (an intermediate tool-use step also
|
|
503
|
+
// emits message_end) lives in the shared, tested `referencesEventFor` (#2420).
|
|
504
|
+
const refs = referencesEventFor(event);
|
|
505
|
+
if (refs) output(refs);
|
|
500
506
|
});
|
|
501
507
|
|
|
502
508
|
// Handle a single command
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Citation extraction — the "Sources" chips a surface shows under an answer.
|
|
3
|
+
*
|
|
4
|
+
* Neutral module ON PURPOSE. This used to live in `browser/chat-handler.ts`, whose
|
|
5
|
+
* 14 imports would have dragged the whole browser chat stack into the RPC path when
|
|
6
|
+
* `rpc-mode` needed the same extraction (#2420). One extractor, and one rule for
|
|
7
|
+
* "which assistant message ends a turn", shared by every transport — a client
|
|
8
|
+
* reimplementing either would drift (see the #2249 trailing-markup regression).
|
|
9
|
+
*/
|
|
10
|
+
import type { AssistantMessage } from "@f5-sales-demo/pi-ai";
|
|
11
|
+
import type { ChatReference } from "./browser/chat-protocol";
|
|
12
|
+
|
|
13
|
+
export function classifyReferenceKind(url: string): "doc" | "console" {
|
|
14
|
+
try {
|
|
15
|
+
const parsed = new URL(url);
|
|
16
|
+
if (/\.console\.ves\.volterra\.io$/.test(parsed.hostname)) return "console";
|
|
17
|
+
if (parsed.hostname === "docs.cloud.f5.com" || parsed.pathname.startsWith("/docs")) return "doc";
|
|
18
|
+
} catch {
|
|
19
|
+
/* malformed URL — default to doc */
|
|
20
|
+
}
|
|
21
|
+
return "doc";
|
|
22
|
+
}
|
|
23
|
+
function titleFromUrl(url: string): string {
|
|
24
|
+
try {
|
|
25
|
+
const parsed = new URL(url);
|
|
26
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
27
|
+
return segments.length > 0 ? segments[segments.length - 1] : parsed.hostname;
|
|
28
|
+
} catch {
|
|
29
|
+
return url;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Trailing characters a bare-URL match may greedily swallow at a markdown/prose
|
|
34
|
+
* boundary — markdown emphasis + code (`*_~` and backtick) and sentence/wrap
|
|
35
|
+
* punctuation. A real URL effectively never ends in these, so trimming them yields
|
|
36
|
+
* the intended link (e.g. `**https://…/llms.txt**` or a code-wrapped
|
|
37
|
+
* `` `https://…/llms.txt` `` → `https://…/llms.txt`). The markdown-link branch is
|
|
38
|
+
* bounded by its closing `)` and needs no trimming.
|
|
39
|
+
*/
|
|
40
|
+
function trimTrailingMarkup(url: string): string {
|
|
41
|
+
return url.replace(/[*_~`,.;:!?'")\]}>]+$/, "");
|
|
42
|
+
}
|
|
43
|
+
export function extractReferences(msg: AssistantMessage): ChatReference[] {
|
|
44
|
+
const refs: ChatReference[] = [];
|
|
45
|
+
const seen = new Set<string>();
|
|
46
|
+
|
|
47
|
+
// STRUCTURED CITATIONS FIRST (#2340): a provider-side search reports the real page title, so
|
|
48
|
+
// it beats the regex scrape below — which can only guess a title from the URL path, and finds
|
|
49
|
+
// nothing at all when the model cites a source without printing its URL in the prose. Done as
|
|
50
|
+
// its own pass so a citation in a later block still wins over a scrape in an earlier one.
|
|
51
|
+
for (const block of msg.content) {
|
|
52
|
+
if (block.type !== "text" || !block.citations) continue;
|
|
53
|
+
for (const citation of block.citations) {
|
|
54
|
+
const url = citation.url;
|
|
55
|
+
if (!url || seen.has(url)) continue;
|
|
56
|
+
seen.add(url);
|
|
57
|
+
refs.push({
|
|
58
|
+
kind: classifyReferenceKind(url),
|
|
59
|
+
title: citation.title?.trim() || titleFromUrl(url),
|
|
60
|
+
url,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const block of msg.content) {
|
|
66
|
+
if (block.type !== "text") continue;
|
|
67
|
+
|
|
68
|
+
const mdLinkRegex = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
|
|
69
|
+
for (let match = mdLinkRegex.exec(block.text); match !== null; match = mdLinkRegex.exec(block.text)) {
|
|
70
|
+
const [, title, url] = match;
|
|
71
|
+
if (seen.has(url)) continue;
|
|
72
|
+
seen.add(url);
|
|
73
|
+
refs.push({ kind: classifyReferenceKind(url), title, url });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const bareUrlRegex = /(?<!\()(https?:\/\/[^\s)>\]]+)/g;
|
|
77
|
+
for (let match = bareUrlRegex.exec(block.text); match !== null; match = bareUrlRegex.exec(block.text)) {
|
|
78
|
+
const url = trimTrailingMarkup(match[1]);
|
|
79
|
+
if (seen.has(url)) continue;
|
|
80
|
+
seen.add(url);
|
|
81
|
+
refs.push({ kind: classifyReferenceKind(url), title: titleFromUrl(url), url });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return refs;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The RPC stream's citation event, mirroring `chat_done.references` on the bridge. */
|
|
88
|
+
export interface RpcReferencesEvent {
|
|
89
|
+
type: "references";
|
|
90
|
+
references: ChatReference[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Decide whether an agent event should produce a citation event, and build it.
|
|
95
|
+
*
|
|
96
|
+
* Pure so the TURN-BOUNDARY rule is testable: an intermediate tool-use step also
|
|
97
|
+
* emits `message_end`, and treating that as terminal is the trap
|
|
98
|
+
* `chat-handler.ts` documents at length — it would publish the citations of a
|
|
99
|
+
* half-finished answer (and, on the bridge, end the turn early). Returns null for
|
|
100
|
+
* anything that is not a settled assistant message, and for a turn that cited
|
|
101
|
+
* nothing, so callers emit only real content.
|
|
102
|
+
*
|
|
103
|
+
* Typed by what it CONSUMES (a tagged event that may carry a message) rather than by
|
|
104
|
+
* one event union: the bridge and the RPC session stream different unions
|
|
105
|
+
* (`AgentEvent` vs `AgentSessionEvent`) and both must be able to call this.
|
|
106
|
+
*/
|
|
107
|
+
export function referencesEventFor(event: { type: string; message?: unknown }): RpcReferencesEvent | null {
|
|
108
|
+
if (event.type !== "message_end") return null;
|
|
109
|
+
const message = (event as { message?: { role?: string } }).message;
|
|
110
|
+
if (message?.role !== "assistant") return null;
|
|
111
|
+
const assistant = message as AssistantMessage;
|
|
112
|
+
// Intermediate tool-use step — the turn continues after the tool round-trip.
|
|
113
|
+
if (assistant.stopReason === "toolUse" || assistant.content.some(part => part.type === "toolCall")) return null;
|
|
114
|
+
const references = extractReferences(assistant);
|
|
115
|
+
return references.length > 0 ? { type: "references", references } : null;
|
|
116
|
+
}
|