Skip to content
Open
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: 7 additions & 1 deletion apps/mobile/src/features/threads/PendingApprovalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
Approval needed
</Text>
<Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50">
{props.approval.requestKind}
{props.approval.requestKind === "command"
? "Command approval"
: props.approval.requestKind === "file-read"
? "File-read approval"
: props.approval.requestKind === "file-change"
? "File-change approval"
: "Permission approval"}
</Text>
{props.approval.detail ? (
<Text className="font-sans text-sm leading-normal text-neutral-600 dark:text-neutral-400">
Expand Down
29 changes: 29 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import {
buildPendingUserInputAnswers,
buildThreadFeed,
derivePendingApprovals,
deriveThreadFeedPresentation,
isPendingUserInputOptionSelected,
setPendingUserInputCustomAnswer,
Expand Down Expand Up @@ -150,6 +151,34 @@ function makeThread(
};
}

describe("derivePendingApprovals", () => {
it("keeps unknown provider permissions visible", () => {
const approvals = derivePendingApprovals([
makeActivity({
id: EventId.make("mobile-approval-external-directory"),
createdAt: "2026-04-01T00:00:01.000Z",
kind: "approval.requested",
summary: "Approval requested",
tone: "approval",
payload: {
requestId: "mobile-req-external-directory",
requestType: "unknown",
detail: "Permission: external_directory",
},
}),
]);

expect(approvals).toEqual([
{
requestId: "mobile-req-external-directory",
requestKind: "unknown",
createdAt: "2026-04-01T00:00:01.000Z",
detail: "Permission: external_directory",
},
]);
});
});

describe("buildThreadFeed", () => {
it("keeps historic work entries attributed to their turns", () => {
const thread = makeThread({
Expand Down
11 changes: 8 additions & 3 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import * as Order from "effect/Order";

export interface PendingApproval {
readonly requestId: ApprovalRequestId;
readonly requestKind: "command" | "file-read" | "file-change";
readonly requestKind: "command" | "file-read" | "file-change" | "unknown";
readonly createdAt: string;
readonly detail?: string;
}
Expand Down Expand Up @@ -147,6 +147,8 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ
case "file_change_approval":
case "apply_patch_approval":
return "file-change";
case "unknown":
return "unknown";
default:
return null;
}
Expand Down Expand Up @@ -632,6 +634,7 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {
if (entry.requestKind === "command") return "command";
if (entry.requestKind === "file-read") return "eye";
if (entry.requestKind === "file-change") return "edit";
if (entry.requestKind === "unknown") return "alert";
if (entry.itemType === "command_execution" || entry.command) return "command";
if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) return "edit";
if (entry.itemType === "web_search") return "globe";
Expand Down Expand Up @@ -967,7 +970,8 @@ function extractWorkLogRequestKind(
if (
payload?.requestKind === "command" ||
payload?.requestKind === "file-read" ||
payload?.requestKind === "file-change"
payload?.requestKind === "file-change" ||
payload?.requestKind === "unknown"
) {
return payload.requestKind;
}
Expand Down Expand Up @@ -1367,7 +1371,8 @@ export function derivePendingApprovals(
const requestKind =
payload?.requestKind === "command" ||
payload?.requestKind === "file-read" ||
payload?.requestKind === "file-change"
payload?.requestKind === "file-change" ||
payload?.requestKind === "unknown"
? payload.requestKind
: requestKindFromRequestType(payload?.requestType);
const detail = typeof payload?.detail === "string" ? payload.detail : undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2651,6 +2651,26 @@ describe("ProviderRuntimeIngestion", () => {
},
});

harness.emit({
type: "request.opened",
eventId: asEventId("evt-request-opened-unknown"),
provider: ProviderDriverKind.make("opencode"),
createdAt: now,
threadId: asThreadId("thread-1"),
requestId: ApprovalRequestId.make("req-external-directory"),
providerRefs: {
providerSessionId: "ses_child",
},
payload: {
requestType: "unknown",
detail: "Permission: external_directory\n\nPatterns:\n/another/project/*",
args: {
permission: "external_directory",
sessionID: "ses_child",
},
},
});

await waitForThread(
harness.readModel,
(entry) =>
Expand All @@ -2659,6 +2679,9 @@ describe("ProviderRuntimeIngestion", () => {
) &&
entry.activities.some(
(activity: ProviderRuntimeTestActivity) => activity.kind === "approval.resolved",
) &&
entry.activities.some(
(activity: ProviderRuntimeTestActivity) => activity.id === "evt-request-opened-unknown",
),
);

Expand All @@ -2676,6 +2699,21 @@ describe("ProviderRuntimeIngestion", () => {
expect(requestedPayload?.requestKind).toBe("command");
expect(requestedPayload?.requestType).toBe("command_execution_approval");

const unknownRequested = thread?.activities.find(
(activity: ProviderRuntimeTestActivity) => activity.id === "evt-request-opened-unknown",
);
const unknownPayload =
unknownRequested?.payload && typeof unknownRequested.payload === "object"
? (unknownRequested.payload as Record<string, unknown>)
: undefined;
expect(unknownPayload?.requestKind).toBe("unknown");
expect(unknownPayload?.providerSessionId).toBe("ses_child");
expect(unknownPayload?.detail).toContain("external_directory");
expect(unknownPayload?.args).toEqual({
permission: "external_directory",
sessionID: "ses_child",
});

const resolved = thread?.activities.find(
(activity: ProviderRuntimeTestActivity) => activity.id === "evt-request-resolved",
);
Expand Down Expand Up @@ -2714,6 +2752,55 @@ describe("ProviderRuntimeIngestion", () => {
expect(thread.session?.lastError).toBe("runtime exploded");
});

it("keeps parent session state when a child-session runtime.error arrives", async () => {
const harness = await createHarness();
const now = "2026-01-01T00:00:00.000Z";

// Seed a running session so there is live turn state to protect.
await harness.dispatch({
type: "thread.session.set",
commandId: CommandId.make("cmd-session-seed-child-error"),
threadId: ThreadId.make("thread-1"),
session: {
threadId: ThreadId.make("thread-1"),
status: "running",
providerName: "codex",
runtimeMode: "approval-required",
activeTurnId: asTurnId("turn-child-error"),
updatedAt: now,
lastError: null,
},
createdAt: now,
});

// A subagent failure must surface as an activity, but it must not flip
// the parent thread's session into error — the root session is still
// working and owns that lifecycle.
harness.emit({
type: "runtime.error",
eventId: asEventId("evt-runtime-error-child"),
provider: ProviderDriverKind.make("opencode"),
createdAt: now,
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-child-error"),
providerRefs: {
providerSessionId: "ses_child",
providerParentSessionId: "ses_parent",
},
payload: {
message: "child task failed",
},
});

await waitForThread(harness.readModel, (entry) =>
entry.activities.some((activity) => activity.id === "evt-runtime-error-child"),
);
const readModel = await harness.readModel();
const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
expect(thread?.session?.status).toBe("running");
expect(thread?.session?.lastError).toBeNull();
});

it("records runtime.error activities from the typed payload message", async () => {
const harness = await createHarness();
const now = "2026-01-01T00:00:00.000Z";
Expand Down
Loading
Loading