Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
- All of `bun fmt`, `bun lint`, and `bun typecheck` must pass before considering tasks completed.
- NEVER run `bun test`. Always use `bun run test` (runs Vitest).

## Bun Gotcha

- In this environment, `bun` may not be on `PATH` even though Bun is installed at `/home/claude/.bun/bin/bun`.
- If plain `bun ...` fails with `bun: command not found`, use the absolute binary path instead.
- For `bun typecheck`, also prepend Bun to `PATH` so Turbo can find the package manager binary:
`env PATH="/home/claude/.bun/bin:$PATH" /home/claude/.bun/bin/bun typecheck`
- `bun fmt` and `bun lint` can be run directly with `/home/claude/.bun/bin/bun fmt` and `/home/claude/.bun/bin/bun lint`.

## Project Snapshot

T3 Code is a minimal web GUI for using code agents like Codex and Claude Code (coming soon).
Expand Down
111 changes: 111 additions & 0 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,117 @@ describe("ProviderCommandReactor", () => {
});
});

it("surfaces stale provider user-input failures with a clear recovery message", async () => {
const harness = await createHarness();
const now = new Date().toISOString();
harness.respondToUserInput.mockImplementation(() =>
Effect.fail(
new ProviderAdapterRequestError({
provider: "codex",
method: "item/tool/requestUserInput",
detail: "Unknown pending user input request: user-input-request-1",
}),
),
);

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.session.set",
commandId: CommandId.makeUnsafe("cmd-session-set-for-user-input-error"),
threadId: ThreadId.makeUnsafe("thread-1"),
session: {
threadId: ThreadId.makeUnsafe("thread-1"),
status: "running",
providerName: "codex",
runtimeMode: "approval-required",
activeTurnId: null,
lastError: null,
updatedAt: now,
},
createdAt: now,
}),
);

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.activity.append",
commandId: CommandId.makeUnsafe("cmd-user-input-requested"),
threadId: ThreadId.makeUnsafe("thread-1"),
activity: {
id: EventId.makeUnsafe("activity-user-input-requested"),
tone: "info",
kind: "user-input.requested",
summary: "User input requested",
payload: {
requestId: "user-input-request-1",
questions: [
{
id: "sandbox_mode",
header: "Sandbox",
question: "Which mode should be used?",
options: [
{
label: "workspace-write",
description: "Allow workspace writes only",
},
],
},
],
},
turnId: null,
createdAt: now,
},
createdAt: now,
}),
);

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.user-input.respond",
commandId: CommandId.makeUnsafe("cmd-user-input-respond-stale"),
threadId: ThreadId.makeUnsafe("thread-1"),
requestId: asApprovalRequestId("user-input-request-1"),
answers: {
sandbox_mode: "workspace-write",
},
createdAt: now,
}),
);

await waitFor(async () => {
const readModel = await Effect.runPromise(harness.engine.getReadModel());
const thread = readModel.threads.find(
(entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
);
if (!thread) return false;
return thread.activities.some(
(activity) => activity.kind === "provider.user-input.respond.failed",
);
});

const readModel = await Effect.runPromise(harness.engine.getReadModel());
const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
expect(thread).toBeDefined();

const failureActivity = thread?.activities.find(
(activity) => activity.kind === "provider.user-input.respond.failed",
);
expect(failureActivity?.payload).toMatchObject({
requestId: "user-input-request-1",
detail:
"This question came from an earlier provider session and expired after the session restarted. Please ask the agent to re-ask the question.",
});

const resolvedActivity = thread?.activities.find(
(activity) =>
activity.kind === "user-input.resolved" &&
typeof activity.payload === "object" &&
activity.payload !== null &&
(activity.payload as Record<string, unknown>).requestId === "user-input-request-1",
);
expect(resolvedActivity).toBeUndefined();
});

it("surfaces stale provider approval request failures without faking approval resolution", async () => {
const harness = await createHarness();
const now = new Date().toISOString();
Expand Down
24 changes: 23 additions & 1 deletion apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,28 @@ function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderService
);
}

function isStalePendingUserInputError(cause: Cause.Cause<ProviderServiceError>): boolean {
const error = Cause.squash(cause);
if (Schema.is(ProviderAdapterRequestError)(error)) {
return error.detail.toLowerCase().includes("unknown pending user input request");
}
const message = Cause.pretty(cause).toLowerCase();
return (
message.includes("unknown pending user input request") ||
message.includes("belongs to a previous provider session")
);
}

function describePendingUserInputFailure(cause: Cause.Cause<ProviderServiceError>): string {
if (isStalePendingUserInputError(cause)) {
return (
"This question came from an earlier provider session and expired after the session restarted. " +
"Please ask the agent to re-ask the question."
);
}
return Cause.pretty(cause);
}

function isTemporaryWorktreeBranch(branch: string): boolean {
return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase());
}
Expand Down Expand Up @@ -578,7 +600,7 @@ const make = Effect.gen(function* () {
threadId: event.payload.threadId,
kind: "provider.user-input.respond.failed",
summary: "Provider user input response failed",
detail: Cause.pretty(cause),
detail: describePendingUserInputFailure(cause),
turnId: null,
createdAt: event.payload.createdAt,
requestId: event.payload.requestId,
Expand Down
36 changes: 36 additions & 0 deletions apps/server/src/provider/Layers/ProviderService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,42 @@ routing.layer("ProviderServiceLive routing", (it) => {
}),
);

it.effect("fails fast for user input replies after the provider session is gone", () =>
Effect.gen(function* () {
const provider = yield* ProviderService;

const session = yield* provider.startSession(asThreadId("thread-user-input-stale"), {
provider: "codex",
threadId: asThreadId("thread-user-input-stale"),
runtimeMode: "full-access",
});
yield* routing.codex.stopSession(session.threadId);
routing.codex.startSession.mockClear();
routing.codex.respondToUserInput.mockClear();

const response = yield* Effect.result(
provider.respondToUserInput({
threadId: session.threadId,
requestId: asRequestId("req-user-input-stale"),
answers: {
sandbox_mode: "workspace-write",
},
}),
);

assertFailure(
response,
new ProviderValidationError({
operation: "ProviderService.respondToUserInput",
issue:
"This question belongs to a previous provider session and can no longer be answered. Ask the agent to re-ask it.",
}),
);
assert.equal(routing.codex.startSession.mock.calls.length, 0);
assert.equal(routing.codex.respondToUserInput.mock.calls.length, 0);
}),
);

it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () =>
Effect.gen(function* () {
const provider = yield* ProviderService;
Expand Down
8 changes: 7 additions & 1 deletion apps/server/src/provider/Layers/ProviderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,8 +397,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
const routed = yield* resolveRoutableSession({
threadId: input.threadId,
operation: "ProviderService.respondToUserInput",
allowRecovery: true,
allowRecovery: false,
});
if (!routed.isActive) {
return yield* toValidationError(
"ProviderService.respondToUserInput",
"This question belongs to a previous provider session and can no longer be answered. Ask the agent to re-ask it.",
);
}
yield* routed.adapter.respondToUserInput(routed.threadId, input.requestId, input.answers);
});

Expand Down
Loading
Loading