@gajae-code/coding-agent 0.15.4 → 0.15.5
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 +20 -1
- package/dist/types/cli/update-cli.d.ts +25 -5
- package/dist/types/config/autorouting-tier-map.d.ts +3 -0
- package/dist/types/extensibility/extensions/types.d.ts +3 -0
- package/dist/types/extensibility/shared-events.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
- package/dist/types/sdk/cli/session-cli.d.ts +42 -1
- package/dist/types/sdk/host/host.d.ts +2 -0
- package/dist/types/sdk/host/session-runtime.d.ts +4 -0
- package/dist/types/session/agent-session.d.ts +4 -2
- package/dist/types/session/internal/managed-session-storage.d.ts +4 -3
- package/dist/types/tools/path-utils.d.ts +5 -6
- package/dist/types/tools/tmux-self-injection-guard.d.ts +15 -0
- package/package.json +7 -7
- package/src/cli/update-cli.ts +102 -1
- package/src/commands/sdk.ts +5 -0
- package/src/config/autorouting-tier-map.ts +1 -0
- package/src/config/model-profiles.ts +5 -5
- package/src/config/model-registry.ts +10 -4
- package/src/coordinator-mcp/policy.ts +1 -1
- package/src/extensibility/extensions/types.ts +3 -0
- package/src/extensibility/shared-events.ts +2 -0
- package/src/gjc-runtime/launch-worktree.ts +44 -7
- package/src/gjc-runtime/ralplan-runtime.ts +14 -1
- package/src/gjc-runtime/tmux-sessions.ts +145 -48
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/modes/prompt-action-autocomplete.ts +26 -47
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/kind-aware-reconciliation.ts +10 -2
- package/src/sdk/bus/prompt-reconciliation.ts +5 -2
- package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
- package/src/sdk/cli/session-cli.ts +149 -4
- package/src/sdk/host/host.ts +7 -0
- package/src/sdk/host/session-runtime.test.ts +821 -22
- package/src/sdk/host/session-runtime.ts +669 -67
- package/src/sdk/router/session-router.ts +69 -15
- package/src/session/agent-session.ts +232 -15
- package/src/session/internal/managed-session-storage.ts +64 -26
- package/src/session/session-manager.ts +26 -8
- package/src/tools/bash.ts +14 -0
- package/src/tools/path-utils.ts +77 -29
- package/src/tools/tmux-self-injection-guard.ts +359 -0
|
@@ -742,7 +742,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
742
742
|
},
|
|
743
743
|
sendUserMessage: async (_content: string, options: { onPreflightAcceptCommit?: () => Promise<void> }) => {
|
|
744
744
|
await options?.onPreflightAcceptCommit?.();
|
|
745
|
-
await
|
|
745
|
+
await neverSettlingPromise();
|
|
746
746
|
},
|
|
747
747
|
} as unknown as ExtensionAPI;
|
|
748
748
|
const transport = memoryTransport();
|
|
@@ -994,7 +994,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
994
994
|
},
|
|
995
995
|
sendUserMessage: async (_content: string, options: { onPreflightAcceptCommit?: () => Promise<void> }) => {
|
|
996
996
|
await options?.onPreflightAcceptCommit?.();
|
|
997
|
-
await
|
|
997
|
+
await neverSettlingPromise();
|
|
998
998
|
},
|
|
999
999
|
} as unknown as ExtensionAPI;
|
|
1000
1000
|
const transport = memoryTransport();
|
|
@@ -1356,7 +1356,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
1356
1356
|
},
|
|
1357
1357
|
sendUserMessage: async (_content: string, options: { onPreflightAcceptCommit?: () => Promise<void> }) => {
|
|
1358
1358
|
await options?.onPreflightAcceptCommit?.();
|
|
1359
|
-
await
|
|
1359
|
+
await neverSettlingPromise();
|
|
1360
1360
|
},
|
|
1361
1361
|
} as unknown as ExtensionAPI;
|
|
1362
1362
|
const transport = memoryTransport();
|
|
@@ -1710,7 +1710,7 @@ describe("SessionSdkSessionRuntime", () => {
|
|
|
1710
1710
|
// Production-faithful: an accepted run stays in-flight for the whole
|
|
1711
1711
|
// test; sendUserMessage resolution (turn completion) never precedes
|
|
1712
1712
|
// agent_start (#4668 success-retirement).
|
|
1713
|
-
await
|
|
1713
|
+
await neverSettlingPromise();
|
|
1714
1714
|
},
|
|
1715
1715
|
} as unknown as ExtensionAPI;
|
|
1716
1716
|
const transport = memoryTransport();
|
|
@@ -2959,6 +2959,8 @@ interface PreflightHooks {
|
|
|
2959
2959
|
interface ResponseFrame {
|
|
2960
2960
|
id?: string;
|
|
2961
2961
|
ok?: boolean;
|
|
2962
|
+
code?: string;
|
|
2963
|
+
error?: { code: string; message: string };
|
|
2962
2964
|
result?: { status?: string; commandId?: string; turnId?: string; error?: { code: string; message: string } };
|
|
2963
2965
|
}
|
|
2964
2966
|
|
|
@@ -2966,6 +2968,11 @@ interface InvocationHarness {
|
|
|
2966
2968
|
control(operation: string, input: Record<string, unknown>): Promise<ResponseFrame>;
|
|
2967
2969
|
query(name: string, input: Record<string, unknown>): Promise<ResponseFrame>;
|
|
2968
2970
|
emit(event: string, payload?: unknown): Promise<void>;
|
|
2971
|
+
switchSession(sessionId: string): Promise<void>;
|
|
2972
|
+
branch(sessionId: string): Promise<void>;
|
|
2973
|
+
requestOnSession(sessionId: string, frame: Record<string, unknown>): Promise<ResponseFrame>;
|
|
2974
|
+
sendToSession(sessionId: string, frame: Record<string, unknown>): void;
|
|
2975
|
+
sent(sessionId: string): SdkFrame[];
|
|
2969
2976
|
readonly broadcasts: SdkFrame[];
|
|
2970
2977
|
stop(): Promise<void>;
|
|
2971
2978
|
}
|
|
@@ -2985,6 +2992,10 @@ async function invocationHarness(
|
|
|
2985
2992
|
settings?: Settings;
|
|
2986
2993
|
/** Throw to inject a durable-write failure for matching transitions. */
|
|
2987
2994
|
persistInterceptor?: (transition: { type: string }) => void;
|
|
2995
|
+
/** Hold one matching durable transition until the test releases it. */
|
|
2996
|
+
persistHold?: { type: string; onEntered: () => void; release: Promise<void> };
|
|
2997
|
+
onLifecycleDrainTimeout?: () => void;
|
|
2998
|
+
onFailureDiagnosticKeyCount?: (count: number) => void;
|
|
2988
2999
|
agentFailedWriteFailures?: number;
|
|
2989
3000
|
},
|
|
2990
3001
|
): Promise<InvocationHarness> {
|
|
@@ -2992,6 +3003,8 @@ async function invocationHarness(
|
|
|
2992
3003
|
const handlers = new Map<string, (event: unknown, ctx: unknown) => Promise<void> | void>();
|
|
2993
3004
|
const broadcasts: SdkFrame[] = [];
|
|
2994
3005
|
let deliver: ((connectionId: string, frame: SdkFrame) => void) | undefined;
|
|
3006
|
+
const deliveries = new Map<string, (connectionId: string, frame: SdkFrame) => void>();
|
|
3007
|
+
const sentFrames = new Map<string, SdkFrame[]>();
|
|
2995
3008
|
let nextId = 0;
|
|
2996
3009
|
const api = {
|
|
2997
3010
|
on(event: string, handler: (event: unknown, ctx: unknown) => Promise<void> | void) {
|
|
@@ -3000,10 +3013,15 @@ async function invocationHarness(
|
|
|
3000
3013
|
sendUserMessage: hooks.sendUserMessage ?? (async () => {}),
|
|
3001
3014
|
} as unknown as ExtensionAPI;
|
|
3002
3015
|
const interceptorStore = hooks.persistInterceptor
|
|
3003
|
-
? createInterceptorReconciliationStore(
|
|
3016
|
+
? createInterceptorReconciliationStore(
|
|
3017
|
+
hooks.persistInterceptor,
|
|
3018
|
+
hooks.agentFailedWriteFailures,
|
|
3019
|
+
hooks.persistHold,
|
|
3020
|
+
)
|
|
3004
3021
|
: undefined;
|
|
3005
3022
|
createSdkSessionRuntimeExtension(api, {
|
|
3006
3023
|
agentDir: cwd,
|
|
3024
|
+
...(hooks.onLifecycleDrainTimeout ? { onLifecycleDrainTimeoutForTests: hooks.onLifecycleDrainTimeout } : {}),
|
|
3007
3025
|
...(interceptorStore
|
|
3008
3026
|
? {
|
|
3009
3027
|
terminalAbortSeams: {
|
|
@@ -3015,6 +3033,9 @@ async function invocationHarness(
|
|
|
3015
3033
|
},
|
|
3016
3034
|
}
|
|
3017
3035
|
: {}),
|
|
3036
|
+
...(hooks.onFailureDiagnosticKeyCount
|
|
3037
|
+
? { onFailureDiagnosticKeyCountForTests: hooks.onFailureDiagnosticKeyCount }
|
|
3038
|
+
: {}),
|
|
3018
3039
|
...(hooks.settings ? { settings: hooks.settings } : {}),
|
|
3019
3040
|
createTransport: async ({ sessionId: id, stateRoot, token }) => ({
|
|
3020
3041
|
sessionId: id,
|
|
@@ -3022,12 +3043,17 @@ async function invocationHarness(
|
|
|
3022
3043
|
token,
|
|
3023
3044
|
onFrame(handler) {
|
|
3024
3045
|
deliver = handler;
|
|
3046
|
+
deliveries.set(id, handler);
|
|
3025
3047
|
return () => {
|
|
3026
3048
|
if (deliver === handler) deliver = undefined;
|
|
3049
|
+
if (deliveries.get(id) === handler) deliveries.delete(id);
|
|
3027
3050
|
};
|
|
3028
3051
|
},
|
|
3029
3052
|
sendFrame(_connectionId, frame) {
|
|
3030
3053
|
const response = frame as ResponseFrame;
|
|
3054
|
+
const frames = sentFrames.get(id) ?? [];
|
|
3055
|
+
frames.push(frame);
|
|
3056
|
+
sentFrames.set(id, frames);
|
|
3031
3057
|
if (typeof response.id === "string") waiters.get(response.id)?.(response);
|
|
3032
3058
|
},
|
|
3033
3059
|
broadcastFrame(frame) {
|
|
@@ -3046,6 +3072,7 @@ async function invocationHarness(
|
|
|
3046
3072
|
...(hooks.invokeSkill ? { invokeSkill: hooks.invokeSkill } : {}),
|
|
3047
3073
|
sessionManager: {
|
|
3048
3074
|
getSessionId: () => sessionId,
|
|
3075
|
+
getSessionFile: () => path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`),
|
|
3049
3076
|
getSessionName: () => undefined,
|
|
3050
3077
|
getBranch: () => [],
|
|
3051
3078
|
},
|
|
@@ -3069,6 +3096,44 @@ async function invocationHarness(
|
|
|
3069
3096
|
stop: async () => {
|
|
3070
3097
|
await handlers.get("session_shutdown")?.({}, ctx);
|
|
3071
3098
|
},
|
|
3099
|
+
switchSession: async sessionId => {
|
|
3100
|
+
await handlers.get("session_switch")?.(
|
|
3101
|
+
{},
|
|
3102
|
+
{
|
|
3103
|
+
...ctx,
|
|
3104
|
+
sessionManager: {
|
|
3105
|
+
...ctx.sessionManager,
|
|
3106
|
+
getSessionId: () => sessionId,
|
|
3107
|
+
getSessionFile: () => path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`),
|
|
3108
|
+
},
|
|
3109
|
+
},
|
|
3110
|
+
);
|
|
3111
|
+
},
|
|
3112
|
+
branch: async sessionId => {
|
|
3113
|
+
await handlers.get("session_branch")?.(
|
|
3114
|
+
{},
|
|
3115
|
+
{
|
|
3116
|
+
...ctx,
|
|
3117
|
+
sessionManager: {
|
|
3118
|
+
...ctx.sessionManager,
|
|
3119
|
+
getSessionId: () => sessionId,
|
|
3120
|
+
getSessionFile: () => path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`),
|
|
3121
|
+
},
|
|
3122
|
+
},
|
|
3123
|
+
);
|
|
3124
|
+
},
|
|
3125
|
+
requestOnSession: (sessionId, frame) => {
|
|
3126
|
+
const id = `frame-${nextId}`;
|
|
3127
|
+
nextId += 1;
|
|
3128
|
+
const { promise, resolve } = Promise.withResolvers<ResponseFrame>();
|
|
3129
|
+
waiters.set(id, resolve);
|
|
3130
|
+
deliveries.get(sessionId)?.("client", { ...frame, id } as SdkFrame);
|
|
3131
|
+
return promise;
|
|
3132
|
+
},
|
|
3133
|
+
sendToSession: (sessionId, frame) => {
|
|
3134
|
+
deliveries.get(sessionId)?.("client", frame as SdkFrame);
|
|
3135
|
+
},
|
|
3136
|
+
sent: sessionId => sentFrames.get(sessionId) ?? [],
|
|
3072
3137
|
};
|
|
3073
3138
|
}
|
|
3074
3139
|
|
|
@@ -3078,34 +3143,46 @@ async function invocationHarness(
|
|
|
3078
3143
|
function createInterceptorReconciliationStore(
|
|
3079
3144
|
interceptor: (transition: { type: string }) => void,
|
|
3080
3145
|
agentFailedWriteFailures = 1,
|
|
3146
|
+
persistHold?: { type: string; onEntered: () => void; release: Promise<void> },
|
|
3081
3147
|
): SdkOnlyReconciliationStore {
|
|
3082
3148
|
const failureCounts = new Map<string, number>();
|
|
3149
|
+
let holdConsumed = false;
|
|
3083
3150
|
const backing = createInterceptorBackingStore();
|
|
3084
3151
|
return {
|
|
3085
3152
|
path: null,
|
|
3086
3153
|
load: async () => backing.records.map(record => ({ ...record })),
|
|
3087
3154
|
async transact(mutator) {
|
|
3088
3155
|
const records = mutator(backing.records.map(record => ({ ...record })));
|
|
3156
|
+
let transitionType: "agent_failed" | "agent_end" | undefined;
|
|
3089
3157
|
for (const record of records as Array<{ status?: string; terminalAt?: number; commandId?: string }>) {
|
|
3090
3158
|
// The intermediate durable state an agent_failed write produces:
|
|
3091
3159
|
// the reason is set but the row is not terminal yet. Tests choose how
|
|
3092
3160
|
// many consecutive writes fail before the recovery replay succeeds.
|
|
3093
3161
|
const identity = String(record?.commandId ?? "unknown");
|
|
3094
3162
|
const failureCount = failureCounts.get(identity) ?? 0;
|
|
3163
|
+
if (record?.terminalAt === undefined && (record as { error?: unknown }).error !== undefined)
|
|
3164
|
+
transitionType ??= "agent_failed";
|
|
3095
3165
|
if (
|
|
3096
3166
|
record?.terminalAt === undefined &&
|
|
3097
3167
|
(record as { error?: unknown }).error !== undefined &&
|
|
3098
3168
|
failureCount < agentFailedWriteFailures
|
|
3099
3169
|
) {
|
|
3170
|
+
transitionType = "agent_failed";
|
|
3100
3171
|
failureCounts.set(identity, failureCount + 1);
|
|
3101
3172
|
interceptor({ type: "agent_failed" });
|
|
3102
3173
|
throw Object.assign(new Error("injected persistence failure"), { code: "io_error" });
|
|
3103
3174
|
}
|
|
3104
3175
|
const previous = backing.records.find(candidate => candidate.commandId === record.commandId);
|
|
3105
|
-
if (record
|
|
3176
|
+
if (record?.terminalAt !== undefined && previous?.terminalAt === undefined) {
|
|
3177
|
+
transitionType = "agent_end";
|
|
3106
3178
|
interceptor({ type: "agent_end" });
|
|
3107
3179
|
}
|
|
3108
3180
|
}
|
|
3181
|
+
if (persistHold && !holdConsumed && persistHold.type === transitionType) {
|
|
3182
|
+
holdConsumed = true;
|
|
3183
|
+
persistHold.onEntered();
|
|
3184
|
+
await persistHold.release;
|
|
3185
|
+
}
|
|
3109
3186
|
backing.records = records;
|
|
3110
3187
|
},
|
|
3111
3188
|
snapshotTerminalScopes: () => [],
|
|
@@ -3134,14 +3211,645 @@ async function settledStatus(
|
|
|
3134
3211
|
throw new Error(`${name} never reported a terminal reconciliation status`);
|
|
3135
3212
|
}
|
|
3136
3213
|
|
|
3214
|
+
function neverSettlingPromise(): Promise<void> {
|
|
3215
|
+
return Promise.withResolvers<void>().promise;
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3137
3218
|
describe("post-acceptance invocation terminalization", () => {
|
|
3219
|
+
test.each([
|
|
3220
|
+
{ status: 402, code: "provider_http_402" },
|
|
3221
|
+
{ status: 429, code: "provider_http_429" },
|
|
3222
|
+
{ status: 500, code: "provider_rejected" },
|
|
3223
|
+
])("terminalizes a streamed provider HTTP $status rejection and permits a healthy abort_and_prompt replacement", async ({
|
|
3224
|
+
status,
|
|
3225
|
+
code,
|
|
3226
|
+
}) => {
|
|
3227
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-provider-http-${status}-`));
|
|
3228
|
+
try {
|
|
3229
|
+
let prompts = 0;
|
|
3230
|
+
const harness = await invocationHarness(`provider-http-${status}`, cwd, {
|
|
3231
|
+
sendUserMessage: async (_content, options) => {
|
|
3232
|
+
prompts += 1;
|
|
3233
|
+
await options?.onPreflightAcceptCommit?.();
|
|
3234
|
+
if (prompts === 1) await neverSettlingPromise();
|
|
3235
|
+
},
|
|
3236
|
+
});
|
|
3237
|
+
const failed = await harness.control("turn.prompt", { text: "provider rejects", clientRef: `http-${status}` });
|
|
3238
|
+
expect(failed.ok).toBe(true);
|
|
3239
|
+
const failedIds = { commandId: failed.result?.commandId, turnId: failed.result?.turnId };
|
|
3240
|
+
await harness.emit("agent_start");
|
|
3241
|
+
await harness.emit("agent_end", {
|
|
3242
|
+
messages: [
|
|
3243
|
+
{
|
|
3244
|
+
role: "assistant",
|
|
3245
|
+
stopReason: "error",
|
|
3246
|
+
errorStatus: status,
|
|
3247
|
+
transportFailure: { kind: "transport", status },
|
|
3248
|
+
},
|
|
3249
|
+
],
|
|
3250
|
+
});
|
|
3251
|
+
|
|
3252
|
+
const terminal = await settledStatus(harness, "turn.result", {
|
|
3253
|
+
kind: "prompt",
|
|
3254
|
+
clientRef: `http-${status}`,
|
|
3255
|
+
});
|
|
3256
|
+
expect(terminal).toMatchObject({
|
|
3257
|
+
status: "failed",
|
|
3258
|
+
error: { code, message: "Prompt submission failed." },
|
|
3259
|
+
terminalAt: expect.any(Number),
|
|
3260
|
+
});
|
|
3261
|
+
const stable = await harness.query("turn.result", { kind: "prompt", ...failedIds });
|
|
3262
|
+
expect(stable.result).toEqual(terminal);
|
|
3263
|
+
|
|
3264
|
+
const replacement = await harness.control("turn.abort_and_prompt", { text: "replacement" });
|
|
3265
|
+
expect(replacement).toMatchObject({ ok: true, result: { accepted: true } });
|
|
3266
|
+
const replacementIds = { commandId: replacement.result?.commandId, turnId: replacement.result?.turnId };
|
|
3267
|
+
await harness.emit("agent_start");
|
|
3268
|
+
await harness.emit("agent_end", {
|
|
3269
|
+
messages: [
|
|
3270
|
+
{ role: "assistant", stopReason: "error", errorStatus: 429 },
|
|
3271
|
+
{ role: "assistant", stopReason: "stop" },
|
|
3272
|
+
],
|
|
3273
|
+
});
|
|
3274
|
+
expect(await settledStatus(harness, "turn.prompt_status", replacementIds)).toMatchObject({
|
|
3275
|
+
status: "terminal_ok",
|
|
3276
|
+
});
|
|
3277
|
+
expect(prompts).toBe(2);
|
|
3278
|
+
await harness.stop();
|
|
3279
|
+
} finally {
|
|
3280
|
+
await Bun.sleep(50);
|
|
3281
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3282
|
+
}
|
|
3283
|
+
});
|
|
3284
|
+
|
|
3285
|
+
test("classifies a statusless provider error completion as rejected", async () => {
|
|
3286
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-provider-statusless-"));
|
|
3287
|
+
try {
|
|
3288
|
+
const harness = await invocationHarness("provider-statusless", cwd, {
|
|
3289
|
+
sendUserMessage: async (_content, options) => {
|
|
3290
|
+
await options?.onPreflightAcceptCommit?.();
|
|
3291
|
+
await neverSettlingPromise();
|
|
3292
|
+
},
|
|
3293
|
+
});
|
|
3294
|
+
const accepted = await harness.control("turn.prompt", { text: "provider rejects" });
|
|
3295
|
+
expect(accepted.ok).toBe(true);
|
|
3296
|
+
const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
|
|
3297
|
+
await harness.emit("agent_start");
|
|
3298
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "error" }] });
|
|
3299
|
+
expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
|
|
3300
|
+
status: "failed",
|
|
3301
|
+
error: { code: "provider_rejected" },
|
|
3302
|
+
});
|
|
3303
|
+
await harness.stop();
|
|
3304
|
+
} finally {
|
|
3305
|
+
await Bun.sleep(50);
|
|
3306
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3307
|
+
}
|
|
3308
|
+
});
|
|
3309
|
+
|
|
3310
|
+
test("upgrades a generic agent_failed diagnostic when agent_end proves provider rejection", async () => {
|
|
3311
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-provider-statusless-upgrade-"));
|
|
3312
|
+
try {
|
|
3313
|
+
const harness = await invocationHarness("provider-statusless-upgrade", cwd, {
|
|
3314
|
+
sendUserMessage: async (_content, options) => {
|
|
3315
|
+
await options?.onPreflightAcceptCommit?.();
|
|
3316
|
+
await neverSettlingPromise();
|
|
3317
|
+
},
|
|
3318
|
+
});
|
|
3319
|
+
const accepted = await harness.control("turn.prompt", { text: "provider rejects" });
|
|
3320
|
+
expect(accepted.ok).toBe(true);
|
|
3321
|
+
const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
|
|
3322
|
+
await harness.emit("agent_start");
|
|
3323
|
+
await harness.emit("agent_failed", {
|
|
3324
|
+
error: Object.assign(new Error("generic agent failure"), { code: "agent_failed" }),
|
|
3325
|
+
});
|
|
3326
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "error" }] });
|
|
3327
|
+
expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
|
|
3328
|
+
status: "failed",
|
|
3329
|
+
error: { code: "provider_rejected" },
|
|
3330
|
+
});
|
|
3331
|
+
const failures = harness.broadcasts.filter(frame => frame.kind === "agent_failed");
|
|
3332
|
+
expect(failures.at(-1)).toMatchObject({ payload: { error: { code: "provider_rejected" } } });
|
|
3333
|
+
await harness.stop();
|
|
3334
|
+
} finally {
|
|
3335
|
+
await Bun.sleep(50);
|
|
3336
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3337
|
+
}
|
|
3338
|
+
});
|
|
3339
|
+
|
|
3340
|
+
test("preserves a specific agent_failed diagnostic when agent_end is statusless", async () => {
|
|
3341
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-provider-statusless-specific-"));
|
|
3342
|
+
try {
|
|
3343
|
+
const harness = await invocationHarness("provider-statusless-specific", cwd, {
|
|
3344
|
+
sendUserMessage: async (_content, options) => {
|
|
3345
|
+
await options?.onPreflightAcceptCommit?.();
|
|
3346
|
+
await neverSettlingPromise();
|
|
3347
|
+
},
|
|
3348
|
+
});
|
|
3349
|
+
const accepted = await harness.control("turn.prompt", { text: "provider unavailable" });
|
|
3350
|
+
expect(accepted.ok).toBe(true);
|
|
3351
|
+
const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
|
|
3352
|
+
await harness.emit("agent_start");
|
|
3353
|
+
await harness.emit("agent_failed", {
|
|
3354
|
+
error: Object.assign(new Error("provider unavailable"), { code: "provider_unavailable" }),
|
|
3355
|
+
});
|
|
3356
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "error" }] });
|
|
3357
|
+
expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
|
|
3358
|
+
status: "failed",
|
|
3359
|
+
error: { code: "provider_unavailable" },
|
|
3360
|
+
});
|
|
3361
|
+
expect(harness.broadcasts.filter(frame => frame.kind === "agent_failed")).toHaveLength(1);
|
|
3362
|
+
await harness.stop();
|
|
3363
|
+
} finally {
|
|
3364
|
+
await Bun.sleep(50);
|
|
3365
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3366
|
+
}
|
|
3367
|
+
});
|
|
3368
|
+
|
|
3369
|
+
test("upgrades every shared generic diagnostic on a statusless provider end", async () => {
|
|
3370
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-provider-statusless-shared-"));
|
|
3371
|
+
try {
|
|
3372
|
+
let promoted: ((promotion: { startsOwnRun?: boolean }) => void) | undefined;
|
|
3373
|
+
const harness = await invocationHarness("provider-statusless-shared", cwd, {
|
|
3374
|
+
sendUserMessage: async (content, options) => {
|
|
3375
|
+
await options?.onPreflightAcceptCommit?.();
|
|
3376
|
+
if (content === "attached") {
|
|
3377
|
+
promoted = (options as { onQueuedPromoted?: (promotion: { startsOwnRun?: boolean }) => void })
|
|
3378
|
+
?.onQueuedPromoted;
|
|
3379
|
+
return;
|
|
3380
|
+
}
|
|
3381
|
+
await neverSettlingPromise();
|
|
3382
|
+
},
|
|
3383
|
+
});
|
|
3384
|
+
const first = await harness.control("turn.prompt", { text: "first" });
|
|
3385
|
+
expect(first.ok).toBe(true);
|
|
3386
|
+
await harness.emit("agent_start");
|
|
3387
|
+
const attached = await harness.control("turn.follow_up", { text: "attached" });
|
|
3388
|
+
expect(attached.ok).toBe(true);
|
|
3389
|
+
promoted?.({ startsOwnRun: false });
|
|
3390
|
+
const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
|
|
3391
|
+
const attachedIds = { commandId: attached.result?.commandId, turnId: attached.result?.turnId };
|
|
3392
|
+
await harness.emit("agent_failed", { error: Object.assign(new Error("generic"), { code: "agent_failed" }) });
|
|
3393
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "error" }] });
|
|
3394
|
+
expect(await settledStatus(harness, "turn.prompt_status", firstIds)).toMatchObject({
|
|
3395
|
+
status: "failed",
|
|
3396
|
+
error: { code: "provider_rejected" },
|
|
3397
|
+
});
|
|
3398
|
+
expect(await settledStatus(harness, "turn.prompt_status", attachedIds)).toMatchObject({
|
|
3399
|
+
status: "failed",
|
|
3400
|
+
error: { code: "provider_rejected" },
|
|
3401
|
+
});
|
|
3402
|
+
await harness.stop();
|
|
3403
|
+
} finally {
|
|
3404
|
+
await Bun.sleep(50);
|
|
3405
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3406
|
+
}
|
|
3407
|
+
});
|
|
3408
|
+
|
|
3409
|
+
test("does not misclassify a local malformed-tool circuit breaker as provider rejection", async () => {
|
|
3410
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-local-malformed-tool-"));
|
|
3411
|
+
try {
|
|
3412
|
+
const harness = await invocationHarness("local-malformed-tool", cwd, {
|
|
3413
|
+
sendUserMessage: async (_content, options) => {
|
|
3414
|
+
await options?.onPreflightAcceptCommit?.();
|
|
3415
|
+
await neverSettlingPromise();
|
|
3416
|
+
},
|
|
3417
|
+
});
|
|
3418
|
+
const accepted = await harness.control("turn.prompt", { text: "local malformed tool" });
|
|
3419
|
+
expect(accepted.ok).toBe(true);
|
|
3420
|
+
const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
|
|
3421
|
+
await harness.emit("agent_start");
|
|
3422
|
+
await harness.emit("agent_end", {
|
|
3423
|
+
messages: [
|
|
3424
|
+
{
|
|
3425
|
+
role: "assistant",
|
|
3426
|
+
stopReason: "error",
|
|
3427
|
+
errorMessage:
|
|
3428
|
+
"Stopping after 3 consecutive turns of malformed tool calls; the model did not produce a usable tool call or answer.",
|
|
3429
|
+
},
|
|
3430
|
+
],
|
|
3431
|
+
});
|
|
3432
|
+
expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({ status: "terminal_ok" });
|
|
3433
|
+
await harness.stop();
|
|
3434
|
+
} finally {
|
|
3435
|
+
await Bun.sleep(50);
|
|
3436
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3437
|
+
}
|
|
3438
|
+
});
|
|
3439
|
+
|
|
3440
|
+
test("does not misclassify a local composer policy breaker as provider rejection", async () => {
|
|
3441
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-local-composer-policy-"));
|
|
3442
|
+
try {
|
|
3443
|
+
const harness = await invocationHarness("local-composer-policy", cwd, {
|
|
3444
|
+
sendUserMessage: async (_content, options) => {
|
|
3445
|
+
await options?.onPreflightAcceptCommit?.();
|
|
3446
|
+
await neverSettlingPromise();
|
|
3447
|
+
},
|
|
3448
|
+
});
|
|
3449
|
+
const accepted = await harness.control("turn.prompt", { text: "local composer policy" });
|
|
3450
|
+
expect(accepted.ok).toBe(true);
|
|
3451
|
+
const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
|
|
3452
|
+
await harness.emit("agent_start");
|
|
3453
|
+
await harness.emit("agent_end", {
|
|
3454
|
+
messages: [
|
|
3455
|
+
{
|
|
3456
|
+
role: "assistant",
|
|
3457
|
+
stopReason: "error",
|
|
3458
|
+
errorMessage:
|
|
3459
|
+
"Composer bash policy blocked repository file I/O again after its one automatic recovery turn.",
|
|
3460
|
+
},
|
|
3461
|
+
],
|
|
3462
|
+
});
|
|
3463
|
+
expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({ status: "terminal_ok" });
|
|
3464
|
+
await harness.stop();
|
|
3465
|
+
} finally {
|
|
3466
|
+
await Bun.sleep(50);
|
|
3467
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3468
|
+
}
|
|
3469
|
+
});
|
|
3470
|
+
|
|
3471
|
+
test("terminalizes when provider end metadata exposes a throwing accessor", async () => {
|
|
3472
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-provider-throwing-metadata-"));
|
|
3473
|
+
try {
|
|
3474
|
+
const harness = await invocationHarness("provider-throwing-metadata", cwd, {
|
|
3475
|
+
sendUserMessage: async (_content, options) => {
|
|
3476
|
+
await options?.onPreflightAcceptCommit?.();
|
|
3477
|
+
await neverSettlingPromise();
|
|
3478
|
+
},
|
|
3479
|
+
});
|
|
3480
|
+
const accepted = await harness.control("turn.prompt", { text: "provider metadata" });
|
|
3481
|
+
expect(accepted.ok).toBe(true);
|
|
3482
|
+
const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
|
|
3483
|
+
await harness.emit("agent_start");
|
|
3484
|
+
const event = {} as Record<string, unknown>;
|
|
3485
|
+
Object.defineProperty(event, "messages", {
|
|
3486
|
+
get() {
|
|
3487
|
+
throw new Error("provider metadata accessor failed");
|
|
3488
|
+
},
|
|
3489
|
+
});
|
|
3490
|
+
await harness.emit("agent_end", event);
|
|
3491
|
+
expect(await settledStatus(harness, "turn.prompt_status", ids)).toMatchObject({
|
|
3492
|
+
status: "terminal_ok",
|
|
3493
|
+
});
|
|
3494
|
+
await harness.stop();
|
|
3495
|
+
} finally {
|
|
3496
|
+
await Bun.sleep(50);
|
|
3497
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3498
|
+
}
|
|
3499
|
+
});
|
|
3500
|
+
|
|
3501
|
+
test("binds a delayed predecessor provider failure to its own batch after a replacement starts", async () => {
|
|
3502
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-delayed-provider-batch-"));
|
|
3503
|
+
try {
|
|
3504
|
+
const firstInflight = Promise.withResolvers<void>();
|
|
3505
|
+
const secondInflight = Promise.withResolvers<void>();
|
|
3506
|
+
let prompts = 0;
|
|
3507
|
+
const harness = await invocationHarness("delayed-provider-batch", cwd, {
|
|
3508
|
+
sendUserMessage: async (_content, options) => {
|
|
3509
|
+
prompts += 1;
|
|
3510
|
+
await options?.onPreflightAcceptCommit?.();
|
|
3511
|
+
if (prompts === 1) await firstInflight.promise;
|
|
3512
|
+
if (prompts === 2) await secondInflight.promise;
|
|
3513
|
+
},
|
|
3514
|
+
persistInterceptor: () => {},
|
|
3515
|
+
});
|
|
3516
|
+
const first = await harness.control("turn.prompt", { text: "first", clientRef: "delayed-first" });
|
|
3517
|
+
expect(first.ok).toBe(true);
|
|
3518
|
+
const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
|
|
3519
|
+
await harness.emit("agent_start");
|
|
3520
|
+
const replacement = await harness.control("turn.abort_and_prompt", { text: "replacement" });
|
|
3521
|
+
expect(replacement).toMatchObject({ ok: true, result: { accepted: true } });
|
|
3522
|
+
const replacementIds = { commandId: replacement.result?.commandId, turnId: replacement.result?.turnId };
|
|
3523
|
+
await harness.emit("agent_start");
|
|
3524
|
+
expect((await harness.query("turn.prompt_status", replacementIds)).result?.status).toBe("in_flight");
|
|
3525
|
+
await harness.emit("agent_end", {
|
|
3526
|
+
messages: [{ role: "assistant", stopReason: "error", errorStatus: 402 }],
|
|
3527
|
+
});
|
|
3528
|
+
expect(await settledStatus(harness, "turn.prompt_status", firstIds)).toMatchObject({
|
|
3529
|
+
status: "failed",
|
|
3530
|
+
error: { code: "provider_http_402" },
|
|
3531
|
+
});
|
|
3532
|
+
expect((await harness.query("turn.prompt_status", replacementIds)).result?.status).toBe("in_flight");
|
|
3533
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "stop" }] });
|
|
3534
|
+
expect(await settledStatus(harness, "turn.prompt_status", replacementIds)).toMatchObject({
|
|
3535
|
+
status: "terminal_ok",
|
|
3536
|
+
});
|
|
3537
|
+
firstInflight.resolve();
|
|
3538
|
+
secondInflight.resolve();
|
|
3539
|
+
expect(prompts).toBe(2);
|
|
3540
|
+
await harness.stop();
|
|
3541
|
+
} finally {
|
|
3542
|
+
await Bun.sleep(50);
|
|
3543
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3544
|
+
}
|
|
3545
|
+
});
|
|
3546
|
+
|
|
3547
|
+
test.each([
|
|
3548
|
+
{ operation: "switchSession" as const, label: "session switch" },
|
|
3549
|
+
{ operation: "branch" as const, label: "session branch" },
|
|
3550
|
+
])("keeps a held 402/429 predecessor terminal across $label", async ({ operation }) => {
|
|
3551
|
+
for (const { status, code } of [
|
|
3552
|
+
{ status: 402, code: "provider_http_402" },
|
|
3553
|
+
{ status: 429, code: "provider_http_429" },
|
|
3554
|
+
]) {
|
|
3555
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-${operation}-provider-race-`));
|
|
3556
|
+
try {
|
|
3557
|
+
const entered = Promise.withResolvers<void>();
|
|
3558
|
+
const release = Promise.withResolvers<void>();
|
|
3559
|
+
const firstInflight = Promise.withResolvers<void>();
|
|
3560
|
+
const secondInflight = Promise.withResolvers<void>();
|
|
3561
|
+
let timeoutWarnings = 0;
|
|
3562
|
+
const diagnosticKeyCounts: number[] = [];
|
|
3563
|
+
let prompts = 0;
|
|
3564
|
+
const harness = await invocationHarness(`${operation}-provider-race`, cwd, {
|
|
3565
|
+
persistInterceptor: () => {},
|
|
3566
|
+
onLifecycleDrainTimeout: () => {
|
|
3567
|
+
timeoutWarnings += 1;
|
|
3568
|
+
},
|
|
3569
|
+
onFailureDiagnosticKeyCount: count => {
|
|
3570
|
+
diagnosticKeyCounts.push(count);
|
|
3571
|
+
},
|
|
3572
|
+
persistHold: { type: "agent_failed", onEntered: entered.resolve, release: release.promise },
|
|
3573
|
+
agentFailedWriteFailures: 0,
|
|
3574
|
+
sendUserMessage: async (_content, options) => {
|
|
3575
|
+
prompts += 1;
|
|
3576
|
+
await options?.onPreflightAcceptCommit?.();
|
|
3577
|
+
if (prompts === 1) await firstInflight.promise;
|
|
3578
|
+
if (prompts === 2) await secondInflight.promise;
|
|
3579
|
+
},
|
|
3580
|
+
});
|
|
3581
|
+
const first = await harness.control("turn.prompt", {
|
|
3582
|
+
text: "first",
|
|
3583
|
+
clientRef: `${operation}-${status}-first`,
|
|
3584
|
+
});
|
|
3585
|
+
expect(first.ok).toBe(true);
|
|
3586
|
+
const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
|
|
3587
|
+
await harness.emit("agent_start");
|
|
3588
|
+
const end = harness.emit("agent_end", {
|
|
3589
|
+
messages: [{ role: "assistant", stopReason: "error", errorStatus: status }],
|
|
3590
|
+
});
|
|
3591
|
+
await entered.promise;
|
|
3592
|
+
const lifecycleChange = harness[operation](`${operation}-successor`);
|
|
3593
|
+
await Bun.sleep(10);
|
|
3594
|
+
const [oldControl, oldQuery, oldReplay, oldProvider, oldReverse] = await Promise.all([
|
|
3595
|
+
harness.requestOnSession(`${operation}-provider-race`, {
|
|
3596
|
+
type: "control_request",
|
|
3597
|
+
operation: "turn.prompt",
|
|
3598
|
+
input: {},
|
|
3599
|
+
}),
|
|
3600
|
+
harness.requestOnSession(`${operation}-provider-race`, {
|
|
3601
|
+
type: "query_request",
|
|
3602
|
+
query: "turn.prompt_status",
|
|
3603
|
+
input: {},
|
|
3604
|
+
}),
|
|
3605
|
+
harness.requestOnSession(`${operation}-provider-race`, {
|
|
3606
|
+
type: "event_replay",
|
|
3607
|
+
sinceGeneration: 0,
|
|
3608
|
+
sinceSeq: 0,
|
|
3609
|
+
}),
|
|
3610
|
+
harness.requestOnSession(`${operation}-provider-race`, {
|
|
3611
|
+
type: "register_provider",
|
|
3612
|
+
capability: "fs",
|
|
3613
|
+
definitions: {},
|
|
3614
|
+
}),
|
|
3615
|
+
harness.requestOnSession(`${operation}-provider-race`, {
|
|
3616
|
+
type: "reverse_response",
|
|
3617
|
+
leaseId: "old-lease",
|
|
3618
|
+
ok: true,
|
|
3619
|
+
result: {},
|
|
3620
|
+
}),
|
|
3621
|
+
]);
|
|
3622
|
+
harness.sendToSession(`${operation}-provider-race`, { type: "provider_heartbeat", leaseId: "old-lease" });
|
|
3623
|
+
harness.sendToSession(`${operation}-provider-race`, { type: "lease_release", leaseId: "old-lease" });
|
|
3624
|
+
expect(
|
|
3625
|
+
[oldControl, oldQuery, oldReplay, oldProvider, oldReverse].map(
|
|
3626
|
+
response => response.error?.code ?? response.code,
|
|
3627
|
+
),
|
|
3628
|
+
).toEqual([
|
|
3629
|
+
"session_quiescing",
|
|
3630
|
+
"session_quiescing",
|
|
3631
|
+
"session_quiescing",
|
|
3632
|
+
"session_quiescing",
|
|
3633
|
+
"session_quiescing",
|
|
3634
|
+
]);
|
|
3635
|
+
expect(
|
|
3636
|
+
harness.sent(`${operation}-provider-race`).filter(frame => frame.type === "transport_error"),
|
|
3637
|
+
).toHaveLength(3);
|
|
3638
|
+
release.resolve();
|
|
3639
|
+
await Promise.all([end, lifecycleChange]);
|
|
3640
|
+
expect(timeoutWarnings).toBe(0);
|
|
3641
|
+
expect(diagnosticKeyCounts.at(-1)).toBe(0);
|
|
3642
|
+
const failure = harness.broadcasts.find(frame => {
|
|
3643
|
+
const payload = frame.payload;
|
|
3644
|
+
return (
|
|
3645
|
+
frame.kind === "agent_failed" &&
|
|
3646
|
+
typeof payload === "object" &&
|
|
3647
|
+
payload !== null &&
|
|
3648
|
+
(payload as { commandId?: unknown }).commandId === firstIds.commandId &&
|
|
3649
|
+
(payload as { turnId?: unknown }).turnId === firstIds.turnId
|
|
3650
|
+
);
|
|
3651
|
+
});
|
|
3652
|
+
expect(failure).toMatchObject({ kind: "agent_failed", payload: { error: { code } } });
|
|
3653
|
+
expect(harness.broadcasts.filter(frame => frame.kind === "agent_end").length).toBeGreaterThan(0);
|
|
3654
|
+
|
|
3655
|
+
const successor = await harness.control("turn.prompt", { text: "successor" });
|
|
3656
|
+
expect(successor.ok).toBe(true);
|
|
3657
|
+
const successorIds = { commandId: successor.result?.commandId, turnId: successor.result?.turnId };
|
|
3658
|
+
await harness.emit("agent_start");
|
|
3659
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "stop" }] });
|
|
3660
|
+
secondInflight.resolve();
|
|
3661
|
+
expect(await settledStatus(harness, "turn.prompt_status", successorIds)).toMatchObject({
|
|
3662
|
+
status: "terminal_ok",
|
|
3663
|
+
});
|
|
3664
|
+
firstInflight.resolve();
|
|
3665
|
+
expect(prompts).toBe(2);
|
|
3666
|
+
await harness.stop();
|
|
3667
|
+
} finally {
|
|
3668
|
+
await Bun.sleep(50);
|
|
3669
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3670
|
+
}
|
|
3671
|
+
}
|
|
3672
|
+
});
|
|
3673
|
+
|
|
3674
|
+
test.each([
|
|
3675
|
+
{ operation: "switchSession" as const, label: "session switch" },
|
|
3676
|
+
{ operation: "branch" as const, label: "session branch" },
|
|
3677
|
+
])("bounds a stalled 402/429 lifecycle drain during $label", async ({ operation }) => {
|
|
3678
|
+
for (const status of [402, 429]) {
|
|
3679
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-${operation}-provider-stall-`));
|
|
3680
|
+
try {
|
|
3681
|
+
const entered = Promise.withResolvers<void>();
|
|
3682
|
+
const release = Promise.withResolvers<void>();
|
|
3683
|
+
const firstInflight = Promise.withResolvers<void>();
|
|
3684
|
+
let timeoutWarnings = 0;
|
|
3685
|
+
const harness = await invocationHarness(`${operation}-provider-stall`, cwd, {
|
|
3686
|
+
persistInterceptor: () => {},
|
|
3687
|
+
onLifecycleDrainTimeout: () => {
|
|
3688
|
+
timeoutWarnings += 1;
|
|
3689
|
+
},
|
|
3690
|
+
persistHold: { type: "agent_failed", onEntered: entered.resolve, release: release.promise },
|
|
3691
|
+
agentFailedWriteFailures: 0,
|
|
3692
|
+
sendUserMessage: async (_content, options) => {
|
|
3693
|
+
await options?.onPreflightAcceptCommit?.();
|
|
3694
|
+
await firstInflight.promise;
|
|
3695
|
+
},
|
|
3696
|
+
});
|
|
3697
|
+
const first = await harness.control("turn.prompt", { text: "first" });
|
|
3698
|
+
expect(first.ok).toBe(true);
|
|
3699
|
+
await harness.emit("agent_start");
|
|
3700
|
+
const end = harness.emit("agent_end", {
|
|
3701
|
+
messages: [{ role: "assistant", stopReason: "error", errorStatus: status }],
|
|
3702
|
+
});
|
|
3703
|
+
await entered.promise;
|
|
3704
|
+
const startedAt = Date.now();
|
|
3705
|
+
const lifecycleChange = harness[operation](`${operation}-stall-successor`);
|
|
3706
|
+
await lifecycleChange;
|
|
3707
|
+
expect(Date.now() - startedAt).toBeLessThan(1_500);
|
|
3708
|
+
expect(timeoutWarnings).toBe(1);
|
|
3709
|
+
release.resolve();
|
|
3710
|
+
firstInflight.resolve();
|
|
3711
|
+
await end;
|
|
3712
|
+
await harness.stop();
|
|
3713
|
+
} finally {
|
|
3714
|
+
await Bun.sleep(50);
|
|
3715
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3716
|
+
}
|
|
3717
|
+
}
|
|
3718
|
+
});
|
|
3719
|
+
|
|
3720
|
+
test("deduplicates repeated agent_failed diagnostics for one invocation", async () => {
|
|
3721
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-agent-failed-dedupe-"));
|
|
3722
|
+
try {
|
|
3723
|
+
const inflight = Promise.withResolvers<void>();
|
|
3724
|
+
const harness = await invocationHarness("agent-failed-dedupe", cwd, {
|
|
3725
|
+
sendUserMessage: async (_content, options) => {
|
|
3726
|
+
await options?.onPreflightAcceptCommit?.();
|
|
3727
|
+
await inflight.promise;
|
|
3728
|
+
},
|
|
3729
|
+
});
|
|
3730
|
+
const accepted = await harness.control("turn.prompt", { text: "hello" });
|
|
3731
|
+
expect(accepted.ok).toBe(true);
|
|
3732
|
+
const correlation = accepted.result;
|
|
3733
|
+
await harness.emit("agent_start");
|
|
3734
|
+
const failure = { error: Object.assign(new Error("provider failed"), { code: "provider_rejected" }) };
|
|
3735
|
+
await harness.emit("agent_failed", failure);
|
|
3736
|
+
await harness.emit("agent_failed", failure);
|
|
3737
|
+
expect(
|
|
3738
|
+
harness.broadcasts.filter(
|
|
3739
|
+
frame =>
|
|
3740
|
+
frame.kind === "agent_failed" &&
|
|
3741
|
+
(frame.payload as { commandId?: string }).commandId === correlation?.commandId &&
|
|
3742
|
+
(frame.payload as { turnId?: string }).turnId === correlation?.turnId,
|
|
3743
|
+
),
|
|
3744
|
+
).toHaveLength(1);
|
|
3745
|
+
await harness.emit("agent_end");
|
|
3746
|
+
inflight.resolve();
|
|
3747
|
+
await harness.stop();
|
|
3748
|
+
} finally {
|
|
3749
|
+
await Bun.sleep(50);
|
|
3750
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3751
|
+
}
|
|
3752
|
+
});
|
|
3753
|
+
|
|
3754
|
+
test("routes a delayed predecessor agent_end after replacement completion to the retired owner", async () => {
|
|
3755
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-delayed-retired-owner-"));
|
|
3756
|
+
try {
|
|
3757
|
+
const firstInflight = Promise.withResolvers<void>();
|
|
3758
|
+
const secondInflight = Promise.withResolvers<void>();
|
|
3759
|
+
let prompts = 0;
|
|
3760
|
+
const harness = await invocationHarness("retired-owner-a", cwd, {
|
|
3761
|
+
persistInterceptor: () => {},
|
|
3762
|
+
sendUserMessage: async (_content, options) => {
|
|
3763
|
+
prompts += 1;
|
|
3764
|
+
await options?.onPreflightAcceptCommit?.();
|
|
3765
|
+
if (prompts === 1) await firstInflight.promise;
|
|
3766
|
+
if (prompts === 2) await secondInflight.promise;
|
|
3767
|
+
},
|
|
3768
|
+
});
|
|
3769
|
+
const first = await harness.control("turn.prompt", { text: "first" });
|
|
3770
|
+
expect(first.ok).toBe(true);
|
|
3771
|
+
const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
|
|
3772
|
+
await harness.emit("agent_start");
|
|
3773
|
+
await harness.switchSession("retired-owner-b");
|
|
3774
|
+
await harness.emit("agent_end", {
|
|
3775
|
+
messages: [{ role: "assistant", stopReason: "error", errorStatus: 402 }],
|
|
3776
|
+
});
|
|
3777
|
+
const failure = harness.broadcasts.find(frame => {
|
|
3778
|
+
const payload = frame.payload;
|
|
3779
|
+
return (
|
|
3780
|
+
frame.kind === "agent_failed" &&
|
|
3781
|
+
typeof payload === "object" &&
|
|
3782
|
+
payload !== null &&
|
|
3783
|
+
(payload as { commandId?: unknown }).commandId === firstIds.commandId &&
|
|
3784
|
+
(payload as { turnId?: unknown }).turnId === firstIds.turnId
|
|
3785
|
+
);
|
|
3786
|
+
});
|
|
3787
|
+
expect(failure).toMatchObject({ kind: "agent_failed", payload: { error: { code: "provider_http_402" } } });
|
|
3788
|
+
const successor = await harness.control("turn.prompt", { text: "successor" });
|
|
3789
|
+
expect(successor.ok).toBe(true);
|
|
3790
|
+
const successorIds = { commandId: successor.result?.commandId, turnId: successor.result?.turnId };
|
|
3791
|
+
await harness.emit("agent_start");
|
|
3792
|
+
await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "stop" }] });
|
|
3793
|
+
secondInflight.resolve();
|
|
3794
|
+
expect(await settledStatus(harness, "turn.prompt_status", successorIds)).toMatchObject({
|
|
3795
|
+
status: "terminal_ok",
|
|
3796
|
+
});
|
|
3797
|
+
firstInflight.resolve();
|
|
3798
|
+
expect(prompts).toBe(2);
|
|
3799
|
+
await harness.stop();
|
|
3800
|
+
} finally {
|
|
3801
|
+
await Bun.sleep(50);
|
|
3802
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3803
|
+
}
|
|
3804
|
+
});
|
|
3805
|
+
|
|
3806
|
+
test("fails closed for tokenless delayed events when a session id is reused", async () => {
|
|
3807
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-reused-session-retired-owner-"));
|
|
3808
|
+
try {
|
|
3809
|
+
const firstInflight = Promise.withResolvers<void>();
|
|
3810
|
+
const harness = await invocationHarness("reused-session", cwd, {
|
|
3811
|
+
persistInterceptor: () => {},
|
|
3812
|
+
sendUserMessage: async (_content, options) => {
|
|
3813
|
+
await options?.onPreflightAcceptCommit?.();
|
|
3814
|
+
await firstInflight.promise;
|
|
3815
|
+
},
|
|
3816
|
+
});
|
|
3817
|
+
const first = await harness.control("turn.prompt", { text: "first" });
|
|
3818
|
+
expect(first.ok).toBe(true);
|
|
3819
|
+
const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
|
|
3820
|
+
await harness.emit("agent_start");
|
|
3821
|
+
await harness.switchSession("different-session");
|
|
3822
|
+
await harness.switchSession("reused-session");
|
|
3823
|
+
await harness.emit("agent_start");
|
|
3824
|
+
await harness.emit("agent_end", {
|
|
3825
|
+
messages: [{ role: "assistant", stopReason: "error", errorStatus: 402 }],
|
|
3826
|
+
});
|
|
3827
|
+
const failure = harness.broadcasts.find(frame => {
|
|
3828
|
+
const payload = frame.payload;
|
|
3829
|
+
return (
|
|
3830
|
+
frame.kind === "agent_failed" &&
|
|
3831
|
+
typeof payload === "object" &&
|
|
3832
|
+
payload !== null &&
|
|
3833
|
+
(payload as { commandId?: unknown }).commandId === firstIds.commandId &&
|
|
3834
|
+
(payload as { turnId?: unknown }).turnId === firstIds.turnId
|
|
3835
|
+
);
|
|
3836
|
+
});
|
|
3837
|
+
expect(failure).toBeUndefined();
|
|
3838
|
+
firstInflight.resolve();
|
|
3839
|
+
await harness.stop();
|
|
3840
|
+
} finally {
|
|
3841
|
+
await Bun.sleep(50);
|
|
3842
|
+
await rm(cwd, { recursive: true, force: true });
|
|
3843
|
+
}
|
|
3844
|
+
});
|
|
3845
|
+
|
|
3138
3846
|
test("a prompt killed by a provider stream interrupt reports a terminal failed status", async () => {
|
|
3139
3847
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-prompt-"));
|
|
3140
3848
|
try {
|
|
3141
3849
|
const harness = await invocationHarness("terminalize-prompt", cwd, {
|
|
3142
3850
|
sendUserMessage: async (_content, options) => {
|
|
3143
3851
|
await options?.onPreflightAcceptCommit?.();
|
|
3144
|
-
await
|
|
3852
|
+
await neverSettlingPromise();
|
|
3145
3853
|
},
|
|
3146
3854
|
});
|
|
3147
3855
|
const accepted = await harness.control("turn.prompt", { text: "hello" });
|
|
@@ -3172,7 +3880,7 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
3172
3880
|
const harness = await invocationHarness("terminalize-abort", cwd, {
|
|
3173
3881
|
sendUserMessage: async (_content, options) => {
|
|
3174
3882
|
await options?.onPreflightAcceptCommit?.();
|
|
3175
|
-
await
|
|
3883
|
+
await neverSettlingPromise();
|
|
3176
3884
|
},
|
|
3177
3885
|
abort: () => {},
|
|
3178
3886
|
});
|
|
@@ -3293,6 +4001,55 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
3293
4001
|
}
|
|
3294
4002
|
});
|
|
3295
4003
|
|
|
4004
|
+
test("reused-session successor tool progress renews its active deadline", async () => {
|
|
4005
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-reused-successor-progress-"));
|
|
4006
|
+
try {
|
|
4007
|
+
const firstInflight = Promise.withResolvers<void>();
|
|
4008
|
+
const successorStarted = Promise.withResolvers<void>();
|
|
4009
|
+
let prompts = 0;
|
|
4010
|
+
const harness = await invocationHarness("reused-successor-progress", cwd, {
|
|
4011
|
+
settings: {
|
|
4012
|
+
get: (key: string) =>
|
|
4013
|
+
key === "sdk.promptDeadlineMs" ? 25 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
|
|
4014
|
+
} as unknown as Settings,
|
|
4015
|
+
sendUserMessage: async (_content, options) => {
|
|
4016
|
+
prompts += 1;
|
|
4017
|
+
await options?.onPreflightAcceptCommit?.();
|
|
4018
|
+
if (prompts === 1) {
|
|
4019
|
+
await firstInflight.promise;
|
|
4020
|
+
return;
|
|
4021
|
+
}
|
|
4022
|
+
successorStarted.resolve();
|
|
4023
|
+
await Promise.withResolvers<void>().promise;
|
|
4024
|
+
},
|
|
4025
|
+
});
|
|
4026
|
+
const first = await harness.control("turn.prompt", { text: "first" });
|
|
4027
|
+
expect(first.ok).toBe(true);
|
|
4028
|
+
await harness.emit("agent_start");
|
|
4029
|
+
await harness.switchSession("reused-successor-progress-other");
|
|
4030
|
+
await harness.switchSession("reused-successor-progress");
|
|
4031
|
+
const successor = await harness.control("turn.prompt", { text: "successor" });
|
|
4032
|
+
expect(successor.ok).toBe(true);
|
|
4033
|
+
const successorIds = { commandId: successor.result?.commandId, turnId: successor.result?.turnId };
|
|
4034
|
+
await harness.emit("agent_start");
|
|
4035
|
+
await successorStarted.promise;
|
|
4036
|
+
for (let index = 0; index < 4; index += 1) {
|
|
4037
|
+
await harness.emit("tool_execution_start");
|
|
4038
|
+
await Bun.sleep(15);
|
|
4039
|
+
}
|
|
4040
|
+
expect(await harness.query("turn.prompt_status", successorIds)).toMatchObject({
|
|
4041
|
+
result: { status: expect.stringMatching(/accepted|in_flight/) },
|
|
4042
|
+
});
|
|
4043
|
+
await harness.emit("agent_end");
|
|
4044
|
+
firstInflight.resolve();
|
|
4045
|
+
await harness.stop();
|
|
4046
|
+
expect(prompts).toBe(2);
|
|
4047
|
+
} finally {
|
|
4048
|
+
await Bun.sleep(50);
|
|
4049
|
+
await rm(cwd, { recursive: true, force: true });
|
|
4050
|
+
}
|
|
4051
|
+
});
|
|
4052
|
+
|
|
3296
4053
|
test("abort_and_prompt starts exactly one successor and is not duplicated by delayed abort teardown", async () => {
|
|
3297
4054
|
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-abort-and-prompt-once-"));
|
|
3298
4055
|
try {
|
|
@@ -3339,7 +4096,7 @@ describe("post-acceptance invocation terminalization", () => {
|
|
|
3339
4096
|
const harness = await invocationHarness("terminalize-skill", cwd, {
|
|
3340
4097
|
invokeSkill: async (_name, _args, options) => {
|
|
3341
4098
|
await options?.onPreflightAcceptCommit?.();
|
|
3342
|
-
await
|
|
4099
|
+
await neverSettlingPromise();
|
|
3343
4100
|
},
|
|
3344
4101
|
});
|
|
3345
4102
|
const accepted = await harness.control("skill.invoke", { name: "ralplan" });
|
|
@@ -3563,7 +4320,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
3563
4320
|
sendUserMessage: async (_content, options) => {
|
|
3564
4321
|
await options?.onPreflightAcceptCommit?.();
|
|
3565
4322
|
// Accepted, then permanently no execution progress and no agent_start.
|
|
3566
|
-
await
|
|
4323
|
+
await neverSettlingPromise();
|
|
3567
4324
|
},
|
|
3568
4325
|
});
|
|
3569
4326
|
const accepted = await harness.control("turn.prompt", { text: "hello" });
|
|
@@ -3597,7 +4354,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
3597
4354
|
settings: zeroProgressSettings,
|
|
3598
4355
|
sendUserMessage: async (_content, options) => {
|
|
3599
4356
|
await options?.onPreflightAcceptCommit?.();
|
|
3600
|
-
await
|
|
4357
|
+
await neverSettlingPromise();
|
|
3601
4358
|
},
|
|
3602
4359
|
});
|
|
3603
4360
|
const original = await harness.control("turn.prompt", { text: "original" });
|
|
@@ -3731,7 +4488,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
3731
4488
|
return;
|
|
3732
4489
|
}
|
|
3733
4490
|
// The first turn accepts and then never makes progress.
|
|
3734
|
-
await
|
|
4491
|
+
await neverSettlingPromise();
|
|
3735
4492
|
},
|
|
3736
4493
|
});
|
|
3737
4494
|
const first = await harness.control("turn.prompt", { text: "first" });
|
|
@@ -3817,7 +4574,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
3817
4574
|
)?.onQueuedPromoted;
|
|
3818
4575
|
return;
|
|
3819
4576
|
}
|
|
3820
|
-
await
|
|
4577
|
+
await neverSettlingPromise();
|
|
3821
4578
|
},
|
|
3822
4579
|
});
|
|
3823
4580
|
const first = await harness.control("turn.prompt", { text: "first" });
|
|
@@ -3873,7 +4630,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
3873
4630
|
promoted = options?.onQueuedPromoted;
|
|
3874
4631
|
return;
|
|
3875
4632
|
}
|
|
3876
|
-
await
|
|
4633
|
+
await neverSettlingPromise();
|
|
3877
4634
|
},
|
|
3878
4635
|
} as unknown as ExtensionAPI;
|
|
3879
4636
|
const transport = memoryTransport();
|
|
@@ -3994,7 +4751,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
3994
4751
|
return;
|
|
3995
4752
|
}
|
|
3996
4753
|
// The first turn accepts and then keeps streaming.
|
|
3997
|
-
await
|
|
4754
|
+
await neverSettlingPromise();
|
|
3998
4755
|
},
|
|
3999
4756
|
});
|
|
4000
4757
|
const first = await harness.control("turn.prompt", { text: "first" });
|
|
@@ -4052,7 +4809,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
4052
4809
|
return;
|
|
4053
4810
|
}
|
|
4054
4811
|
// The first turn accepts and then keeps streaming.
|
|
4055
|
-
await
|
|
4812
|
+
await neverSettlingPromise();
|
|
4056
4813
|
},
|
|
4057
4814
|
});
|
|
4058
4815
|
const first = await harness.control("turn.prompt", { text: "first" });
|
|
@@ -4128,7 +4885,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
4128
4885
|
} as unknown as Settings,
|
|
4129
4886
|
sendUserMessage: async (_content, options) => {
|
|
4130
4887
|
await options?.onPreflightAcceptCommit?.();
|
|
4131
|
-
await
|
|
4888
|
+
await neverSettlingPromise();
|
|
4132
4889
|
},
|
|
4133
4890
|
persistInterceptor: transition => {
|
|
4134
4891
|
if (transition.type === "agent_failed") {
|
|
@@ -4212,7 +4969,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
4212
4969
|
const harness = await invocationHarness("skill-terminal-retry", cwd, {
|
|
4213
4970
|
invokeSkill: async (_name, _args, options) => {
|
|
4214
4971
|
await options?.onPreflightAcceptCommit?.();
|
|
4215
|
-
await
|
|
4972
|
+
await neverSettlingPromise();
|
|
4216
4973
|
},
|
|
4217
4974
|
persistInterceptor: transition => {
|
|
4218
4975
|
if (transition.type === "agent_end" && failedWrites < 2) {
|
|
@@ -4245,6 +5002,48 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
4245
5002
|
}
|
|
4246
5003
|
});
|
|
4247
5004
|
|
|
5005
|
+
test("retains skill terminal recovery across session replacement", async () => {
|
|
5006
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-skill-terminal-replacement-"));
|
|
5007
|
+
let failFirstTerminalWrite = true;
|
|
5008
|
+
try {
|
|
5009
|
+
const harness = await invocationHarness("skill-terminal-replacement", cwd, {
|
|
5010
|
+
invokeSkill: async (_name, _args, options) => {
|
|
5011
|
+
await options?.onPreflightAcceptCommit?.();
|
|
5012
|
+
await neverSettlingPromise();
|
|
5013
|
+
},
|
|
5014
|
+
persistInterceptor: transition => {
|
|
5015
|
+
if (transition.type === "agent_end" && failFirstTerminalWrite) {
|
|
5016
|
+
failFirstTerminalWrite = false;
|
|
5017
|
+
throw Object.assign(new Error("injected terminal persistence failure"), { code: "io_error" });
|
|
5018
|
+
}
|
|
5019
|
+
},
|
|
5020
|
+
});
|
|
5021
|
+
const submitted = await harness.control("skill.invoke", {
|
|
5022
|
+
name: "replacement-hang",
|
|
5023
|
+
args: "",
|
|
5024
|
+
clientRef: "skill-terminal-replacement-ref",
|
|
5025
|
+
});
|
|
5026
|
+
expect(submitted.ok).toBe(true);
|
|
5027
|
+
await harness.emit("agent_start");
|
|
5028
|
+
await harness.emit("agent_end");
|
|
5029
|
+
await Bun.sleep(50);
|
|
5030
|
+
await harness.switchSession("skill-terminal-replacement-next");
|
|
5031
|
+
await Bun.sleep(1_100);
|
|
5032
|
+
let settled: { status?: string; error?: { code?: string } } | undefined;
|
|
5033
|
+
for (let attempt = 0; attempt < 500 && settled === undefined; attempt += 1) {
|
|
5034
|
+
const frame = await harness.query("skill.invoke_status", { clientRef: "skill-terminal-replacement-ref" });
|
|
5035
|
+
const result = frame.result as { status?: string; error?: { code?: string } } | undefined;
|
|
5036
|
+
if (result?.status === "terminal_ok" || result?.status === "failed") settled = result;
|
|
5037
|
+
else await Bun.sleep(10);
|
|
5038
|
+
}
|
|
5039
|
+
expect(settled?.status).toBe("terminal_ok");
|
|
5040
|
+
await harness.stop();
|
|
5041
|
+
} finally {
|
|
5042
|
+
await Bun.sleep(50);
|
|
5043
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5044
|
+
}
|
|
5045
|
+
});
|
|
5046
|
+
|
|
4248
5047
|
test("a rejected prompt replays its failure reason, never a bare terminal_ok", async () => {
|
|
4249
5048
|
// Exact-head review HIGH: after failed agent_failed persistence, recovery
|
|
4250
5049
|
// replayed only agent_end, so an abandoned/rejected prompt became durable
|
|
@@ -4335,7 +5134,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
4335
5134
|
)?.onQueuedPromoted;
|
|
4336
5135
|
return;
|
|
4337
5136
|
}
|
|
4338
|
-
await
|
|
5137
|
+
await neverSettlingPromise();
|
|
4339
5138
|
},
|
|
4340
5139
|
});
|
|
4341
5140
|
const first = await harness.control("turn.prompt", { text: "first" });
|
|
@@ -4369,7 +5168,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
4369
5168
|
sendUserMessage: async (_content, options) => {
|
|
4370
5169
|
await options?.onPreflightAcceptCommit?.();
|
|
4371
5170
|
// The failing turn accepts and then never makes progress on its own.
|
|
4372
|
-
await
|
|
5171
|
+
await neverSettlingPromise();
|
|
4373
5172
|
},
|
|
4374
5173
|
});
|
|
4375
5174
|
const failing = await harness.control("turn.prompt", { text: "failing" });
|
|
@@ -4420,7 +5219,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
4420
5219
|
options: { onPreflightAcceptCommit?: () => Promise<void> } | undefined,
|
|
4421
5220
|
) => {
|
|
4422
5221
|
await options?.onPreflightAcceptCommit?.();
|
|
4423
|
-
if (content === "hangs") await
|
|
5222
|
+
if (content === "hangs") await neverSettlingPromise();
|
|
4424
5223
|
},
|
|
4425
5224
|
} as unknown as ExtensionAPI;
|
|
4426
5225
|
const transport = memoryTransport();
|
|
@@ -4818,7 +5617,7 @@ test("SDK-only host keeps the idle-submitted prompt's owner when isIdle flips du
|
|
|
4818
5617
|
// Production-faithful: the accepted run stays in-flight; sendUserMessage
|
|
4819
5618
|
// resolution (turn completion) never precedes agent_start (#4668
|
|
4820
5619
|
// success-retirement).
|
|
4821
|
-
await
|
|
5620
|
+
await neverSettlingPromise();
|
|
4822
5621
|
},
|
|
4823
5622
|
} as unknown as ExtensionAPI;
|
|
4824
5623
|
const transport = memoryTransport();
|