@checkstack/ai-backend 0.1.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/CHANGELOG.md +97 -0
- package/drizzle/0000_productive_jackpot.sql +26 -0
- package/drizzle/0001_puzzling_purple_man.sql +26 -0
- package/drizzle/0002_sparkling_paper_doll.sql +15 -0
- package/drizzle/0003_married_senator_kelly.sql +1 -0
- package/drizzle/0004_crazy_miek.sql +2 -0
- package/drizzle/0005_tearful_randall_flagg.sql +1 -0
- package/drizzle/meta/0000_snapshot.json +232 -0
- package/drizzle/meta/0001_snapshot.json +434 -0
- package/drizzle/meta/0002_snapshot.json +551 -0
- package/drizzle/meta/0003_snapshot.json +557 -0
- package/drizzle/meta/0004_snapshot.json +573 -0
- package/drizzle/meta/0005_snapshot.json +574 -0
- package/drizzle/meta/_journal.json +48 -0
- package/drizzle.config.ts +7 -0
- package/package.json +42 -0
- package/src/agent-runner.test.ts +262 -0
- package/src/agent-runner.ts +262 -0
- package/src/chat/agent-loop.test.ts +119 -0
- package/src/chat/agent-loop.ts +73 -0
- package/src/chat/auto-apply.test.ts +237 -0
- package/src/chat/chat-handler.ts +111 -0
- package/src/chat/chat-service.streamturn.test.ts +417 -0
- package/src/chat/chat-service.test.ts +250 -0
- package/src/chat/chat-service.ts +923 -0
- package/src/chat/classifier-service.ts +64 -0
- package/src/chat/classifier.logic.test.ts +92 -0
- package/src/chat/classifier.logic.ts +71 -0
- package/src/chat/conversation-store.it.test.ts +203 -0
- package/src/chat/conversation-store.test.ts +248 -0
- package/src/chat/conversation-store.ts +237 -0
- package/src/chat/decision.logic.test.ts +45 -0
- package/src/chat/decision.logic.ts +54 -0
- package/src/chat/llm-provider.test.ts +63 -0
- package/src/chat/llm-provider.ts +67 -0
- package/src/chat/model-error.logic.test.ts +60 -0
- package/src/chat/model-error.logic.ts +65 -0
- package/src/chat/normalize-messages.logic.test.ts +101 -0
- package/src/chat/normalize-messages.logic.ts +65 -0
- package/src/chat/permission-mode.logic.test.ts +70 -0
- package/src/chat/permission-mode.logic.ts +45 -0
- package/src/chat/read-invoker.ts +72 -0
- package/src/chat/replay.test.ts +174 -0
- package/src/chat/scrub-content.test.ts +183 -0
- package/src/chat/scrub-content.ts +154 -0
- package/src/chat/sdk-tools.test.ts +168 -0
- package/src/chat/sdk-tools.ts +181 -0
- package/src/chat/title-service.test.ts +146 -0
- package/src/chat/title-service.ts +111 -0
- package/src/chat/title.logic.test.ts +98 -0
- package/src/chat/title.logic.ts +102 -0
- package/src/extension-points.ts +41 -0
- package/src/generated/docs-index.ts +3020 -0
- package/src/hardening/handler-authz.test.ts +282 -0
- package/src/hardening/no-secret-leak.test.ts +303 -0
- package/src/hooks.ts +33 -0
- package/src/index.ts +542 -0
- package/src/mcp/connection-registry.test.ts +25 -0
- package/src/mcp/connection-registry.ts +54 -0
- package/src/mcp/mcp-conformance.it.test.ts +128 -0
- package/src/mcp/server.test.ts +285 -0
- package/src/mcp/server.ts +300 -0
- package/src/mcp/tool-invoker.ts +65 -0
- package/src/openai-provider.test.ts +64 -0
- package/src/openai-provider.ts +146 -0
- package/src/projection.test.ts +97 -0
- package/src/projection.ts +132 -0
- package/src/propose-apply/args-hash.test.ts +26 -0
- package/src/propose-apply/args-hash.ts +30 -0
- package/src/propose-apply/service.test.ts +423 -0
- package/src/propose-apply/service.ts +419 -0
- package/src/propose-apply/store.test.ts +136 -0
- package/src/propose-apply/store.ts +224 -0
- package/src/propose-apply/token.test.ts +52 -0
- package/src/propose-apply/token.ts +71 -0
- package/src/rate-limit/spend-ledger.it.test.ts +224 -0
- package/src/rate-limit/spend-ledger.test.ts +176 -0
- package/src/rate-limit/spend-ledger.ts +162 -0
- package/src/rate-limit/tool-budget.it.test.ts +173 -0
- package/src/rate-limit/tool-budget.test.ts +58 -0
- package/src/rate-limit/tool-budget.ts +107 -0
- package/src/registry-wiring.test.ts +131 -0
- package/src/registry-wiring.ts +68 -0
- package/src/resolver.test.ts +156 -0
- package/src/resolver.ts +78 -0
- package/src/router.test.ts +78 -0
- package/src/router.ts +345 -0
- package/src/schema.ts +284 -0
- package/src/serializer.test.ts +88 -0
- package/src/serializer.ts +42 -0
- package/src/tool-registry.ts +58 -0
- package/src/tools/composite-tools.ts +24 -0
- package/src/tools/docs-tools.test.ts +150 -0
- package/src/tools/docs-tools.ts +115 -0
- package/src/tools/probe-url.test.ts +51 -0
- package/src/tools/probe-url.ts +146 -0
- package/src/tools/rank-docs.test.ts +153 -0
- package/src/tools/rank-docs.ts +209 -0
- package/src/tools/script-context-extract.test.ts +93 -0
- package/src/tools/script-context-extract.ts +283 -0
- package/src/tools/ssrf-guard.test.ts +69 -0
- package/src/tools/ssrf-guard.ts +108 -0
- package/src/tools/tool-set.e2e.test.ts +64 -0
- package/src/user-rpc-client.test.ts +45 -0
- package/src/user-rpc-client.ts +60 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ModelMessage } from "ai";
|
|
3
|
+
import { normalizeModelMessages } from "./normalize-messages.logic";
|
|
4
|
+
|
|
5
|
+
describe("normalizeModelMessages", () => {
|
|
6
|
+
test("leaves a well-formed alternating history untouched", () => {
|
|
7
|
+
const input: ModelMessage[] = [
|
|
8
|
+
{ role: "user", content: "hi" },
|
|
9
|
+
{ role: "assistant", content: "hello" },
|
|
10
|
+
{ role: "user", content: "list incidents" },
|
|
11
|
+
];
|
|
12
|
+
expect(normalizeModelMessages(input)).toEqual(input);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("merges consecutive user messages (the failed-turn cascade)", () => {
|
|
16
|
+
// A failed turn persists no assistant reply, so retries pile up `user` rows.
|
|
17
|
+
const input: ModelMessage[] = [
|
|
18
|
+
{ role: "user", content: "first try" },
|
|
19
|
+
{ role: "user", content: "second try" },
|
|
20
|
+
{ role: "user", content: "third try" },
|
|
21
|
+
];
|
|
22
|
+
expect(normalizeModelMessages(input)).toEqual([
|
|
23
|
+
{ role: "user", content: "first try\n\nsecond try\n\nthird try" },
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("merges consecutive assistant messages", () => {
|
|
28
|
+
const input: ModelMessage[] = [
|
|
29
|
+
{ role: "user", content: "hi" },
|
|
30
|
+
{ role: "assistant", content: "part one" },
|
|
31
|
+
{ role: "assistant", content: "part two" },
|
|
32
|
+
];
|
|
33
|
+
expect(normalizeModelMessages(input)).toEqual([
|
|
34
|
+
{ role: "user", content: "hi" },
|
|
35
|
+
{ role: "assistant", content: "part one\n\npart two" },
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("drops empty / whitespace-only text rows, then merges across the gap", () => {
|
|
40
|
+
const input: ModelMessage[] = [
|
|
41
|
+
{ role: "user", content: "before" },
|
|
42
|
+
{ role: "assistant", content: " " },
|
|
43
|
+
{ role: "user", content: "after" },
|
|
44
|
+
];
|
|
45
|
+
// The blank assistant row is dropped, leaving two user rows that merge.
|
|
46
|
+
expect(normalizeModelMessages(input)).toEqual([
|
|
47
|
+
{ role: "user", content: "before\n\nafter" },
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("keeps structured (tool-call / tool-result) content verbatim", () => {
|
|
52
|
+
const input: ModelMessage[] = [
|
|
53
|
+
{ role: "user", content: "run it" },
|
|
54
|
+
{
|
|
55
|
+
role: "assistant",
|
|
56
|
+
content: [
|
|
57
|
+
{
|
|
58
|
+
type: "tool-call",
|
|
59
|
+
toolCallId: "c1",
|
|
60
|
+
toolName: "listIncidents",
|
|
61
|
+
input: {},
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
role: "tool",
|
|
67
|
+
content: [
|
|
68
|
+
{
|
|
69
|
+
type: "tool-result",
|
|
70
|
+
toolCallId: "c1",
|
|
71
|
+
toolName: "listIncidents",
|
|
72
|
+
output: { type: "json", value: { count: 0 } },
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
{ role: "assistant", content: "no incidents" },
|
|
77
|
+
];
|
|
78
|
+
// Structured rows are never merged or dropped (would orphan a tool pair).
|
|
79
|
+
expect(normalizeModelMessages(input)).toEqual(input);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("strips a leading non-user message (corruption guard)", () => {
|
|
83
|
+
const input: ModelMessage[] = [
|
|
84
|
+
{ role: "assistant", content: "stray leading assistant" },
|
|
85
|
+
{ role: "user", content: "real start" },
|
|
86
|
+
];
|
|
87
|
+
expect(normalizeModelMessages(input)).toEqual([
|
|
88
|
+
{ role: "user", content: "real start" },
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a trailing user message always survives (never empties the array)", () => {
|
|
93
|
+
const input: ModelMessage[] = [
|
|
94
|
+
{ role: "assistant", content: "" },
|
|
95
|
+
{ role: "user", content: "hello" },
|
|
96
|
+
];
|
|
97
|
+
const out = normalizeModelMessages(input);
|
|
98
|
+
expect(out.length).toBeGreaterThan(0);
|
|
99
|
+
expect(out.at(-1)).toEqual({ role: "user", content: "hello" });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ModelMessage } from "ai";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Defensive normalization of the model message history before it is sent to the
|
|
5
|
+
* provider. Strict OpenAI-compatible providers (notably DeepSeek) reject a
|
|
6
|
+
* history that does not strictly alternate user/assistant, or that contains an
|
|
7
|
+
* empty-content message, with HTTP 400 `invalid_prompt`.
|
|
8
|
+
*
|
|
9
|
+
* Two real corruptions this guards against:
|
|
10
|
+
* - A FAILED turn persists no assistant reply (`onFinish` never runs on error),
|
|
11
|
+
* so each retry appends another `user` row, leaving consecutive `user`
|
|
12
|
+
* messages. Without this, ONE provider hiccup would brick the whole
|
|
13
|
+
* conversation permanently (every later message also 400s).
|
|
14
|
+
* - An assistant turn that produced only a tool call (no text) can persist an
|
|
15
|
+
* empty-text row.
|
|
16
|
+
*
|
|
17
|
+
* Rules (pure, total - never throws):
|
|
18
|
+
* 1. Drop messages whose content is an empty / whitespace-only STRING. Messages
|
|
19
|
+
* with structured content (tool-call / tool-result parts) are ALWAYS kept -
|
|
20
|
+
* they arrive as valid assistant+tool pairs from replay and dropping one
|
|
21
|
+
* half would orphan the other.
|
|
22
|
+
* 2. Merge consecutive same-role STRING messages (user or assistant) into a
|
|
23
|
+
* single message joined by a blank line, so the roles alternate.
|
|
24
|
+
* 3. Drop any leading non-`user` messages: a valid sequence (the system prompt
|
|
25
|
+
* is sent separately) must start with a user message. The turn always
|
|
26
|
+
* appends the new user message last, so a trailing user message is
|
|
27
|
+
* guaranteed and this can never empty the array.
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeModelMessages(
|
|
30
|
+
messages: ModelMessage[],
|
|
31
|
+
): ModelMessage[] {
|
|
32
|
+
const out: ModelMessage[] = [];
|
|
33
|
+
for (const message of messages) {
|
|
34
|
+
const text = typeof message.content === "string" ? message.content : null;
|
|
35
|
+
|
|
36
|
+
// Rule 1: drop empty/whitespace-only text rows.
|
|
37
|
+
if (text !== null && text.trim() === "") continue;
|
|
38
|
+
|
|
39
|
+
// Rule 2: merge with the previous row when both are same-role plain text.
|
|
40
|
+
const last = out.at(-1);
|
|
41
|
+
if (
|
|
42
|
+
last !== undefined &&
|
|
43
|
+
text !== null &&
|
|
44
|
+
typeof last.content === "string" &&
|
|
45
|
+
last.role === message.role &&
|
|
46
|
+
(message.role === "user" || message.role === "assistant")
|
|
47
|
+
) {
|
|
48
|
+
const merged = `${last.content}\n\n${text}`;
|
|
49
|
+
out[out.length - 1] =
|
|
50
|
+
message.role === "user"
|
|
51
|
+
? { role: "user", content: merged }
|
|
52
|
+
: { role: "assistant", content: merged };
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
out.push(message);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Rule 3: strip any leading non-user rows (corruption / a dangling assistant).
|
|
60
|
+
while (out.length > 0 && out[0].role !== "user") {
|
|
61
|
+
out.shift();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { AiPermissionMode, AiToolEffect } from "@checkstack/ai-common";
|
|
3
|
+
import { decideToolDisposition } from "./permission-mode.logic";
|
|
4
|
+
|
|
5
|
+
const MODES: AiPermissionMode[] = ["approve", "auto"];
|
|
6
|
+
const EFFECTS: AiToolEffect[] = ["read", "mutate", "destructive"];
|
|
7
|
+
|
|
8
|
+
describe("decideToolDisposition — 3-tier gating", () => {
|
|
9
|
+
test("read ALWAYS auto-runs, in BOTH modes (mode never gates reads)", () => {
|
|
10
|
+
for (const mode of MODES) {
|
|
11
|
+
expect(decideToolDisposition({ effect: "read", mode })).toBe("auto-run");
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("mutate INHERITS the mode: auto -> auto-apply, approve -> propose", () => {
|
|
16
|
+
expect(decideToolDisposition({ effect: "mutate", mode: "auto" })).toBe(
|
|
17
|
+
"auto-apply",
|
|
18
|
+
);
|
|
19
|
+
expect(decideToolDisposition({ effect: "mutate", mode: "approve" })).toBe(
|
|
20
|
+
"propose",
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("SECURITY INVARIANT: destructive can NEVER auto-apply", () => {
|
|
25
|
+
test("destructive ALWAYS proposes, in BOTH modes", () => {
|
|
26
|
+
for (const mode of MODES) {
|
|
27
|
+
expect(decideToolDisposition({ effect: "destructive", mode })).toBe(
|
|
28
|
+
"propose",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("NO permission-mode value changes a destructive tool's requirement for a human apply", () => {
|
|
34
|
+
// The mode is the ONLY parameter that could differ; assert it has no
|
|
35
|
+
// effect on a destructive tool's disposition. If a future change let the
|
|
36
|
+
// mode leak into the destructive branch, the two values would diverge.
|
|
37
|
+
const approveDisposition = decideToolDisposition({
|
|
38
|
+
effect: "destructive",
|
|
39
|
+
mode: "approve",
|
|
40
|
+
});
|
|
41
|
+
const autoDisposition = decideToolDisposition({
|
|
42
|
+
effect: "destructive",
|
|
43
|
+
mode: "auto",
|
|
44
|
+
});
|
|
45
|
+
expect(autoDisposition).toBe(approveDisposition);
|
|
46
|
+
expect(autoDisposition).toBe("propose");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("no (effect, mode) pair routes a destructive tool to auto-apply", () => {
|
|
50
|
+
for (const mode of MODES) {
|
|
51
|
+
expect(
|
|
52
|
+
decideToolDisposition({ effect: "destructive", mode }),
|
|
53
|
+
).not.toBe("auto-apply");
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("auto-apply is reachable ONLY via (mutate, auto)", () => {
|
|
58
|
+
const autoApplyPairs: Array<{ effect: AiToolEffect; mode: AiPermissionMode }> =
|
|
59
|
+
[];
|
|
60
|
+
for (const effect of EFFECTS) {
|
|
61
|
+
for (const mode of MODES) {
|
|
62
|
+
if (decideToolDisposition({ effect, mode }) === "auto-apply") {
|
|
63
|
+
autoApplyPairs.push({ effect, mode });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
expect(autoApplyPairs).toEqual([{ effect: "mutate", mode: "auto" }]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AiToolEffect } from "@checkstack/ai-common";
|
|
2
|
+
import type { AiPermissionMode } from "@checkstack/ai-common";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The disposition the agent loop bakes into a tool's `execute`, decided purely
|
|
6
|
+
* from the tool's `effect` and the conversation's permission mode. This is the
|
|
7
|
+
* single source of truth for the 3-tier gating model (Phase 4) and is kept
|
|
8
|
+
* DOM-free / dependency-free so it is exhaustively unit-testable.
|
|
9
|
+
*
|
|
10
|
+
* - `auto-run` -> run the read directly (read tools, BOTH modes).
|
|
11
|
+
* - `auto-apply` -> propose + apply SERVER-SIDE in one shot, no human click
|
|
12
|
+
* (mutate tools, `auto` mode ONLY).
|
|
13
|
+
* - `propose` -> propose + return a confirm card the human must `applyTool`
|
|
14
|
+
* (mutate tools in `approve` mode; ALL destructive tools in
|
|
15
|
+
* BOTH modes).
|
|
16
|
+
*/
|
|
17
|
+
export type ToolDisposition = "auto-run" | "auto-apply" | "propose";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Decide a tool's disposition from its effect + the conversation's mode.
|
|
21
|
+
*
|
|
22
|
+
* SECURITY INVARIANT (structurally enforced here): `destructive` ALWAYS returns
|
|
23
|
+
* `propose`, regardless of `mode`. The `mode` parameter is consulted ONLY for
|
|
24
|
+
* the `mutate` branch, so there is no `(effect, mode)` pair that lets a
|
|
25
|
+
* destructive tool reach `auto-apply`. The accompanying tests assert that no
|
|
26
|
+
* mode value changes a destructive tool's `propose` disposition.
|
|
27
|
+
*/
|
|
28
|
+
export function decideToolDisposition({
|
|
29
|
+
effect,
|
|
30
|
+
mode,
|
|
31
|
+
}: {
|
|
32
|
+
effect: AiToolEffect;
|
|
33
|
+
mode: AiPermissionMode;
|
|
34
|
+
}): ToolDisposition {
|
|
35
|
+
// Tier 1: reads ALWAYS auto-run, in BOTH modes. Mode never gates reads.
|
|
36
|
+
if (effect === "read") return "auto-run";
|
|
37
|
+
|
|
38
|
+
// Tier 3: destructive ALWAYS requires a human apply, in BOTH modes. The mode
|
|
39
|
+
// is NEVER consulted here -> destructive can never auto-apply.
|
|
40
|
+
if (effect === "destructive") return "propose";
|
|
41
|
+
|
|
42
|
+
// Tier 2: mutate INHERITS the mode. `auto` auto-applies server-side; `approve`
|
|
43
|
+
// surfaces a confirm card. This is the ONLY branch the mode governs.
|
|
44
|
+
return mode === "auto" ? "auto-apply" : "propose";
|
|
45
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createORPCClient } from "@orpc/client";
|
|
2
|
+
import { RPCLink } from "@orpc/client/fetch";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Runs a chat read-tool's SOURCE oRPC procedure by re-entering the live router
|
|
6
|
+
* AS THE LOGGED-IN USER, so handler-side authorization runs exactly as for any
|
|
7
|
+
* other caller (decision §1.5 — the model is an untrusted caller that merely
|
|
8
|
+
* picks arguments; it never bypasses authz).
|
|
9
|
+
*
|
|
10
|
+
* Mirrors the MCP `tool-invoker`, but forwards the chat request's OWN auth
|
|
11
|
+
* (session cookie and/or bearer) to the loopback endpoint instead of an OAuth
|
|
12
|
+
* bearer. The API route re-authenticates that request to the SAME principal the
|
|
13
|
+
* chat handler resolved, then runs `autoAuthMiddleware`. We deliberately do NOT
|
|
14
|
+
* use the trusted service client (that would skip the user's authz).
|
|
15
|
+
*/
|
|
16
|
+
export interface ChatReadInvoker {
|
|
17
|
+
invoke(args: {
|
|
18
|
+
pluginId: string;
|
|
19
|
+
procedureKey: string;
|
|
20
|
+
input: unknown;
|
|
21
|
+
/** Auth headers forwarded verbatim from the chat request (cookie/bearer). */
|
|
22
|
+
forwardHeaders: Record<string, string>;
|
|
23
|
+
}): Promise<unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type LoopbackClient = Record<
|
|
27
|
+
string,
|
|
28
|
+
Record<string, (input: unknown) => Promise<unknown>>
|
|
29
|
+
>;
|
|
30
|
+
|
|
31
|
+
export function createChatReadInvoker({
|
|
32
|
+
internalUrl,
|
|
33
|
+
}: {
|
|
34
|
+
internalUrl: string;
|
|
35
|
+
}): ChatReadInvoker {
|
|
36
|
+
return {
|
|
37
|
+
async invoke({ pluginId, procedureKey, input, forwardHeaders }) {
|
|
38
|
+
const link = new RPCLink({
|
|
39
|
+
url: `${internalUrl}/api`,
|
|
40
|
+
headers: forwardHeaders,
|
|
41
|
+
});
|
|
42
|
+
const client = createORPCClient(link) as LoopbackClient;
|
|
43
|
+
const pluginClient = client[pluginId];
|
|
44
|
+
if (!pluginClient) {
|
|
45
|
+
throw new Error(`No RPC client for plugin "${pluginId}".`);
|
|
46
|
+
}
|
|
47
|
+
const procedure = pluginClient[procedureKey];
|
|
48
|
+
if (typeof procedure !== "function") {
|
|
49
|
+
throw new TypeError(
|
|
50
|
+
`Procedure "${pluginId}.${procedureKey}" is not callable.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return procedure(input);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Extract the auth headers to forward from the incoming chat request: the
|
|
60
|
+
* session cookie and/or the bearer Authorization header. Only these are
|
|
61
|
+
* forwarded — never arbitrary client headers.
|
|
62
|
+
*/
|
|
63
|
+
export function forwardableAuthHeaders(
|
|
64
|
+
req: Request,
|
|
65
|
+
): Record<string, string> {
|
|
66
|
+
const headers: Record<string, string> = {};
|
|
67
|
+
const cookie = req.headers.get("cookie");
|
|
68
|
+
if (cookie) headers.cookie = cookie;
|
|
69
|
+
const auth = req.headers.get("authorization");
|
|
70
|
+
if (auth) headers.authorization = auth;
|
|
71
|
+
return headers;
|
|
72
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ModelMessage } from "ai";
|
|
3
|
+
import { toModelMessages } from "./chat-service";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* TOOL-MESSAGE REPLAY (Phase 6) — a RESUMED multi-turn conversation must replay
|
|
7
|
+
* the full prior TOOL-CALL history to the model, not just assistant text.
|
|
8
|
+
*
|
|
9
|
+
* `toModelMessages` reconstructs a persisted `ai_messages` row into AI-SDK
|
|
10
|
+
* `ModelMessage`s. When a row carries `modelMessages` (the canonical
|
|
11
|
+
* `ResponseMessage[]` the SDK produced — assistant tool-call parts + tool-result
|
|
12
|
+
* parts), they are replayed VERBATIM, so the model sees the prior tool
|
|
13
|
+
* interaction on the next turn. Legacy text-only rows still replay as text.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A persisted assistant row with a full tool round-trip (what onFinish stores). */
|
|
17
|
+
const toolRoundTrip: Array<Record<string, unknown>> = [
|
|
18
|
+
{
|
|
19
|
+
role: "assistant",
|
|
20
|
+
content: [
|
|
21
|
+
{ type: "text", text: "Let me check open incidents." },
|
|
22
|
+
{
|
|
23
|
+
type: "tool-call",
|
|
24
|
+
toolCallId: "tc1",
|
|
25
|
+
toolName: "incident.list",
|
|
26
|
+
input: { status: "open" },
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
role: "tool",
|
|
32
|
+
content: [
|
|
33
|
+
{
|
|
34
|
+
type: "tool-result",
|
|
35
|
+
toolCallId: "tc1",
|
|
36
|
+
toolName: "incident.list",
|
|
37
|
+
output: { type: "json", value: { rows: [{ id: 7 }] } },
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
role: "assistant",
|
|
43
|
+
content: [{ type: "text", text: "There is 1 open incident (#7)." }],
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
describe("toModelMessages: full tool-call history replay", () => {
|
|
48
|
+
test("an assistant row WITH modelMessages replays the assistant + tool messages verbatim", () => {
|
|
49
|
+
const replayed = toModelMessages({
|
|
50
|
+
role: "assistant",
|
|
51
|
+
content: { text: "There is 1 open incident (#7)." },
|
|
52
|
+
modelMessages: toolRoundTrip,
|
|
53
|
+
});
|
|
54
|
+
// One row expands into THREE model messages (assistant, tool, assistant) —
|
|
55
|
+
// the prior tool interaction is fully reconstructed for the model.
|
|
56
|
+
expect(replayed).toHaveLength(3);
|
|
57
|
+
expect(replayed.map((m) => m.role)).toEqual([
|
|
58
|
+
"assistant",
|
|
59
|
+
"tool",
|
|
60
|
+
"assistant",
|
|
61
|
+
]);
|
|
62
|
+
// The tool-call + tool-result parts survive (not just text).
|
|
63
|
+
const assistant = replayed[0] as ModelMessage & {
|
|
64
|
+
content: Array<Record<string, unknown>>;
|
|
65
|
+
};
|
|
66
|
+
expect(assistant.content[1]?.type).toBe("tool-call");
|
|
67
|
+
expect(assistant.content[1]?.toolCallId).toBe("tc1");
|
|
68
|
+
const tool = replayed[1] as ModelMessage & {
|
|
69
|
+
content: Array<Record<string, unknown>>;
|
|
70
|
+
};
|
|
71
|
+
expect(tool.content[0]?.type).toBe("tool-result");
|
|
72
|
+
expect(tool.content[0]?.toolCallId).toBe("tc1");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a user row replays as a plain user text message", () => {
|
|
76
|
+
const replayed = toModelMessages({
|
|
77
|
+
role: "user",
|
|
78
|
+
content: { text: "what changed in the last hour?" },
|
|
79
|
+
modelMessages: null,
|
|
80
|
+
});
|
|
81
|
+
expect(replayed).toEqual([
|
|
82
|
+
{ role: "user", content: "what changed in the last hour?" },
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a LEGACY assistant row (text only, no modelMessages) still replays as text", () => {
|
|
87
|
+
// Rows written before the replay column existed have modelMessages = null.
|
|
88
|
+
const replayed = toModelMessages({
|
|
89
|
+
role: "assistant",
|
|
90
|
+
content: { text: "a deploy at 14:02" },
|
|
91
|
+
modelMessages: null,
|
|
92
|
+
});
|
|
93
|
+
expect(replayed).toEqual([
|
|
94
|
+
{ role: "assistant", content: "a deploy at 14:02" },
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a fully-malformed modelMessages array falls back to the text content", () => {
|
|
99
|
+
const replayed = toModelMessages({
|
|
100
|
+
role: "assistant",
|
|
101
|
+
content: { text: "fallback text" },
|
|
102
|
+
// entries with no/invalid role are skipped; an all-bad array falls back.
|
|
103
|
+
modelMessages: [{ nonsense: true }, { role: "banana", content: [] }],
|
|
104
|
+
});
|
|
105
|
+
expect(replayed).toEqual([
|
|
106
|
+
{ role: "assistant", content: "fallback text" },
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a PARTIALLY-corrupt modelMessages array falls back to text (all-or-nothing, no dangling pair)", () => {
|
|
111
|
+
// The first two entries are a valid assistant tool-call + tool-result pair,
|
|
112
|
+
// but the third (the follow-up assistant message) is corrupt. Dropping only
|
|
113
|
+
// the bad entry would keep a dangling tool-call/result the provider rejects;
|
|
114
|
+
// the row must instead fall back ENTIRELY to its text representation.
|
|
115
|
+
const replayed = toModelMessages({
|
|
116
|
+
role: "assistant",
|
|
117
|
+
content: { text: "There is 1 open incident (#7)." },
|
|
118
|
+
modelMessages: [
|
|
119
|
+
toolRoundTrip[0], // valid assistant tool-call
|
|
120
|
+
toolRoundTrip[1], // valid tool-result for tc1
|
|
121
|
+
{ role: 42, content: [] }, // corrupt: role is not a string
|
|
122
|
+
],
|
|
123
|
+
});
|
|
124
|
+
// NOT a partial [assistant, tool] — the whole row degrades to text.
|
|
125
|
+
expect(replayed).toEqual([
|
|
126
|
+
{ role: "assistant", content: "There is 1 open incident (#7)." },
|
|
127
|
+
]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("a row missing the tool-result half of a pair falls back to text, never an orphaned tool-call", () => {
|
|
131
|
+
const replayed = toModelMessages({
|
|
132
|
+
role: "assistant",
|
|
133
|
+
content: { text: "checked incidents" },
|
|
134
|
+
modelMessages: [
|
|
135
|
+
toolRoundTrip[0], // assistant tool-call (tc1)
|
|
136
|
+
{ type: "tool-result" }, // corrupt: no role -> invalidates the row
|
|
137
|
+
],
|
|
138
|
+
});
|
|
139
|
+
expect(replayed).toEqual([
|
|
140
|
+
{ role: "assistant", content: "checked incidents" },
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("a standalone tool row with no modelMessages is skipped (no dangling tool result)", () => {
|
|
145
|
+
const replayed = toModelMessages({
|
|
146
|
+
role: "tool",
|
|
147
|
+
content: { text: "" },
|
|
148
|
+
modelMessages: null,
|
|
149
|
+
});
|
|
150
|
+
expect(replayed).toEqual([]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a multi-turn transcript reconstructs in order across rows", () => {
|
|
154
|
+
const rows = [
|
|
155
|
+
{ role: "user", content: { text: "list incidents" }, modelMessages: null },
|
|
156
|
+
{
|
|
157
|
+
role: "assistant",
|
|
158
|
+
content: { text: "1 open (#7)" },
|
|
159
|
+
modelMessages: toolRoundTrip,
|
|
160
|
+
},
|
|
161
|
+
{ role: "user", content: { text: "ack it" }, modelMessages: null },
|
|
162
|
+
];
|
|
163
|
+
const all: ModelMessage[] = [];
|
|
164
|
+
for (const row of rows) all.push(...toModelMessages(row));
|
|
165
|
+
// user, (assistant, tool, assistant), user — the tool round-trip is inline.
|
|
166
|
+
expect(all.map((m) => m.role)).toEqual([
|
|
167
|
+
"user",
|
|
168
|
+
"assistant",
|
|
169
|
+
"tool",
|
|
170
|
+
"assistant",
|
|
171
|
+
"user",
|
|
172
|
+
]);
|
|
173
|
+
});
|
|
174
|
+
});
|