Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
22 changes: 22 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,28 @@ describe("pending approvals", () => {

expect(derivePendingApprovals([requested, resolved])).toEqual([]);
});

it("derives unknown provider approvals as actionable generic approvals", () => {
const activity = makeActivity({
id: EventId.make("approval-unknown"),
kind: "approval.requested",
summary: "Approval requested",
createdAt: "2026-08-24T00:00:00.000Z",
payload: {
requestId: "req-unknown",
requestType: "unknown",
detail: "addCommentReaction|eyes|'confused'",
},
});

expect(derivePendingApprovals([activity])).toMatchObject([
{
requestId: "req-unknown",
requestKind: "command",
detail: "addCommentReaction|eyes|'confused'",
},
]);
});
});

function makeActivity(
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1388,9 +1388,11 @@ export function derivePendingApprovals(
? (activity.payload as Record<string, unknown>)
: null;
const requestId = parseApprovalRequestId(payload?.requestId);
const requestKind = isProviderRequestKind(payload?.requestKind)
const mappedRequestKind = isProviderRequestKind(payload?.requestKind)
? payload.requestKind
: requestKindFromRequestType(payload?.requestType);
const requestKind =
activity.kind === "approval.requested" ? (mappedRequestKind ?? "command") : mappedRequestKind;
const detail = typeof payload?.detail === "string" ? payload.detail : undefined;
const appName = typeof payload?.appName === "string" ? payload.appName : undefined;
const options = Array.isArray(payload?.options)
Expand Down
286 changes: 281 additions & 5 deletions apps/server/src/provider/Layers/OpenCodeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
ProviderDriverKind,
ProviderInstanceId,
ThreadId,
TurnId,
} from "@t3tools/contracts";
import { createModelSelection } from "@t3tools/shared/model";
import { ServerConfig } from "../../config.ts";
Expand Down Expand Up @@ -68,6 +69,9 @@ const runtimeMock = {
closeError: null as Error | null,
messages: [] as MessageEntry[],
subscribedEvents: [] as unknown[],
subscribedEventStream: null as AsyncIterable<unknown> | null,
abortWait: null as Promise<void> | null,
onAbort: null as (() => void) | null,
sessionGetIds: [] as string[],
missingSessionIds: new Set<string>(),
transientErrorSessionIds: new Set<string>(),
Expand All @@ -88,6 +92,9 @@ const runtimeMock = {
this.state.closeError = null;
this.state.messages = [];
this.state.subscribedEvents = [];
this.state.subscribedEventStream = null;
this.state.abortWait = null;
this.state.onAbort = null;
this.state.sessionGetIds.length = 0;
this.state.missingSessionIds.clear();
this.state.transientErrorSessionIds.clear();
Expand Down Expand Up @@ -176,6 +183,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
},
abort: async ({ sessionID }: { sessionID: string }) => {
runtimeMock.state.abortCalls.push(sessionID);
runtimeMock.state.onAbort?.();
await runtimeMock.state.abortWait;
},
promptAsync: async (input: unknown) => {
runtimeMock.state.promptCalls.push(input);
Expand Down Expand Up @@ -205,11 +214,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
},
event: {
subscribe: async () => ({
stream: (async function* () {
for (const event of runtimeMock.state.subscribedEvents) {
yield event;
}
})(),
stream:
runtimeMock.state.subscribedEventStream ??
(async function* () {
for (const event of runtimeMock.state.subscribedEvents) {
yield event;
}
})(),
}),
},
}) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>,
Expand Down Expand Up @@ -628,6 +639,271 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
}),
);

it.effect("settles pending permissions and questions when stopping", () =>
Effect.gen(function* () {
const adapter = yield* OpenCodeAdapter;
const threadId = asThreadId("thread-opencode-unknown-permission");
runtimeMock.state.subscribedEvents = [
{
type: "permission.asked",
properties: {
id: "permission-1",
sessionID: "http://127.0.0.1:9999/session",
permission: "addCommentReaction",
patterns: ["eyes", "confused"],
metadata: {},
},
},
{
type: "question.asked",
properties: {
id: "question-1",
sessionID: "http://127.0.0.1:9999/session",
questions: [
{
question: "Continue?",
header: "Continue",
options: [{ label: "Yes", description: "Continue the task" }],
},
],
},
},
];
const eventsFiber = yield* adapter.streamEvents.pipe(
Stream.filter((event) => event.threadId === threadId),
Stream.take(4),
Stream.runCollect,
Effect.forkChild,
);

yield* adapter.startSession({
provider: ProviderDriverKind.make("opencode"),
threadId,
runtimeMode: "approval-required",
});

const openedEvents = Array.from(
yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")),
);
const stoppedEventsFiber = yield* adapter.streamEvents.pipe(
Stream.filter((event) => event.threadId === threadId),
Stream.take(3),
Stream.runCollect,
Effect.forkChild,
);
yield* adapter.stopSession(threadId);

const stoppedEvents = Array.from(
yield* Fiber.join(stoppedEventsFiber).pipe(Effect.timeout("1 second")),
);
NodeAssert.deepEqual(
openedEvents.map((event) => event.type),
["session.started", "thread.started", "request.opened", "user-input.requested"],
);
NodeAssert.equal(
openedEvents[2]?.type === "request.opened" && openedEvents[2].payload.requestType,
"dynamic_tool_call",
);
NodeAssert.deepEqual(
stoppedEvents.map((event) => event.type),
["request.resolved", "user-input.resolved", "session.exited"],
);
NodeAssert.equal(
stoppedEvents[0]?.type === "request.resolved" && stoppedEvents[0].payload.decision,
"cancel",
);
NodeAssert.deepEqual(
stoppedEvents[1]?.type === "user-input.resolved" && stoppedEvents[1].payload.answers,
{},
);
}),
);

it.effect("settles pending permissions when interrupting", () =>
Effect.gen(function* () {
const adapter = yield* OpenCodeAdapter;
const threadId = asThreadId("thread-opencode-interrupt-permission");
runtimeMock.state.subscribedEvents = [
{
type: "permission.asked",
properties: {
id: "permission-interrupt",
sessionID: "http://127.0.0.1:9999/session",
permission: "bash",
patterns: ["git status"],
metadata: {},
},
},
];
const openedFiber = yield* adapter.streamEvents.pipe(
Stream.filter((event) => event.threadId === threadId),
Stream.take(3),
Stream.runCollect,
Effect.forkChild,
);

yield* adapter.startSession({
provider: ProviderDriverKind.make("opencode"),
threadId,
runtimeMode: "approval-required",
});
yield* Fiber.join(openedFiber).pipe(Effect.timeout("1 second"));

const interruptedFiber = yield* adapter.streamEvents.pipe(
Stream.filter((event) => event.threadId === threadId),
Stream.take(2),
Stream.runCollect,
Effect.forkChild,
);
yield* adapter.interruptTurn(threadId, TurnId.make("turn-interrupted"));

const events = Array.from(
yield* Fiber.join(interruptedFiber).pipe(Effect.timeout("1 second")),
);
NodeAssert.deepEqual(
events.map((event) => event.type),
["request.resolved", "turn.aborted"],
);
NodeAssert.equal(
events[0]?.type === "request.resolved" && events[0].payload.decision,
"cancel",
);
yield* adapter.stopSession(threadId);
}),
);

it.effect("aborts OpenCode when a session errors with a pending request", () =>
Effect.gen(function* () {
const adapter = yield* OpenCodeAdapter;
const threadId = asThreadId("thread-opencode-session-error");
runtimeMock.state.subscribedEvents = [
{
type: "permission.asked",
properties: {
id: "permission-session-error",
sessionID: "http://127.0.0.1:9999/session",
permission: "bash",
patterns: ["git status"],
metadata: {},
},
},
{
type: "session.error",
properties: {
sessionID: "http://127.0.0.1:9999/session",
error: { data: { message: "provider failed" } },
},
},
];
const eventsFiber = yield* adapter.streamEvents.pipe(
Stream.filter((event) => event.threadId === threadId),
Stream.take(5),
Stream.runCollect,
Effect.forkChild,
);

yield* adapter.startSession({
provider: ProviderDriverKind.make("opencode"),
threadId,
runtimeMode: "approval-required",
});

const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
NodeAssert.deepEqual(
events.map((event) => event.type),
[
"session.started",
"thread.started",
"request.opened",
"request.resolved",
"runtime.error",
],
);
NodeAssert.deepEqual(runtimeMock.state.abortCalls, ["http://127.0.0.1:9999/session"]);
yield* adapter.stopSession(threadId);
}),
);

it.effect("handles permission reply and open races during teardown", () =>
Effect.gen(function* () {
const adapter = yield* OpenCodeAdapter;
const threadId = asThreadId("thread-opencode-reply-race");
const abortStarted = Promise.withResolvers<void>();
const releaseAbort = Promise.withResolvers<void>();
const releaseRacingEvents = Promise.withResolvers<void>();
const racingEventsHandled = Promise.withResolvers<void>();
runtimeMock.state.abortWait = releaseAbort.promise;
runtimeMock.state.onAbort = abortStarted.resolve;
runtimeMock.state.subscribedEventStream = (async function* () {
yield {
type: "permission.asked",
properties: {
id: "permission-reply-race",
sessionID: "http://127.0.0.1:9999/session",
permission: "bash",
patterns: ["git status"],
metadata: {},
},
};
await releaseRacingEvents.promise;
yield {
type: "permission.replied",
properties: {
sessionID: "http://127.0.0.1:9999/session",
requestID: "permission-reply-race",
reply: "once",
},
};
yield {
type: "permission.asked",
properties: {
id: "permission-after-stop",
sessionID: "http://127.0.0.1:9999/session",
permission: "bash",
patterns: ["git diff"],
metadata: {},
},
};
racingEventsHandled.resolve();
})();
const openedFiber = yield* adapter.streamEvents.pipe(
Stream.filter((event) => event.threadId === threadId),
Stream.take(3),
Stream.runCollect,
Effect.forkChild,
);

yield* adapter.startSession({
provider: ProviderDriverKind.make("opencode"),
threadId,
runtimeMode: "approval-required",
});
yield* Fiber.join(openedFiber).pipe(Effect.timeout("1 second"));
const closingFiber = yield* adapter.streamEvents.pipe(
Stream.filter((event) => event.threadId === threadId),
Stream.takeUntil((event) => event.type === "session.exited"),
Stream.runCollect,
Effect.forkChild,
);
const stopFiber = yield* adapter.stopSession(threadId).pipe(Effect.forkChild);
yield* Effect.promise(() => abortStarted.promise);
releaseRacingEvents.resolve();
yield* Effect.promise(() => racingEventsHandled.promise);
releaseAbort.resolve();
yield* Fiber.join(stopFiber);

const events = Array.from(yield* Fiber.join(closingFiber).pipe(Effect.timeout("1 second")));
NodeAssert.deepEqual(
events.map((event) => event.type),
["request.resolved", "session.exited"],
);
NodeAssert.equal(
events[0]?.type === "request.resolved" && events[0].payload.decision,
"cancel",
);
}),
);

it.effect("clears session state even when cleanup finalizers throw", () =>
Effect.gen(function* () {
const adapter = yield* OpenCodeAdapter;
Expand Down
Loading
Loading