Skip to content

Commit c29c241

Browse files
committed
feat: add opt-in feedback transport
1 parent ccfff7c commit c29c241

3 files changed

Lines changed: 94 additions & 0 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { ContextFeedbackConfig } from "../core/types.js";
2+
import type { ContextFeedback } from "./types.js";
3+
4+
export type ContextFeedbackTransportStatus = "disabled" | "sent" | "failed";
5+
6+
export interface ContextFeedbackTransportResult {
7+
status: ContextFeedbackTransportStatus;
8+
endpoint?: string;
9+
error?: string;
10+
}
11+
12+
export async function submitContextFeedback(
13+
feedback: ContextFeedback,
14+
config: ContextFeedbackConfig,
15+
options: { timeoutMs?: number; fetcher?: typeof fetch } = {}
16+
): Promise<ContextFeedbackTransportResult> {
17+
if (!config.enabled || !config.network || !config.telemetry || !config.endpoint) {
18+
return { status: "disabled", ...(config.endpoint ? { endpoint: config.endpoint } : {}) };
19+
}
20+
const fetcher = options.fetcher ?? globalThis.fetch;
21+
if (!fetcher) return { status: "failed", endpoint: config.endpoint, error: "The runtime does not provide fetch." };
22+
const controller = new AbortController();
23+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 10_000);
24+
try {
25+
const response = await fetcher(config.endpoint, {
26+
method: "POST",
27+
headers: { "content-type": "application/json" },
28+
body: JSON.stringify(feedback),
29+
signal: controller.signal
30+
});
31+
if (!response.ok) return { status: "failed", endpoint: config.endpoint, error: "Feedback endpoint returned HTTP " + response.status + "." };
32+
return { status: "sent", endpoint: config.endpoint };
33+
} catch (error) {
34+
return { status: "failed", endpoint: config.endpoint, error: error instanceof Error ? error.message : String(error) };
35+
} finally {
36+
clearTimeout(timeout);
37+
}
38+
}

src/context-registry/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ export * from "./annotations.js";
1616
export * from "./feedback.js";
1717
export * from "./feedback-store.js";
1818
export * from "./feedback-stats.js";
19+
export * from "./feedback-transport.js";
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import { createContextFeedback } from "../src/context-registry/feedback.js";
4+
import { submitContextFeedback } from "../src/context-registry/feedback-transport.js";
5+
6+
const feedback = createContextFeedback({
7+
repository: "C:/work/project",
8+
entryId: "official/payments",
9+
source: "official",
10+
version: "2.0.0",
11+
revision: 1,
12+
target: "entry",
13+
label: "useful"
14+
});
15+
16+
test("feedback transport is offline by default and does not call fetch", async () => {
17+
let calls = 0;
18+
const result = await submitContextFeedback(
19+
feedback,
20+
{ enabled: true, telemetry: false, network: false, useLocalQualitySignals: false },
21+
{ fetcher: async () => { calls += 1; throw new Error("must not call"); } }
22+
);
23+
assert.equal(result.status, "disabled");
24+
assert.equal(calls, 0);
25+
});
26+
27+
test("feedback transport sends only explicit safe metadata", async () => {
28+
let body = "";
29+
const result = await submitContextFeedback(
30+
feedback,
31+
{ enabled: true, telemetry: true, network: true, useLocalQualitySignals: false, endpoint: "https://feedback.example.test/v1" },
32+
{
33+
fetcher: async (_input, init) => {
34+
body = String(init?.body);
35+
return new Response(null, { status: 204 });
36+
}
37+
}
38+
);
39+
assert.equal(result.status, "sent");
40+
const payload = JSON.parse(body) as Record<string, unknown>;
41+
assert.equal(payload.entryId, "official/payments");
42+
assert.equal("task" in payload, false);
43+
assert.equal("content" in payload, false);
44+
assert.equal("repository" in payload, false);
45+
});
46+
47+
test("network failure is returned without throwing", async () => {
48+
const result = await submitContextFeedback(
49+
feedback,
50+
{ enabled: true, telemetry: true, network: true, useLocalQualitySignals: false, endpoint: "https://feedback.example.test/v1" },
51+
{ fetcher: async () => { throw new Error("offline"); } }
52+
);
53+
assert.equal(result.status, "failed");
54+
assert.match(result.error ?? "", /offline/);
55+
});

0 commit comments

Comments
 (0)