| 865 |
879
|
|
expect(notices).toHaveLength(1);
|
| 866 |
880
|
|
});
|
| 867 |
881
|
|
});
|
|
882
|
+ |
|
|
883
|
+ |
describe("a provider failure mid-session", () => {
|
|
884
|
+ |
const proxyCalls = (calls: ReadonlyArray<Call>) =>
|
|
885
|
+ |
calls.filter((call) => call.url.endsWith("/api/inference/proxy"));
|
|
886
|
+ |
|
|
887
|
+ |
it("carries the server's failure class into the sentence a reader sees", async () => {
|
|
888
|
+ |
const calls = stub({
|
|
889
|
+ |
proxy: [
|
|
890
|
+ |
json(502, { error: { code: "provider_failed", reason: "context_length_exceeded" } }),
|
|
891
|
+ |
json(502, { error: { code: "provider_failed", reason: "context_length_exceeded" } }),
|
|
892
|
+ |
json(502, { error: { code: "provider_failed", reason: "context_length_exceeded" } }),
|
|
893
|
+ |
],
|
|
894
|
+ |
});
|
|
895
|
+ |
const source = await open();
|
|
896
|
+ |
|
|
897
|
+ |
await expect(chunks(source)).rejects.toThrow(/context_length_exceeded/);
|
|
898
|
+ |
// Non-vacuous: the generic sentence is still there, with the class beside it.
|
|
899
|
+ |
expect(proxyCalls(calls)).toHaveLength(3);
|
|
900
|
+ |
});
|
|
901
|
+ |
|
|
902
|
+ |
it("retries a provider failure rather than losing the turn to one hiccup", async () => {
|
|
903
|
+ |
const calls = stub({
|
|
904
|
+ |
proxy: [json(502, { error: { code: "provider_failed" } }), sse([LIVE_SSE])],
|
|
905
|
+ |
});
|
|
906
|
+ |
const source = await open();
|
|
907
|
+ |
|
|
908
|
+ |
expect(textOf(await chunks(source))).toBe("Hello! Nice");
|
|
909
|
+ |
expect(proxyCalls(calls)).toHaveLength(2);
|
|
910
|
+ |
});
|
|
911
|
+ |
|
|
912
|
+ |
it("gives up after a bounded number of attempts", async () => {
|
|
913
|
+ |
const calls = stub({
|
|
914
|
+ |
proxy: [
|
|
915
|
+ |
json(502, { error: { code: "provider_failed" } }),
|
|
916
|
+ |
json(502, { error: { code: "provider_failed" } }),
|
|
917
|
+ |
json(502, { error: { code: "provider_failed" } }),
|
|
918
|
+ |
sse([LIVE_SSE]),
|
|
919
|
+ |
],
|
|
920
|
+ |
});
|
|
921
|
+ |
const source = await open();
|
|
922
|
+ |
|
|
923
|
+ |
await expect(chunks(source)).rejects.toBeInstanceOf(ThreadUnavailable);
|
|
924
|
+ |
// Stops at three; the fourth response, which would have succeeded, is never
|
|
925
|
+ |
// asked for. A retry re-spends budget, so the ceiling has to bite.
|
|
926
|
+ |
expect(proxyCalls(calls)).toHaveLength(3);
|
|
927
|
+ |
});
|
|
928
|
+ |
|
|
929
|
+ |
it("does not retry a settled grant", async () => {
|
|
930
|
+ |
for (const [status, code] of [
|
|
931
|
+ |
[403, "grant_revoked"],
|
|
932
|
+ |
[403, "grant_expired"],
|
|
933
|
+ |
[429, "grant_exhausted"],
|
|
934
|
+ |
[401, "invalid_grant"],
|
|
935
|
+ |
] as const) {
|
|
936
|
+ |
const calls = stub({
|
|
937
|
+ |
proxy: [json(status, { error: { code } }), sse([LIVE_SSE])],
|
|
938
|
+ |
});
|
|
939
|
+ |
const source = await open();
|
|
940
|
+ |
|
|
941
|
+ |
await expect(chunks(source)).rejects.toBeInstanceOf(ThreadUnavailable);
|
|
942
|
+ |
// Retrying a settled refusal tells the reader the same thing three times.
|
|
943
|
+ |
expect(proxyCalls(calls)).toHaveLength(1);
|
|
944
|
+ |
vi.unstubAllGlobals();
|
|
945
|
+ |
}
|
|
946
|
+ |
});
|
|
947
|
+ |
});
|
|
948
|
+ |
|
|
949
|
+ |
describe("the session's anchor on the thread lane", () => {
|
|
950
|
+ |
const proxied = (calls: ReadonlyArray<Call>) =>
|
|
951
|
+ |
calls.filter((call) => call.url.endsWith("/api/inference/proxy"));
|
|
952
|
+ |
|
|
953
|
+ |
const tool = (name: string) => ({
|
|
954
|
+ |
name,
|
|
955
|
+ |
description: `the ${name} tool`,
|
|
956
|
+ |
parameters: { type: "object" as const },
|
|
957
|
+ |
run: async () => "done",
|
|
958
|
+ |
});
|
|
959
|
+ |
|
|
960
|
+ |
it("says what the session is, so the model does not answer with the name underneath", async () => {
|
|
961
|
+ |
// The gap this closes: asked "who are you", a thread session answered
|
|
962
|
+ |
// "I'm ChatGPT" — the hosted model's own name — because nothing on this
|
|
963
|
+ |
// lane had ever told it otherwise.
|
|
964
|
+ |
const calls = stub({});
|
|
965
|
+ |
const source = await open();
|
|
966
|
+ |
await chunks(source);
|
|
967
|
+ |
|
|
968
|
+ |
const anchor = anchorOf(proxied(calls)[0]?.body["messages"]);
|
|
969
|
+ |
expect(anchor?.["content"]).toContain("`openagents coder`");
|
|
970
|
+ |
expect(anchor?.["content"]).toContain("inference proxy");
|
|
971
|
+ |
});
|
|
972
|
+ |
|
|
973
|
+ |
it("names the declared tools as a closed list", async () => {
|
|
974
|
+ |
const calls = stub({});
|
|
975
|
+ |
const source = await open();
|
|
976
|
+ |
source.useTools([tool("shell"), tool("delegate")]);
|
|
977
|
+ |
await chunks(source);
|
|
978
|
+ |
|
|
979
|
+ |
const content = String(anchorOf(proxied(calls)[0]?.body["messages"])?.["content"]);
|
|
980
|
+ |
expect(content).toContain("You have 2 tools, and no others:");
|
|
981
|
+ |
expect(content).toContain("`shell`");
|
|
982
|
+ |
expect(content).toContain("`delegate`");
|
|
983
|
+ |
});
|
|
984
|
+ |
|
|
985
|
+ |
it("carries the standing context, rather than gluing it to what the reader typed", async () => {
|
|
986
|
+ |
const calls = stub({});
|
|
987
|
+ |
const source = await open();
|
|
988
|
+ |
source.useContext("This session is working in /repo.");
|
|
989
|
+ |
await chunks(source, "hello");
|
|
990
|
+ |
|
|
991
|
+ |
const messages = proxied(calls)[0]?.body["messages"] as Array<Record<string, unknown>>;
|
|
992
|
+ |
expect(String(anchorOf(messages)?.["content"])).toContain("This session is working in /repo.");
|
|
993
|
+ |
// The reader's turn is the reader's words and nothing else. Prefixed onto
|
|
994
|
+ |
// the prompt, the preamble read as something they had typed.
|
|
995
|
+ |
expect(conversation(messages)).toEqual([{ role: "user", content: "hello" }]);
|
|
996
|
+ |
});
|
|
997
|
+ |
|
|
998
|
+ |
it("sends the anchor once, not on every turn", async () => {
|
|
999
|
+ |
const calls = stub({});
|
|
1000
|
+ |
const source = await open();
|
|
1001
|
+ |
await chunks(source, "first");
|
|
1002
|
+ |
await chunks(source, "second");
|
|
1003
|
+ |
|
|
1004
|
+ |
const messages = proxied(calls)[1]?.body["messages"] as Array<Record<string, unknown>>;
|
|
1005
|
+ |
expect(messages.filter((message) => message["role"] === "system")).toHaveLength(1);
|
|
1006
|
+ |
expect(messages[0]?.["role"]).toBe("system");
|
|
1007
|
+ |
});
|
|
1008
|
+ |
|
|
1009
|
+ |
it("shows the reader the same text it sends", async () => {
|
|
1010
|
+ |
stub({});
|
|
1011
|
+ |
const source = await open();
|
|
1012
|
+ |
source.useTools([tool("shell")]);
|
|
1013
|
+ |
source.useContext("Workspace facts.");
|
|
1014
|
+ |
|
|
1015
|
+ |
// `/system` reads the thing the model read. It used to say the server
|
|
1016
|
+ |
// composed this, which was both untrue and hiding that nothing was sent.
|
|
1017
|
+ |
const shown = source.describeContext();
|
|
1018
|
+ |
expect(shown).toContain("`openagents coder`");
|
|
1019
|
+ |
expect(shown).toContain("Workspace facts.");
|
|
1020
|
+ |
expect(shown).not.toContain("composed by the server");
|
|
1021
|
+ |
});
|
|
1022
|
+ |
});
|