@gajae-code/coding-agent 0.16.0 → 0.16.3
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 +52 -1
- package/dist/types/async/job-manager.d.ts +32 -1
- package/dist/types/browser-backend.d.ts +12 -0
- package/dist/types/cli/update-cli.d.ts +1 -0
- package/dist/types/commands/sdk.d.ts +2 -0
- package/dist/types/config/billing-path.d.ts +25 -0
- package/dist/types/config/config-schema-version.d.ts +13 -1
- package/dist/types/config/model-registry.d.ts +16 -0
- package/dist/types/config/settings-schema.d.ts +17 -7
- package/dist/types/extensibility/extensions/types.d.ts +8 -3
- package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
- package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
- package/dist/types/main.d.ts +1 -0
- package/dist/types/modes/acp/acp-agent.d.ts +21 -21
- package/dist/types/modes/components/model-selector.d.ts +4 -0
- package/dist/types/modes/components/session-selector.d.ts +1 -1
- package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
- package/dist/types/modes/interactive-mode.d.ts +16 -2
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/sdk/broker/broker.d.ts +2 -1
- package/dist/types/sdk/broker/ensure.d.ts +3 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +14 -0
- package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
- package/dist/types/sdk/broker/state-version.d.ts +17 -0
- package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
- package/dist/types/sdk/bus/index.d.ts +3 -1
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
- package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
- package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/cli/rows.d.ts +15 -2
- package/dist/types/sdk/cli/session-cli.d.ts +2 -1
- package/dist/types/sdk/host/events.d.ts +2 -0
- package/dist/types/sdk/host/host.d.ts +2 -0
- package/dist/types/sdk/host/query/cursor.d.ts +4 -2
- package/dist/types/sdk/host/query/handlers.d.ts +1 -0
- package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +18 -17
- package/dist/types/sdk/lifecycle-session.d.ts +1 -1
- package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
- package/dist/types/sdk/receipt-state.d.ts +2 -0
- package/dist/types/sdk/router/session-router.d.ts +4 -0
- package/dist/types/sdk/turn-result.d.ts +6 -0
- package/dist/types/session/agent-session.d.ts +47 -7
- package/dist/types/session/fold-coordinator.d.ts +29 -2
- package/dist/types/session/session-manager.d.ts +14 -2
- package/dist/types/tools/ask.d.ts +1 -0
- package/dist/types/tools/bash.d.ts +12 -0
- package/dist/types/tools/browser/launch.d.ts +10 -0
- package/dist/types/tools/browser/registry.d.ts +8 -0
- package/dist/types/tools/index.d.ts +4 -10
- package/dist/types/tools/job.d.ts +3 -1
- package/dist/types/utils/external-editor.d.ts +12 -0
- package/examples/extensions/README.md +1 -1
- package/examples/extensions/pirate.ts +7 -4
- package/package.json +7 -7
- package/scripts/generate-sdk-operation-inventory.ts +7 -1
- package/src/async/job-manager.ts +107 -9
- package/src/browser-backend.ts +29 -0
- package/src/cli/plugin-cli.ts +29 -0
- package/src/cli/update-cli.ts +63 -12
- package/src/commands/sdk.ts +22 -7
- package/src/config/billing-path.ts +42 -0
- package/src/config/config-schema-version.ts +18 -1
- package/src/config/model-preset-registry.ts +177 -18
- package/src/config/model-registry.ts +268 -42
- package/src/config/settings-schema.ts +19 -7
- package/src/config/settings.ts +46 -15
- package/src/coordinator-mcp/projection-scan.ts +6 -6
- package/src/coordinator-mcp/server.ts +21 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
- package/src/extensibility/extensions/runner.ts +27 -0
- package/src/extensibility/extensions/types.ts +8 -3
- package/src/extensibility/gjc-plugins/installer.ts +51 -11
- package/src/extensibility/runtime-skill-discovery.ts +110 -3
- package/src/extensibility/skills.ts +22 -12
- package/src/gjc-runtime/launch-worktree.ts +285 -4
- package/src/gjc-runtime/session-state-lock.ts +156 -32
- package/src/gjc-runtime/session-state-sidecar.ts +10 -17
- package/src/gjc-runtime/tmux-owner-isolation.ts +16 -1
- package/src/internal-urls/docs-index.generated.ts +11 -11
- package/src/main.ts +4 -1
- package/src/modes/acp/acp-agent.ts +34 -19
- package/src/modes/components/model-selector.ts +76 -8
- package/src/modes/components/session-selector.ts +23 -15
- package/src/modes/components/sessions-dashboard.ts +8 -5
- package/src/modes/controllers/command-controller.ts +1 -1
- package/src/modes/controllers/event-controller.ts +30 -4
- package/src/modes/controllers/extension-ui-controller.ts +13 -13
- package/src/modes/controllers/input-controller.ts +2 -2
- package/src/modes/controllers/selector-controller.ts +10 -3
- package/src/modes/interactive-mode.ts +36 -12
- package/src/modes/prompt-action-autocomplete.ts +30 -4
- package/src/modes/runtime-init.ts +12 -9
- package/src/modes/theme/theme.ts +5 -0
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/ui-helpers.ts +22 -3
- package/src/prompts/agent-fragments/browser-aside.md +22 -0
- package/src/prompts/tools/ask.md +1 -1
- package/src/prompts/tools/bash.md +1 -0
- package/src/prompts/tools/job.md +1 -1
- package/src/sdk/acp/adapter.ts +6 -1
- package/src/sdk/broker/broker.ts +10 -1
- package/src/sdk/broker/ensure.ts +76 -3
- package/src/sdk/broker/lifecycle-ledger.ts +6 -0
- package/src/sdk/broker/lifecycle.ts +254 -34
- package/src/sdk/broker/spawn-substrate.ts +45 -2
- package/src/sdk/broker/startup-budget.ts +144 -2
- package/src/sdk/broker/state-version.ts +17 -0
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +118 -24
- package/src/sdk/bus/discord-daemon.ts +50 -10
- package/src/sdk/bus/index.ts +33 -3
- package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
- package/src/sdk/bus/lifecycle-commands.ts +4 -0
- package/src/sdk/bus/notification-service.ts +22 -5
- package/src/sdk/bus/prompt-reconciliation.ts +7 -2
- package/src/sdk/bus/reconciliation-store.ts +55 -31
- package/src/sdk/bus/slack-conversation.ts +1 -1
- package/src/sdk/bus/slack-daemon.ts +72 -38
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/cli/rows.ts +75 -7
- package/src/sdk/cli/session-cli.ts +68 -35
- package/src/sdk/host/control/dispatch.ts +2 -2
- package/src/sdk/host/events.ts +26 -2
- package/src/sdk/host/host.ts +6 -1
- package/src/sdk/host/query/cursor.ts +52 -7
- package/src/sdk/host/query/handlers.ts +33 -12
- package/src/sdk/host/query/revision-store.ts +12 -0
- package/src/sdk/host/session-runtime.test.ts +497 -31
- package/src/sdk/host/session-runtime.ts +387 -54
- package/src/sdk/host/surface-policy.ts +3 -3
- package/src/sdk/lifecycle-session.ts +7 -2
- package/src/sdk/prompt-deadline-manager.ts +2 -1
- package/src/sdk/protocol/operation-inventory.generated.json +54 -3
- package/src/sdk/protocol/operation-registry.ts +1 -1
- package/src/sdk/receipt-state.ts +10 -0
- package/src/sdk/router/session-router.ts +210 -94
- package/src/sdk/session.ts +59 -41
- package/src/sdk/turn-result.ts +22 -0
- package/src/session/agent-session.ts +1033 -260
- package/src/session/fold-coordinator.ts +40 -3
- package/src/session/session-manager.ts +517 -77
- package/src/slash-commands/builtin-registry.ts +41 -0
- package/src/task/executor.ts +9 -4
- package/src/tools/ask-contract.test.ts +64 -0
- package/src/tools/ask-contract.ts +36 -1
- package/src/tools/ask.ts +10 -8
- package/src/tools/bash-interactive.ts +11 -2
- package/src/tools/bash.ts +158 -33
- package/src/tools/browser/launch.ts +37 -0
- package/src/tools/browser/registry.ts +99 -9
- package/src/tools/cron.ts +6 -4
- package/src/tools/descriptors.test.ts +15 -0
- package/src/tools/descriptors.ts +5 -1
- package/src/tools/index.ts +4 -6
- package/src/tools/job.ts +4 -1
- package/src/tools/monitor.ts +12 -9
- package/src/tools/resolve.ts +30 -10
- package/src/tools/tool-catalog.generated.ts +2 -2
- package/src/utils/external-editor.ts +86 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import * as os from "node:os";
|
|
5
5
|
import * as path from "node:path";
|
|
6
|
+
import { logger } from "@gajae-code/utils";
|
|
6
7
|
import { AsyncJobManager } from "../../async";
|
|
7
8
|
import type { Settings } from "../../config/settings";
|
|
8
9
|
import type { ExtensionAPI, ExtensionContext, ExtensionTranscriptEntry } from "../../extensibility/extensions";
|
|
@@ -254,7 +255,10 @@ test("native prompt reconciliation fails closed for an explicitly empty assistan
|
|
|
254
255
|
receiptState: "missing",
|
|
255
256
|
});
|
|
256
257
|
reconciliation.noteTransition(correlation, { type: "agent_end", finalText: "late text" });
|
|
257
|
-
expect(reconciliation.lookup({ clientRef: "native-empty-ref" })).toMatchObject({
|
|
258
|
+
expect(reconciliation.lookup({ clientRef: "native-empty-ref" })).toMatchObject({
|
|
259
|
+
status: "failed",
|
|
260
|
+
receiptState: "present",
|
|
261
|
+
});
|
|
258
262
|
});
|
|
259
263
|
|
|
260
264
|
test("broker reconciliation fails closed for an empty prompt and persists the first terminal result", async () => {
|
|
@@ -283,7 +287,9 @@ test("broker reconciliation fails closed for an empty prompt and persists the fi
|
|
|
283
287
|
expect(reloaded.lookupResult("prompt", { clientRef: "broker-empty-ref" })).toMatchObject({
|
|
284
288
|
status: "failed",
|
|
285
289
|
error: { code: "prompt_failed" },
|
|
290
|
+
content: { text: "late text", byteLength: 9, truncated: false },
|
|
286
291
|
});
|
|
292
|
+
expect(reloaded.lookup("prompt", { clientRef: "broker-empty-ref" })).toMatchObject({ receiptState: "present" });
|
|
287
293
|
const noOpCorrelation = { commandId: "broker-noop-command", turnId: "broker-noop-turn" };
|
|
288
294
|
await reconciliation.noteAccepted("prompt", noOpCorrelation, "broker-noop-ref");
|
|
289
295
|
await reconciliation.finalizeOutcome(
|
|
@@ -446,6 +452,42 @@ test("SDK-only finalizeOutcome keeps legacy recordError positional compatibility
|
|
|
446
452
|
});
|
|
447
453
|
});
|
|
448
454
|
|
|
455
|
+
test("SDK-only terminal outcomes survive result projection and durable reload", async () => {
|
|
456
|
+
let records: unknown[] = [];
|
|
457
|
+
const store = {
|
|
458
|
+
path: null,
|
|
459
|
+
load: async () => records,
|
|
460
|
+
transact: async (mutator: (current: never[]) => never[]) => {
|
|
461
|
+
records = mutator(records as never);
|
|
462
|
+
},
|
|
463
|
+
} as never;
|
|
464
|
+
const stopped = { kind: "stopped", reason: "cancelled", provenance: "client_cancel" } as const;
|
|
465
|
+
const reconciliation = createInvocationReconciliation({ store });
|
|
466
|
+
const correlation = { commandId: "outcome-command", turnId: "outcome-turn" };
|
|
467
|
+
await reconciliation.noteAccepted("prompt", correlation, "outcome-ref");
|
|
468
|
+
await reconciliation.claimPendingOutcome("prompt", correlation, stopped);
|
|
469
|
+
await reconciliation.finalizeOutcome("prompt", correlation);
|
|
470
|
+
const projected = reconciliation.lookupResult("prompt", { clientRef: "outcome-ref" });
|
|
471
|
+
expect(projected).toMatchObject({ status: "terminal_ok", outcome: stopped, receiptState: "unknown" });
|
|
472
|
+
expect(reconciliation.lookup("prompt", { clientRef: "outcome-ref" })).toMatchObject({ outcome: stopped });
|
|
473
|
+
|
|
474
|
+
const reopened = createInvocationReconciliation({ store });
|
|
475
|
+
await reopened.hydrate();
|
|
476
|
+
expect(reopened.lookupResult("prompt", { clientRef: "outcome-ref" })).toMatchObject({
|
|
477
|
+
status: "terminal_ok",
|
|
478
|
+
outcome: stopped,
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
const direct = createInvocationReconciliation();
|
|
482
|
+
const directCorrelation = { commandId: "direct-outcome-command", turnId: "direct-outcome-turn" };
|
|
483
|
+
await direct.noteAccepted("prompt", directCorrelation, "direct-outcome-ref");
|
|
484
|
+
await direct.noteTransition("prompt", directCorrelation, { type: "agent_end", outcome: stopped });
|
|
485
|
+
expect(direct.lookupResult("prompt", { clientRef: "direct-outcome-ref" })).toMatchObject({
|
|
486
|
+
status: "terminal_ok",
|
|
487
|
+
outcome: stopped,
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
449
491
|
test("a late agent failure never overwrites the reason an already terminal record carries", async () => {
|
|
450
492
|
const reconciliation = createInvocationReconciliation();
|
|
451
493
|
const correlation = { commandId: "first-reason-command", turnId: "first-reason-turn" };
|
|
@@ -493,7 +535,7 @@ test("a failed acceptance persist rolls back the provisional record and reservat
|
|
|
493
535
|
"accept persist failed",
|
|
494
536
|
);
|
|
495
537
|
reconciliation.release("prompt", "accept-failed-ref");
|
|
496
|
-
expect(reconciliation.lookup("prompt", first)).toEqual({ status: "unknown" });
|
|
538
|
+
expect(reconciliation.lookup("prompt", first)).toEqual({ status: "unknown", receiptState: "unknown" });
|
|
497
539
|
|
|
498
540
|
const retry = { commandId: "accept-retry-command", turnId: "accept-retry-turn" };
|
|
499
541
|
reconciliation.admit("prompt", "accept-failed-ref");
|
|
@@ -548,6 +590,54 @@ test("a failed uncertainty persist restores the durable deadline terminal until
|
|
|
548
590
|
]);
|
|
549
591
|
});
|
|
550
592
|
|
|
593
|
+
test("SDK-only markUncertain persists an active-schema record without quarantining siblings", async () => {
|
|
594
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-uncertain-reload-"));
|
|
595
|
+
try {
|
|
596
|
+
const sessionFile = path.join(root, "session.jsonl");
|
|
597
|
+
await Bun.write(sessionFile, "");
|
|
598
|
+
const store = createReconciliationStore({ sessionFile, sessionId: "uncertain-reload" });
|
|
599
|
+
const reconciliation = createInvocationReconciliation({ store });
|
|
600
|
+
const correlation = { commandId: "uncertain-reload-command", turnId: "uncertain-reload-turn" };
|
|
601
|
+
const sibling = { commandId: "uncertain-sibling-command", turnId: "uncertain-sibling-turn" };
|
|
602
|
+
await reconciliation.noteAccepted("prompt", correlation, "uncertain-reload-ref");
|
|
603
|
+
await reconciliation.finalizeOutcome("prompt", correlation, {
|
|
604
|
+
kind: "failed",
|
|
605
|
+
code: "prompt_deadline_exceeded",
|
|
606
|
+
message: "deadline",
|
|
607
|
+
});
|
|
608
|
+
await reconciliation.noteAccepted("skill", sibling, "uncertain-sibling-ref");
|
|
609
|
+
await reconciliation.finalizeOutcome("skill", sibling, {
|
|
610
|
+
kind: "stopped",
|
|
611
|
+
code: "cancelled",
|
|
612
|
+
message: "cancelled",
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
await reconciliation.markUncertain("prompt", correlation, undefined, 5_000);
|
|
616
|
+
const active = store
|
|
617
|
+
.snapshot()
|
|
618
|
+
.find(record => record.kind === "prompt" && record.commandId === correlation.commandId) as
|
|
619
|
+
| Record<string, unknown>
|
|
620
|
+
| undefined;
|
|
621
|
+
expect(active).toMatchObject({ status: "accepted", deadlineRecoveryPending: true, deadlineMaxAt: 5_000 });
|
|
622
|
+
expect(active?.terminalAt).toBeUndefined();
|
|
623
|
+
expect(active?.receiptState).toBeUndefined();
|
|
624
|
+
expect(active?.outcome).toBeUndefined();
|
|
625
|
+
expect(active?.pendingOutcome).toBeUndefined();
|
|
626
|
+
expect(active?.pendingReceiptState).toBeUndefined();
|
|
627
|
+
|
|
628
|
+
const reopenedStore = createReconciliationStore({ sessionFile, sessionId: "uncertain-reload" });
|
|
629
|
+
const reopened = createInvocationReconciliation({ store: reopenedStore });
|
|
630
|
+
await reopened.hydrate();
|
|
631
|
+
expect(reopened.lookup("prompt", { clientRef: "uncertain-reload-ref" })).toMatchObject({ status: "accepted" });
|
|
632
|
+
expect(reopened.lookup("skill", { clientRef: "uncertain-sibling-ref" })).toMatchObject({
|
|
633
|
+
status: "terminal_ok",
|
|
634
|
+
});
|
|
635
|
+
expect((await readdir(root)).some(name => name.includes("corrupt"))).toBe(false);
|
|
636
|
+
} finally {
|
|
637
|
+
await rm(root, { recursive: true, force: true });
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
|
|
551
641
|
test("durable reload keeps agent_end terminal across a paused successor transition", async () => {
|
|
552
642
|
let records: unknown[] = [];
|
|
553
643
|
let pauseWrites = 0;
|
|
@@ -911,8 +1001,13 @@ test("finalization without an explicit outcome follows the noteTransition eviden
|
|
|
911
1001
|
await legacyArg5.finalizeOutcome("prompt", legacyCorrelation, undefined, undefined, {
|
|
912
1002
|
content: { version: 1, type: "text", text: "legacy", byteLength: 6, truncated: false },
|
|
913
1003
|
});
|
|
914
|
-
|
|
1004
|
+
await legacyArg5.noteTransition("prompt", legacyCorrelation, {
|
|
1005
|
+
type: "agent_end",
|
|
1006
|
+
content: { version: 1, type: "text", text: "later", byteLength: 5, truncated: false },
|
|
1007
|
+
});
|
|
1008
|
+
expect(legacyArg5.lookupResult("prompt", { clientRef: "final-legacy-ref" })).toMatchObject({
|
|
915
1009
|
status: "terminal_ok",
|
|
1010
|
+
content: { text: "legacy", byteLength: 6, truncated: false },
|
|
916
1011
|
});
|
|
917
1012
|
|
|
918
1013
|
const explicitFailure = createInvocationReconciliation({ store: makeLocalStore() });
|
|
@@ -924,9 +1019,58 @@ test("finalization without an explicit outcome follows the noteTransition eviden
|
|
|
924
1019
|
message: "provider rejected",
|
|
925
1020
|
});
|
|
926
1021
|
expect(explicitFailure.lookup("prompt", { clientRef: "final-failure-ref" })).toMatchObject({
|
|
1022
|
+
status: "failed",
|
|
1023
|
+
receiptState: "unknown",
|
|
1024
|
+
outcome: { kind: "failed", code: "prompt_failed", provenance: "agent_failed" },
|
|
1025
|
+
error: { code: "prompt_failed" },
|
|
1026
|
+
});
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
test("provider failures outrank later stopped outcomes in both SDK-only terminal paths", async () => {
|
|
1030
|
+
const providerError = Object.assign(new Error("provider rejected"), { code: "provider_rejected" });
|
|
1031
|
+
const transition = createInvocationReconciliation();
|
|
1032
|
+
const transitionCorrelation = { commandId: "precedence-transition-command", turnId: "precedence-transition-turn" };
|
|
1033
|
+
await transition.noteAccepted("prompt", transitionCorrelation, "precedence-transition-ref");
|
|
1034
|
+
await transition.noteTransition("prompt", transitionCorrelation, { type: "agent_failed", error: providerError });
|
|
1035
|
+
await transition.noteTransition("prompt", transitionCorrelation, {
|
|
1036
|
+
type: "agent_end",
|
|
1037
|
+
outcome: { kind: "stopped", reason: "cancelled", provenance: "client_cancel" },
|
|
1038
|
+
});
|
|
1039
|
+
expect(transition.lookupResult("prompt", { clientRef: "precedence-transition-ref" })).toMatchObject({
|
|
1040
|
+
status: "failed",
|
|
1041
|
+
error: { code: "provider_rejected" },
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
const finalized = createInvocationReconciliation();
|
|
1045
|
+
const finalizedCorrelation = { commandId: "precedence-finalize-command", turnId: "precedence-finalize-turn" };
|
|
1046
|
+
await finalized.noteAccepted("prompt", finalizedCorrelation, "precedence-finalize-ref");
|
|
1047
|
+
await finalized.noteTransition("prompt", finalizedCorrelation, { type: "agent_failed", error: providerError });
|
|
1048
|
+
await finalized.claimPendingOutcome("prompt", finalizedCorrelation, {
|
|
1049
|
+
kind: "failed",
|
|
1050
|
+
code: "prompt_deadline_exceeded",
|
|
1051
|
+
message: "deadline",
|
|
1052
|
+
});
|
|
1053
|
+
await finalized.finalizeOutcome("prompt", finalizedCorrelation);
|
|
1054
|
+
expect(finalized.lookupResult("prompt", { clientRef: "precedence-finalize-ref" })).toMatchObject({
|
|
927
1055
|
status: "failed",
|
|
928
1056
|
error: { code: "provider_rejected" },
|
|
929
1057
|
});
|
|
1058
|
+
|
|
1059
|
+
const generic = createInvocationReconciliation();
|
|
1060
|
+
const genericCorrelation = { commandId: "precedence-generic-command", turnId: "precedence-generic-turn" };
|
|
1061
|
+
await generic.noteAccepted("prompt", genericCorrelation, "precedence-generic-ref");
|
|
1062
|
+
await generic.noteTransition("prompt", genericCorrelation, {
|
|
1063
|
+
type: "agent_failed",
|
|
1064
|
+
error: Object.assign(new Error("generic failure"), { code: "agent_failed" }),
|
|
1065
|
+
});
|
|
1066
|
+
await generic.noteTransition("prompt", genericCorrelation, {
|
|
1067
|
+
type: "agent_end",
|
|
1068
|
+
outcome: { kind: "stopped", reason: "cancelled", provenance: "client_cancel" },
|
|
1069
|
+
});
|
|
1070
|
+
expect(generic.lookupResult("prompt", { clientRef: "precedence-generic-ref" })).toMatchObject({
|
|
1071
|
+
status: "failed",
|
|
1072
|
+
error: { code: "agent_failed", message: "Prompt submission failed." },
|
|
1073
|
+
});
|
|
930
1074
|
});
|
|
931
1075
|
|
|
932
1076
|
test("a reason attached after a prompt settled is never replaced by a later failure", async () => {
|
|
@@ -945,6 +1089,8 @@ test("a reason attached after a prompt settled is never replaced by a later fail
|
|
|
945
1089
|
clientRef: "late-reason-ref",
|
|
946
1090
|
acceptedAt: expect.any(Number),
|
|
947
1091
|
terminalAt: expect.any(Number),
|
|
1092
|
+
receiptState: "present",
|
|
1093
|
+
content: { version: 1, type: "text", text: "completed", byteLength: 9, truncated: false },
|
|
948
1094
|
});
|
|
949
1095
|
// A failure delivered after the record settled enriches it with the sanitized reason and
|
|
950
1096
|
// leaves the terminal claim itself (status, terminalAt, identity) untouched.
|
|
@@ -1052,6 +1198,7 @@ test("capacity eviction still releases the clientRef and reports honest unknown"
|
|
|
1052
1198
|
}
|
|
1053
1199
|
expect(reconciliation.lookup("prompt", { clientRef: "capacity-ref-1" })).toEqual({
|
|
1054
1200
|
status: "unknown",
|
|
1201
|
+
receiptState: "unknown",
|
|
1055
1202
|
});
|
|
1056
1203
|
expect(() => reconciliation.admit("prompt", "capacity-ref-1")).not.toThrow();
|
|
1057
1204
|
const retained = {
|
|
@@ -1073,6 +1220,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
1073
1220
|
const transport = memoryTransport();
|
|
1074
1221
|
const runtime = new SessionSdkSessionRuntime({
|
|
1075
1222
|
transport,
|
|
1223
|
+
eventRevision: () => 7,
|
|
1076
1224
|
control: async (_connectionId, frame) => ({ id: frame.id, ok: true, result: { operation: frame.operation } }),
|
|
1077
1225
|
query: async (_connectionId, frame) => ({ id: frame.id, ok: true, result: { query: frame.query } }),
|
|
1078
1226
|
});
|
|
@@ -1092,7 +1240,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
1092
1240
|
});
|
|
1093
1241
|
transport.feed("client", { type: "query_request", id: "query", query: "Q18", input: {} });
|
|
1094
1242
|
await Bun.sleep(0);
|
|
1095
|
-
expect(transport.broadcasts.
|
|
1243
|
+
expect(transport.broadcasts).toContainEqual(expect.objectContaining({ kind: "session_ready", revision: 7 }));
|
|
1096
1244
|
expect(transport.sent).toEqual(
|
|
1097
1245
|
expect.arrayContaining([
|
|
1098
1246
|
expect.objectContaining({ type: "event_replay_result", id: "replay", ok: true }),
|
|
@@ -1102,6 +1250,110 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
1102
1250
|
);
|
|
1103
1251
|
await runtime.stop();
|
|
1104
1252
|
});
|
|
1253
|
+
test("SDK-only host publishes one replayable bash_folded frame per fold and drops the subscription on shutdown", async () => {
|
|
1254
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-bash-folded-"));
|
|
1255
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
1256
|
+
const api = {
|
|
1257
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
1258
|
+
handlers.set(event, handler);
|
|
1259
|
+
},
|
|
1260
|
+
sendUserMessage: async () => {},
|
|
1261
|
+
} as unknown as ExtensionAPI;
|
|
1262
|
+
const transport = memoryTransport();
|
|
1263
|
+
createSdkSessionRuntimeExtension(api, { agentDir: cwd, createTransport: async () => transport });
|
|
1264
|
+
const listeners = new Set<(event: { jobId: string; generation: string; reason: string }) => void>();
|
|
1265
|
+
const ctx = {
|
|
1266
|
+
...extensionContext(transport.sessionId, cwd),
|
|
1267
|
+
onJobFold: (listener: (event: { jobId: string; generation: string; reason: string }) => void) => {
|
|
1268
|
+
listeners.add(listener);
|
|
1269
|
+
return () => listeners.delete(listener);
|
|
1270
|
+
},
|
|
1271
|
+
} as unknown as ExtensionContext;
|
|
1272
|
+
try {
|
|
1273
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
1274
|
+
expect(listeners.size).toBe(1);
|
|
1275
|
+
for (const listener of listeners) listener({ jobId: "bg_7", generation: "bg_7:1", reason: "steer" });
|
|
1276
|
+
transport.feed("client", { type: "event_replay", id: "fold-replay", sinceSeq: 0 });
|
|
1277
|
+
await Bun.sleep(0);
|
|
1278
|
+
const replay = transport.sent.find(
|
|
1279
|
+
frame => frame.type === "event_replay_result" && frame.id === "fold-replay",
|
|
1280
|
+
) as { events?: Array<{ kind?: unknown; payload?: Record<string, unknown> }> } | undefined;
|
|
1281
|
+
const folded = replay?.events?.filter(event => event.kind === "bash_folded") ?? [];
|
|
1282
|
+
expect(folded).toEqual([
|
|
1283
|
+
expect.objectContaining({
|
|
1284
|
+
kind: "bash_folded",
|
|
1285
|
+
payload: {
|
|
1286
|
+
type: "bash_folded",
|
|
1287
|
+
sessionId: transport.sessionId,
|
|
1288
|
+
jobId: "bg_7",
|
|
1289
|
+
generation: "bg_7:1",
|
|
1290
|
+
reason: "steer",
|
|
1291
|
+
},
|
|
1292
|
+
}),
|
|
1293
|
+
]);
|
|
1294
|
+
} finally {
|
|
1295
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
1296
|
+
}
|
|
1297
|
+
expect(listeners.size).toBe(0);
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
test("SDK-only host logs a bash_folded publication failure and keeps the fold replayable", async () => {
|
|
1301
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-bash-folded-fail-"));
|
|
1302
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void> | void>();
|
|
1303
|
+
const api = {
|
|
1304
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) {
|
|
1305
|
+
handlers.set(event, handler);
|
|
1306
|
+
},
|
|
1307
|
+
sendUserMessage: async () => {},
|
|
1308
|
+
} as unknown as ExtensionAPI;
|
|
1309
|
+
const transport = memoryTransport();
|
|
1310
|
+
const broadcastFrame = transport.broadcastFrame?.bind(transport);
|
|
1311
|
+
let failNextBroadcast = false;
|
|
1312
|
+
transport.broadcastFrame = frame => {
|
|
1313
|
+
if (failNextBroadcast && frame.kind === "bash_folded") {
|
|
1314
|
+
failNextBroadcast = false;
|
|
1315
|
+
throw new Error("broadcast socket closed");
|
|
1316
|
+
}
|
|
1317
|
+
broadcastFrame?.(frame);
|
|
1318
|
+
};
|
|
1319
|
+
createSdkSessionRuntimeExtension(api, { agentDir: cwd, createTransport: async () => transport });
|
|
1320
|
+
const listeners = new Set<(event: { jobId: string; generation: string; reason: string }) => void>();
|
|
1321
|
+
const ctx = {
|
|
1322
|
+
...extensionContext(transport.sessionId, cwd),
|
|
1323
|
+
onJobFold: (listener: (event: { jobId: string; generation: string; reason: string }) => void) => {
|
|
1324
|
+
listeners.add(listener);
|
|
1325
|
+
return () => listeners.delete(listener);
|
|
1326
|
+
},
|
|
1327
|
+
} as unknown as ExtensionContext;
|
|
1328
|
+
const warnings: Array<{ message: string; data?: Record<string, unknown> }> = [];
|
|
1329
|
+
const warn = spyOn(logger, "warn").mockImplementation((message, data) => {
|
|
1330
|
+
warnings.push({ message: String(message), data: data as Record<string, unknown> | undefined });
|
|
1331
|
+
});
|
|
1332
|
+
try {
|
|
1333
|
+
await handlers.get("session_start")?.({}, ctx);
|
|
1334
|
+
failNextBroadcast = true;
|
|
1335
|
+
// The listener call must not throw back into the manager: publication failure is evidence, not a fold error.
|
|
1336
|
+
for (const listener of listeners) listener({ jobId: "bg_9", generation: "bg_9:1", reason: "chord" });
|
|
1337
|
+
expect(warnings.filter(entry => entry.message === "sdk: bash_folded publication failed")).toEqual([
|
|
1338
|
+
expect.objectContaining({
|
|
1339
|
+
data: expect.objectContaining({ jobId: "bg_9", reason: "chord", error: "broadcast socket closed" }),
|
|
1340
|
+
}),
|
|
1341
|
+
]);
|
|
1342
|
+
// The frame was appended to the ring before the broadcast failed, so it still replays.
|
|
1343
|
+
transport.feed("client", { type: "event_replay", id: "fold-replay-fail", sinceSeq: 0 });
|
|
1344
|
+
await Bun.sleep(0);
|
|
1345
|
+
const replay = transport.sent.find(
|
|
1346
|
+
frame => frame.type === "event_replay_result" && frame.id === "fold-replay-fail",
|
|
1347
|
+
) as { events?: Array<{ kind?: unknown; payload?: Record<string, unknown> }> } | undefined;
|
|
1348
|
+
expect(replay?.events?.filter(event => event.kind === "bash_folded")).toEqual([
|
|
1349
|
+
expect.objectContaining({ payload: expect.objectContaining({ jobId: "bg_9", reason: "chord" }) }),
|
|
1350
|
+
]);
|
|
1351
|
+
} finally {
|
|
1352
|
+
warn.mockRestore();
|
|
1353
|
+
await handlers.get("session_shutdown")?.({}, ctx);
|
|
1354
|
+
}
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1105
1357
|
test("SDK-only host admits, replays, and conflicts terminal abort requests durably", async () => {
|
|
1106
1358
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-terminal-abort-"));
|
|
1107
1359
|
const operatorCapability = "runtime-terminal-abort-capability";
|
|
@@ -2582,26 +2834,32 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
2582
2834
|
|
|
2583
2835
|
// B's accepted receipt is redacted to {} once its submission rejects,
|
|
2584
2836
|
// so select its record by the stable clientRef instead.
|
|
2585
|
-
// Let the async rejection settle and retire B's pending entry.
|
|
2586
|
-
await Bun.sleep(50);
|
|
2587
2837
|
// The rejected submission must TERMINALIZE as failed (#4668 review
|
|
2588
2838
|
// P1): agent_failed alone is diagnostic-only, so the settlement also
|
|
2589
2839
|
// writes agent_end. Before that fix B stayed accepted forever.
|
|
2590
2840
|
{
|
|
2591
2841
|
const ids = { clientRef: "fail-b-ref" };
|
|
2592
|
-
transport.feed("conn-b", {
|
|
2593
|
-
type: "query_request",
|
|
2594
|
-
id: "fail-b-status",
|
|
2595
|
-
query: "turn.prompt_status",
|
|
2596
|
-
input: { kind: "prompt", ...ids },
|
|
2597
|
-
} as SdkFrame);
|
|
2598
|
-
// waitResponse polls control_response only; queries get query_response.
|
|
2599
2842
|
const statusDeadline = Date.now() + 15_000;
|
|
2600
|
-
|
|
2843
|
+
let status: Record<string, unknown> | undefined;
|
|
2844
|
+
for (let attempt = 0; status === undefined; attempt += 1) {
|
|
2845
|
+
const id = `fail-b-status-${attempt}`;
|
|
2846
|
+
transport.feed("conn-b", {
|
|
2847
|
+
type: "query_request",
|
|
2848
|
+
id,
|
|
2849
|
+
query: "turn.prompt_status",
|
|
2850
|
+
input: { kind: "prompt", ...ids },
|
|
2851
|
+
} as SdkFrame);
|
|
2852
|
+
while (!transport.sent.some(frame => frame.id === id)) {
|
|
2853
|
+
if (Date.now() > statusDeadline) throw new Error("Timed out waiting for fail-b-status");
|
|
2854
|
+
await Bun.sleep(20);
|
|
2855
|
+
}
|
|
2856
|
+
const response = transport.sent.find(frame => frame.id === id);
|
|
2857
|
+
const result = response?.result as Record<string, unknown> | undefined;
|
|
2858
|
+
if (result?.status === "failed") status = response;
|
|
2859
|
+
else await Bun.sleep(20);
|
|
2601
2860
|
if (Date.now() > statusDeadline) throw new Error("Timed out waiting for fail-b-status");
|
|
2602
|
-
await Bun.sleep(20);
|
|
2603
2861
|
}
|
|
2604
|
-
expect(
|
|
2862
|
+
expect(status).toMatchObject({
|
|
2605
2863
|
ok: true,
|
|
2606
2864
|
result: expect.objectContaining({ status: "failed" }),
|
|
2607
2865
|
});
|
|
@@ -3510,6 +3768,8 @@ async function invocationHarness(
|
|
|
3510
3768
|
onLifecycleDrainTimeout?: () => void;
|
|
3511
3769
|
onFailureDiagnosticKeyCount?: (count: number) => void;
|
|
3512
3770
|
agentFailedWriteFailures?: number;
|
|
3771
|
+
branch?: unknown[];
|
|
3772
|
+
onInvocationCompletionReconciled?: (kind: string, correlation: { commandId: string; turnId: string }) => void;
|
|
3513
3773
|
},
|
|
3514
3774
|
): Promise<InvocationHarness> {
|
|
3515
3775
|
const waiters = new Map<string, (frame: ResponseFrame) => void>();
|
|
@@ -3552,6 +3812,9 @@ async function invocationHarness(
|
|
|
3552
3812
|
...(hooks.onFailureDiagnosticKeyCount
|
|
3553
3813
|
? { onFailureDiagnosticKeyCountForTests: hooks.onFailureDiagnosticKeyCount }
|
|
3554
3814
|
: {}),
|
|
3815
|
+
...(hooks.onInvocationCompletionReconciled
|
|
3816
|
+
? { onInvocationCompletionReconciledForTests: hooks.onInvocationCompletionReconciled }
|
|
3817
|
+
: {}),
|
|
3555
3818
|
...(hooks.settings ? { settings: hooks.settings } : {}),
|
|
3556
3819
|
createTransport: async ({ sessionId: id, stateRoot, token }) => ({
|
|
3557
3820
|
sessionId: id,
|
|
@@ -3590,7 +3853,7 @@ async function invocationHarness(
|
|
|
3590
3853
|
getSessionId: () => sessionId,
|
|
3591
3854
|
getSessionFile: () => path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`),
|
|
3592
3855
|
getSessionName: () => undefined,
|
|
3593
|
-
getBranch: () => [],
|
|
3856
|
+
getBranch: () => hooks.branch ?? [],
|
|
3594
3857
|
},
|
|
3595
3858
|
};
|
|
3596
3859
|
await handlers.get("session_start")?.({}, ctx);
|
|
@@ -4465,7 +4728,9 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
4465
4728
|
expect(secondIds.turnId).not.toBe(firstIds.turnId);
|
|
4466
4729
|
await harness.emit("agent_start");
|
|
4467
4730
|
await successorStarted.promise;
|
|
4468
|
-
await harness.emit("agent_end"
|
|
4731
|
+
await harness.emit("agent_end", {
|
|
4732
|
+
messages: [{ role: "assistant", content: "predecessor delayed" }],
|
|
4733
|
+
});
|
|
4469
4734
|
expect(await harness.query("turn.prompt_status", secondIds)).toMatchObject({
|
|
4470
4735
|
result: { status: expect.stringMatching(/accepted|in_flight/) },
|
|
4471
4736
|
});
|
|
@@ -4473,10 +4738,23 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
4473
4738
|
status: "failed",
|
|
4474
4739
|
error: { code: "aborted" },
|
|
4475
4740
|
});
|
|
4741
|
+
const firstResult = await harness.query("turn.result", { kind: "prompt", ...firstIds });
|
|
4742
|
+
expect(firstResult).toMatchObject({
|
|
4743
|
+
result: { status: "failed", content: { text: "predecessor delayed" } },
|
|
4744
|
+
});
|
|
4476
4745
|
await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] });
|
|
4477
4746
|
expect(await settledStatus(harness, "turn.prompt_status", secondIds)).toMatchObject({
|
|
4478
4747
|
status: "terminal_ok",
|
|
4479
4748
|
});
|
|
4749
|
+
expect(await harness.query("turn.result", { kind: "prompt", ...secondIds })).toMatchObject({
|
|
4750
|
+
result: {
|
|
4751
|
+
status: "terminal_ok",
|
|
4752
|
+
content: { text: "completed", byteLength: 9, truncated: false },
|
|
4753
|
+
},
|
|
4754
|
+
});
|
|
4755
|
+
expect(await harness.query("turn.result", { kind: "prompt", ...firstIds })).toMatchObject({
|
|
4756
|
+
result: { content: { text: "predecessor delayed" } },
|
|
4757
|
+
});
|
|
4480
4758
|
expect(prompts).toBe(2);
|
|
4481
4759
|
await harness.stop();
|
|
4482
4760
|
} finally {
|
|
@@ -4494,7 +4772,7 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
4494
4772
|
const harness = await invocationHarness("successor-progress", cwd, {
|
|
4495
4773
|
settings: {
|
|
4496
4774
|
get: (key: string) =>
|
|
4497
|
-
key === "sdk.promptDeadlineMs" ?
|
|
4775
|
+
key === "sdk.promptDeadlineMs" ? 2_000 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
|
|
4498
4776
|
} as unknown as Settings,
|
|
4499
4777
|
sendUserMessage: async (_content, options) => {
|
|
4500
4778
|
prompts += 1;
|
|
@@ -4519,7 +4797,7 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
4519
4797
|
await successorStarted.promise;
|
|
4520
4798
|
for (let index = 0; index < 4; index += 1) {
|
|
4521
4799
|
await harness.emit("tool_execution_start");
|
|
4522
|
-
await Bun.sleep(
|
|
4800
|
+
await Bun.sleep(800);
|
|
4523
4801
|
}
|
|
4524
4802
|
expect(await harness.query("turn.prompt_status", successorIds)).toMatchObject({
|
|
4525
4803
|
result: { status: expect.stringMatching(/accepted|in_flight/) },
|
|
@@ -4673,6 +4951,190 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
4673
4951
|
await rm(cwd, { recursive: true, force: true });
|
|
4674
4952
|
}
|
|
4675
4953
|
});
|
|
4954
|
+
test("captures exact bounded content from actual prompt agent_end messages", async () => {
|
|
4955
|
+
const cases = [
|
|
4956
|
+
{ name: "short", agent: "SDK_OK", expected: "SDK_OK", bytes: 6, truncated: false },
|
|
4957
|
+
{ name: "max", agent: "x".repeat(16_384), expected: "x".repeat(16_384), bytes: 16_384, truncated: false },
|
|
4958
|
+
{ name: "blank", agent: " ", expected: undefined, bytes: 0, truncated: false },
|
|
4959
|
+
{
|
|
4960
|
+
name: "overflow",
|
|
4961
|
+
agent: `${"😀".repeat(4_096)}tail`,
|
|
4962
|
+
expected: "😀".repeat(4_096),
|
|
4963
|
+
bytes: 16_384,
|
|
4964
|
+
truncated: true,
|
|
4965
|
+
},
|
|
4966
|
+
] as const;
|
|
4967
|
+
for (const testCase of cases) {
|
|
4968
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-terminal-content-${testCase.name}-`));
|
|
4969
|
+
try {
|
|
4970
|
+
const harness = await invocationHarness(`terminal-content-${testCase.name}`, cwd, {
|
|
4971
|
+
sendUserMessage: async (_content, options) => {
|
|
4972
|
+
await options?.onPreflightAcceptCommit?.();
|
|
4973
|
+
await Promise.withResolvers<void>().promise;
|
|
4974
|
+
},
|
|
4975
|
+
});
|
|
4976
|
+
const accepted = await harness.control("turn.prompt", { text: "hello" });
|
|
4977
|
+
await harness.emit("agent_start");
|
|
4978
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", content: testCase.agent }] });
|
|
4979
|
+
const result = await settledStatus(harness, "turn.result", {
|
|
4980
|
+
kind: "prompt",
|
|
4981
|
+
commandId: accepted.result?.commandId,
|
|
4982
|
+
turnId: accepted.result?.turnId,
|
|
4983
|
+
});
|
|
4984
|
+
if (testCase.expected === undefined) {
|
|
4985
|
+
expect(result).toMatchObject({ status: "failed" });
|
|
4986
|
+
expect((result as Record<string, unknown>).content).toBeUndefined();
|
|
4987
|
+
} else {
|
|
4988
|
+
expect(result).toMatchObject({
|
|
4989
|
+
status: "terminal_ok",
|
|
4990
|
+
content: {
|
|
4991
|
+
text: testCase.expected,
|
|
4992
|
+
byteLength: testCase.bytes,
|
|
4993
|
+
truncated: testCase.truncated,
|
|
4994
|
+
},
|
|
4995
|
+
});
|
|
4996
|
+
}
|
|
4997
|
+
await harness.stop();
|
|
4998
|
+
} finally {
|
|
4999
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5000
|
+
}
|
|
5001
|
+
}
|
|
5002
|
+
});
|
|
5003
|
+
test("does not reuse a previous branch assistant for a new textless prompt", async () => {
|
|
5004
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminal-order-previous-assistant-"));
|
|
5005
|
+
try {
|
|
5006
|
+
const harness = await invocationHarness("terminal-order-previous-assistant", cwd, {
|
|
5007
|
+
branch: [{ type: "message", message: { role: "assistant", content: "PREVIOUS" } }],
|
|
5008
|
+
sendUserMessage: async (_content, options) => {
|
|
5009
|
+
await options?.onPreflightAcceptCommit?.();
|
|
5010
|
+
await Promise.withResolvers<void>().promise;
|
|
5011
|
+
},
|
|
5012
|
+
});
|
|
5013
|
+
const accepted = await harness.control("turn.prompt", { text: "textless" });
|
|
5014
|
+
const selector = {
|
|
5015
|
+
kind: "prompt" as const,
|
|
5016
|
+
commandId: accepted.result?.commandId,
|
|
5017
|
+
turnId: accepted.result?.turnId,
|
|
5018
|
+
};
|
|
5019
|
+
await harness.emit("agent_start");
|
|
5020
|
+
await harness.emit("agent_end", { messages: [] });
|
|
5021
|
+
const result = await settledStatus(harness, "turn.result", selector);
|
|
5022
|
+
expect((result as Record<string, unknown>).content).toBeUndefined();
|
|
5023
|
+
await harness.stop();
|
|
5024
|
+
} finally {
|
|
5025
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5026
|
+
}
|
|
5027
|
+
});
|
|
5028
|
+
test("reconciles contract-valid skill completion and agent_end ordering", async () => {
|
|
5029
|
+
const cases = [
|
|
5030
|
+
{
|
|
5031
|
+
name: "completion-real-agent-real",
|
|
5032
|
+
order: "completion-first",
|
|
5033
|
+
completion: "completion",
|
|
5034
|
+
agent: "agent",
|
|
5035
|
+
expected: "completion",
|
|
5036
|
+
},
|
|
5037
|
+
{
|
|
5038
|
+
name: "completion-blank-agent-real",
|
|
5039
|
+
order: "completion-first",
|
|
5040
|
+
completion: " ",
|
|
5041
|
+
agent: "agent",
|
|
5042
|
+
expected: "agent",
|
|
5043
|
+
},
|
|
5044
|
+
{
|
|
5045
|
+
name: "completion-none-agent-real",
|
|
5046
|
+
order: "completion-first",
|
|
5047
|
+
completion: null,
|
|
5048
|
+
agent: "agent",
|
|
5049
|
+
expected: "agent",
|
|
5050
|
+
},
|
|
5051
|
+
{
|
|
5052
|
+
name: "agent-real-completion-real",
|
|
5053
|
+
order: "agent-first",
|
|
5054
|
+
completion: "completion",
|
|
5055
|
+
agent: "agent",
|
|
5056
|
+
expected: "agent",
|
|
5057
|
+
},
|
|
5058
|
+
{
|
|
5059
|
+
name: "agent-real-completion-blank",
|
|
5060
|
+
order: "agent-first",
|
|
5061
|
+
completion: " ",
|
|
5062
|
+
agent: "agent",
|
|
5063
|
+
expected: "agent",
|
|
5064
|
+
},
|
|
5065
|
+
{
|
|
5066
|
+
name: "agent-blank-completion-real",
|
|
5067
|
+
order: "agent-first",
|
|
5068
|
+
completion: "completion",
|
|
5069
|
+
agent: " ",
|
|
5070
|
+
expected: "completion",
|
|
5071
|
+
},
|
|
5072
|
+
{
|
|
5073
|
+
name: "agent-blank-completion-none",
|
|
5074
|
+
order: "agent-first",
|
|
5075
|
+
completion: null,
|
|
5076
|
+
agent: " ",
|
|
5077
|
+
expected: undefined,
|
|
5078
|
+
},
|
|
5079
|
+
{
|
|
5080
|
+
name: "agent-none-completion-real",
|
|
5081
|
+
order: "agent-first",
|
|
5082
|
+
completion: "completion",
|
|
5083
|
+
agent: null,
|
|
5084
|
+
expected: "completion",
|
|
5085
|
+
},
|
|
5086
|
+
{
|
|
5087
|
+
name: "agent-none-completion-blank",
|
|
5088
|
+
order: "agent-first",
|
|
5089
|
+
completion: " ",
|
|
5090
|
+
agent: null,
|
|
5091
|
+
expected: undefined,
|
|
5092
|
+
},
|
|
5093
|
+
] as const;
|
|
5094
|
+
for (const testCase of cases) {
|
|
5095
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-skill-terminal-order-${testCase.name}-`));
|
|
5096
|
+
const completion = Promise.withResolvers<unknown>();
|
|
5097
|
+
const completionReconciled = Promise.withResolvers<void>();
|
|
5098
|
+
try {
|
|
5099
|
+
const harness = await invocationHarness(`skill-terminal-order-${testCase.name}`, cwd, {
|
|
5100
|
+
onInvocationCompletionReconciled: kind => {
|
|
5101
|
+
if (kind === "skill") completionReconciled.resolve();
|
|
5102
|
+
},
|
|
5103
|
+
invokeSkill: async (_name, _args, options) => {
|
|
5104
|
+
await options?.onPreflightAcceptCommit?.();
|
|
5105
|
+
return await completion.promise;
|
|
5106
|
+
},
|
|
5107
|
+
});
|
|
5108
|
+
const accepted = await harness.control("skill.invoke", { name: "ralplan" });
|
|
5109
|
+
const selector = {
|
|
5110
|
+
kind: "skill" as const,
|
|
5111
|
+
commandId: accepted.result?.commandId,
|
|
5112
|
+
turnId: accepted.result?.turnId,
|
|
5113
|
+
};
|
|
5114
|
+
await harness.emit("agent_start");
|
|
5115
|
+
const emitAgentEnd = () =>
|
|
5116
|
+
harness.emit("agent_end", {
|
|
5117
|
+
messages: testCase.agent === null ? [] : [{ role: "assistant", content: testCase.agent }],
|
|
5118
|
+
});
|
|
5119
|
+
if (testCase.order === "completion-first") {
|
|
5120
|
+
completion.resolve(testCase.completion);
|
|
5121
|
+
await completionReconciled.promise;
|
|
5122
|
+
await emitAgentEnd();
|
|
5123
|
+
} else {
|
|
5124
|
+
await emitAgentEnd();
|
|
5125
|
+
completion.resolve(testCase.completion);
|
|
5126
|
+
await completionReconciled.promise;
|
|
5127
|
+
}
|
|
5128
|
+
const result = await harness.query("turn.result", selector);
|
|
5129
|
+
const content = (result.result as { content?: { text?: string } } | undefined)?.content;
|
|
5130
|
+
expect(content?.text).toBe(testCase.expected);
|
|
5131
|
+
await harness.stop();
|
|
5132
|
+
} finally {
|
|
5133
|
+
completion.resolve(undefined);
|
|
5134
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5135
|
+
}
|
|
5136
|
+
}
|
|
5137
|
+
}, 30_000);
|
|
4676
5138
|
test("a queued follow-up prompt is not terminalized before the turn runs", async () => {
|
|
4677
5139
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-followup-"));
|
|
4678
5140
|
try {
|
|
@@ -4791,7 +5253,7 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
4791
5253
|
const rejected = await harness.control("turn.prompt", { text: "hello", clientRef: "preflight-ref" });
|
|
4792
5254
|
expect(rejected.ok).toBe(false);
|
|
4793
5255
|
const status = await harness.query("turn.prompt_status", { clientRef: "preflight-ref" });
|
|
4794
|
-
expect(status.result).toEqual({ status: "unknown" });
|
|
5256
|
+
expect(status.result as Record<string, unknown>).toEqual({ status: "unknown", receiptState: "unknown" });
|
|
4795
5257
|
await harness.stop();
|
|
4796
5258
|
} finally {
|
|
4797
5259
|
await Bun.sleep(50);
|
|
@@ -5098,9 +5560,13 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
5098
5560
|
// renewal wiring at the SDK boundary.
|
|
5099
5561
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-renew-attached-"));
|
|
5100
5562
|
try {
|
|
5563
|
+
const renewalSettings = {
|
|
5564
|
+
get: (key: string) =>
|
|
5565
|
+
key === "sdk.promptDeadlineMs" ? 2_000 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
|
|
5566
|
+
} as unknown as Settings;
|
|
5101
5567
|
let promoted: ((promotion: { startsOwnRun: boolean }) => void) | undefined;
|
|
5102
5568
|
const harness = await invocationHarness("renew-attached", cwd, {
|
|
5103
|
-
settings:
|
|
5569
|
+
settings: renewalSettings,
|
|
5104
5570
|
sendUserMessage: async (content, options) => {
|
|
5105
5571
|
await options?.onPreflightAcceptCommit?.();
|
|
5106
5572
|
if (content === "consumed") {
|
|
@@ -5119,12 +5585,12 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
5119
5585
|
expect(consumed.ok).toBe(true);
|
|
5120
5586
|
promoted?.({ startsOwnRun: false });
|
|
5121
5587
|
const ids = { commandId: consumed.result?.commandId, turnId: consumed.result?.turnId };
|
|
5122
|
-
// Past the
|
|
5123
|
-
// correlation alive
|
|
5124
|
-
//
|
|
5588
|
+
// Past the initial 2s lease, repeated tool activity keeps the
|
|
5589
|
+
// attached correlation alive. Each interval stays well within one
|
|
5590
|
+
// renewed lease while their cumulative duration exceeds the original.
|
|
5125
5591
|
for (let i = 0; i < 3; i += 1) {
|
|
5126
5592
|
await harness.emit("tool_execution_start");
|
|
5127
|
-
await Bun.sleep(
|
|
5593
|
+
await Bun.sleep(800);
|
|
5128
5594
|
}
|
|
5129
5595
|
const midRun = await harness.query("turn.prompt_status", ids);
|
|
5130
5596
|
expect(midRun.result?.status).not.toBe("failed");
|
|
@@ -5328,7 +5794,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
5328
5794
|
const harness = await invocationHarness("dispatch-race-lease", cwd, {
|
|
5329
5795
|
settings: {
|
|
5330
5796
|
get: (key: string) =>
|
|
5331
|
-
key === "sdk.promptDeadlineMs" ?
|
|
5797
|
+
key === "sdk.promptDeadlineMs" ? 2_000 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
|
|
5332
5798
|
} as unknown as Settings,
|
|
5333
5799
|
sendUserMessage: async (content, options) => {
|
|
5334
5800
|
await options?.onPreflightAcceptCommit?.();
|
|
@@ -5353,9 +5819,9 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
5353
5819
|
const raced = await harness.control("turn.prompt", { text: "raced" });
|
|
5354
5820
|
expect(raced.ok).toBe(true);
|
|
5355
5821
|
const idsRaced = { commandId: raced.result?.commandId, turnId: raced.result?.turnId };
|
|
5356
|
-
// Far past the
|
|
5822
|
+
// Far past the 2s lease while queued: the acceptance-anchored lease
|
|
5357
5823
|
// was dropped at the divert disposition, so no false deadline fire.
|
|
5358
|
-
await Bun.sleep(
|
|
5824
|
+
await Bun.sleep(2_200);
|
|
5359
5825
|
const queued = await harness.query("turn.prompt_status", idsRaced);
|
|
5360
5826
|
expect(queued.result?.status).not.toBe("failed");
|
|
5361
5827
|
// Real consumption re-leases and attaches to the in-flight run.
|