Skip to content

Commit 9ec5403

Browse files
committed
Check in missing files for command parsing, and fixes for some approval flows
1 parent 337d9f0 commit 9ec5403

File tree

18 files changed

+1664
-23
lines changed

18 files changed

+1664
-23
lines changed

apps/server/src/agents/adapters/codex-agent.ts

Lines changed: 161 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,16 @@ import {
1111
} from "@farfield/api";
1212
import {
1313
ProtocolValidationError,
14+
parseCommandExecutionRequestApprovalResponse,
15+
parseFileChangeRequestApprovalResponse,
16+
parseToolRequestUserInputResponsePayload,
1417
parseThreadConversationState,
1518
parseThreadStreamStateChangedBroadcast,
1619
parseUserInputResponsePayload,
1720
type IpcFrame,
1821
type IpcRequestFrame,
1922
type IpcResponseFrame,
23+
type ThreadConversationRequest,
2024
type ThreadConversationState,
2125
type ThreadStreamStateChangedBroadcast,
2226
type UserInputRequestId,
@@ -593,21 +597,121 @@ export class CodexAgentAdapter implements AgentAdapter {
593597
input: AgentSubmitUserInputInput,
594598
): Promise<{ ownerClientId: string; requestId: UserInputRequestId }> {
595599
this.ensureCodexAvailable();
596-
this.ensureIpcReady();
600+
const parsedResponse = parseUserInputResponsePayload(input.response);
601+
const ownerClientIdForResult = (() => {
602+
const mapped = this.threadOwnerById.get(input.threadId);
603+
if (mapped && mapped.trim().length > 0) {
604+
return mapped.trim();
605+
}
606+
if (input.ownerClientId && input.ownerClientId.trim().length > 0) {
607+
return input.ownerClientId.trim();
608+
}
609+
if (this.lastKnownOwnerClientId && this.lastKnownOwnerClientId.trim()) {
610+
return this.lastKnownOwnerClientId.trim();
611+
}
612+
return "app-server";
613+
})();
614+
615+
const threadForRouting = await this.runThreadOperationWithResumeRetry(
616+
input.threadId,
617+
() => this.appClient.readThread(input.threadId, false),
618+
);
619+
const parsedRoutingThread = parseThreadConversationState(threadForRouting.thread);
620+
const routingPendingRequest = findPendingRequestWithId(
621+
parsedRoutingThread,
622+
input.requestId,
623+
);
624+
625+
if (routingPendingRequest) {
626+
await this.runAppServerCall(() =>
627+
this.appClient.submitUserInput(input.requestId, parsedResponse),
628+
);
629+
630+
const refreshedThread = await this.runThreadOperationWithResumeRetry(
631+
input.threadId,
632+
() => this.appClient.readThread(input.threadId, true),
633+
);
634+
const parsedThread = parseThreadConversationState(refreshedThread.thread);
635+
this.streamSnapshotByThreadId.set(input.threadId, parsedThread);
636+
this.streamSnapshotOriginByThreadId.set(input.threadId, "readThreadWithTurns");
637+
this.setThreadTitle(input.threadId, parsedThread.title);
597638

639+
const currentEvents = this.streamEventsByThreadId.get(input.threadId) ?? [];
640+
currentEvents.push(
641+
buildSyntheticSnapshotEvent(input.threadId, ownerClientIdForResult, parsedThread),
642+
);
643+
if (currentEvents.length > 400) {
644+
currentEvents.splice(0, currentEvents.length - 400);
645+
}
646+
this.streamEventsByThreadId.set(input.threadId, currentEvents);
647+
648+
return {
649+
ownerClientId: ownerClientIdForResult,
650+
requestId: input.requestId,
651+
};
652+
}
653+
654+
this.ensureIpcReady();
598655
const ownerClientId = resolveOwnerClientId(
599656
this.threadOwnerById,
600657
input.threadId,
601658
input.ownerClientId,
602659
this.lastKnownOwnerClientId ?? undefined,
603660
);
661+
this.threadOwnerById.set(input.threadId, ownerClientId);
604662

605-
await this.service.submitUserInput({
606-
threadId: input.threadId,
607-
ownerClientId,
608-
requestId: input.requestId,
609-
response: parseUserInputResponsePayload(input.response),
610-
});
663+
const pendingIpcRequest = await this.resolvePendingIpcRequest(
664+
input.threadId,
665+
input.requestId,
666+
);
667+
switch (pendingIpcRequest.method) {
668+
case "item/commandExecution/requestApproval": {
669+
const commandResponse =
670+
parseCommandExecutionRequestApprovalResponse(parsedResponse);
671+
await this.service.submitCommandApprovalDecision({
672+
threadId: input.threadId,
673+
ownerClientId,
674+
requestId: input.requestId,
675+
response: commandResponse,
676+
});
677+
break;
678+
}
679+
case "item/fileChange/requestApproval": {
680+
const fileResponse = parseFileChangeRequestApprovalResponse(
681+
parsedResponse,
682+
);
683+
await this.service.submitFileApprovalDecision({
684+
threadId: input.threadId,
685+
ownerClientId,
686+
requestId: input.requestId,
687+
response: fileResponse,
688+
});
689+
break;
690+
}
691+
case "item/tool/requestUserInput": {
692+
const toolResponse = parseToolRequestUserInputResponsePayload(
693+
parsedResponse,
694+
);
695+
await this.service.submitUserInput({
696+
threadId: input.threadId,
697+
ownerClientId,
698+
requestId: input.requestId,
699+
response: toolResponse,
700+
});
701+
break;
702+
}
703+
case "execCommandApproval":
704+
case "applyPatchApproval":
705+
throw new Error(
706+
`Legacy approval request method ${pendingIpcRequest.method} is not supported over desktop IPC for thread ${input.threadId}`,
707+
);
708+
case "account/chatgptAuthTokens/refresh":
709+
case "item/tool/call":
710+
case "item/plan/requestImplementation":
711+
throw new Error(
712+
`Unsupported pending request method ${pendingIpcRequest.method} for submitUserInput on thread ${input.threadId}`,
713+
);
714+
}
611715

612716
return {
613717
ownerClientId,
@@ -1045,6 +1149,34 @@ export class CodexAgentAdapter implements AgentAdapter {
10451149
return this.runAppServerCall(operation);
10461150
}
10471151

1152+
private async resolvePendingIpcRequest(
1153+
threadId: string,
1154+
requestId: UserInputRequestId,
1155+
): Promise<ThreadConversationRequest> {
1156+
const cachedSnapshot = this.streamSnapshotByThreadId.get(threadId);
1157+
if (cachedSnapshot) {
1158+
const pending = findPendingRequestWithId(cachedSnapshot, requestId);
1159+
if (pending) {
1160+
return pending;
1161+
}
1162+
}
1163+
1164+
const liveState = await this.readLiveState(threadId);
1165+
if (liveState.conversationState) {
1166+
const pending = findPendingRequestWithId(
1167+
liveState.conversationState,
1168+
requestId,
1169+
);
1170+
if (pending) {
1171+
return pending;
1172+
}
1173+
}
1174+
1175+
throw new Error(
1176+
`Unable to find pending request ${String(requestId)} in live state for thread ${threadId}`,
1177+
);
1178+
}
1179+
10481180
private resolveThreadTitle(
10491181
threadId: string,
10501182
directTitle: string | null | undefined,
@@ -1229,6 +1361,28 @@ function deriveThreadWaitingState(
12291361
};
12301362
}
12311363

1364+
function requestIdsMatch(
1365+
left: UserInputRequestId,
1366+
right: UserInputRequestId,
1367+
): boolean {
1368+
return `${left}` === `${right}`;
1369+
}
1370+
1371+
function findPendingRequestWithId(
1372+
state: ThreadConversationState,
1373+
requestId: UserInputRequestId,
1374+
): ThreadConversationRequest | null {
1375+
for (const request of state.requests) {
1376+
if (request.completed === true) {
1377+
continue;
1378+
}
1379+
if (requestIdsMatch(request.id, requestId)) {
1380+
return request;
1381+
}
1382+
}
1383+
return null;
1384+
}
1385+
12321386
function buildSyntheticSnapshotEvent(
12331387
threadId: string,
12341388
sourceClientId: string,

apps/server/src/unified/adapter.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,6 +1137,13 @@ function mapTurnItem(
11371137
review: item.review,
11381138
};
11391139

1140+
case "remoteTaskCreated":
1141+
return {
1142+
id: item.id,
1143+
type: "remoteTaskCreated",
1144+
taskId: item.taskId,
1145+
};
1146+
11401147
case "modelChanged":
11411148
return {
11421149
id: item.id,

apps/server/test/unified-adapter.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,4 +570,49 @@ describe("unified provider adapters", () => {
570570
expect(result.data[0]?.waitingOnApproval).toBe(true);
571571
expect(result.data[0]?.waitingOnUserInput).toBe(true);
572572
});
573+
574+
it("maps remoteTaskCreated turn items into unified items", async () => {
575+
const adapter = createCodexAdapter();
576+
adapter.readThread = async () => ({
577+
thread: {
578+
...SAMPLE_THREAD,
579+
turns: [
580+
{
581+
id: "turn-1",
582+
status: "inProgress",
583+
items: [
584+
{
585+
id: "item-remote-task",
586+
type: "remoteTaskCreated",
587+
taskId: "task-123",
588+
},
589+
],
590+
},
591+
],
592+
},
593+
});
594+
const unified = new AgentUnifiedProviderAdapter("codex", adapter);
595+
596+
const result = await unified.execute(
597+
UnifiedCommandSchema.parse({
598+
kind: "readThread",
599+
provider: "codex",
600+
threadId: SAMPLE_THREAD.id,
601+
includeTurns: true,
602+
}),
603+
);
604+
605+
expect(result.kind).toBe("readThread");
606+
if (result.kind !== "readThread") {
607+
return;
608+
}
609+
610+
const remoteTaskItem = result.thread.turns[0]?.items[0];
611+
expect(remoteTaskItem?.type).toBe("remoteTaskCreated");
612+
expect(
613+
remoteTaskItem && remoteTaskItem.type === "remoteTaskCreated"
614+
? remoteTaskItem.taskId
615+
: null,
616+
).toBe("task-123");
617+
});
573618
});

apps/web/src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1176,7 +1176,7 @@ export function App(): React.JSX.Element {
11761176
return readConversationState;
11771177
}
11781178

1179-
return readConversationState;
1179+
return liveConversationState;
11801180
}, [liveState?.conversationState, readThreadState?.thread]);
11811181

11821182
const pendingRequests = useMemo(() => {

apps/web/src/components/ConversationItem.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const TOOL_BLOCK_TYPES: readonly UnifiedItem["type"][] = [
2626
"webSearch",
2727
"mcpToolCall",
2828
"collabAgentToolCall",
29+
"remoteTaskCreated",
2930
"forkedFromConversation",
3031
];
3132

@@ -313,6 +314,19 @@ const ITEM_RENDERERS = {
313314
</div>
314315
),
315316

317+
remoteTaskCreated: ({ item, toolSpacing }) => (
318+
<div
319+
className={`${toolSpacing} rounded-lg border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground`}
320+
>
321+
<div className="text-[10px] text-muted-foreground font-mono mb-1 uppercase tracking-wider">
322+
Remote task
323+
</div>
324+
<div className="text-xs text-foreground/90 whitespace-pre-wrap break-all">
325+
Created task: {item.taskId}
326+
</div>
327+
</div>
328+
),
329+
316330
modelChanged: (_args) => (
317331
<div className="rounded-lg border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
318332
Model changed
@@ -388,6 +402,8 @@ function renderItem(
388402
return ITEM_RENDERERS.enteredReviewMode({ item, ...context });
389403
case "exitedReviewMode":
390404
return ITEM_RENDERERS.exitedReviewMode({ item, ...context });
405+
case "remoteTaskCreated":
406+
return ITEM_RENDERERS.remoteTaskCreated({ item, ...context });
391407
case "modelChanged":
392408
return ITEM_RENDERERS.modelChanged({ item, ...context });
393409
case "forkedFromConversation":

apps/web/src/lib/code-language.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
const EXTENSION_LANGUAGE_MAP: Record<string, string> = {
2+
cjs: "javascript",
3+
cpp: "cpp",
4+
cs: "csharp",
5+
css: "css",
6+
go: "go",
7+
h: "c",
8+
htm: "markup",
9+
html: "markup",
10+
java: "java",
11+
js: "javascript",
12+
json: "json",
13+
jsx: "jsx",
14+
kt: "kotlin",
15+
md: "markdown",
16+
mjs: "javascript",
17+
py: "python",
18+
rb: "ruby",
19+
rs: "rust",
20+
scss: "scss",
21+
sh: "bash",
22+
sql: "sql",
23+
svg: "markup",
24+
swift: "swift",
25+
toml: "toml",
26+
ts: "typescript",
27+
tsx: "tsx",
28+
txt: "text",
29+
xml: "markup",
30+
yaml: "yaml",
31+
yml: "yaml",
32+
zsh: "bash",
33+
};
34+
35+
export function languageFromPath(path: string): string {
36+
const trimmedPath = path.trim();
37+
if (trimmedPath.length === 0) {
38+
return "text";
39+
}
40+
41+
const fileName = trimmedPath.split("/").pop()?.toLowerCase() ?? "";
42+
if (fileName === "dockerfile") {
43+
return "docker";
44+
}
45+
46+
const extensionIndex = fileName.lastIndexOf(".");
47+
if (extensionIndex < 0 || extensionIndex === fileName.length - 1) {
48+
return "text";
49+
}
50+
51+
const extension = fileName.slice(extensionIndex + 1);
52+
return EXTENSION_LANGUAGE_MAP[extension] ?? "text";
53+
}

0 commit comments

Comments
 (0)