fix(codex): classify activity by payload.type for wrapped event_msg
Real Codex sessions emit records like
`{"type":"event_msg","payload":{"type":"error",...}}` and
`{"type":"event_msg","payload":{"type":"approval_request",...}}`.
readLastJsonlEntry only exposed the top-level `type`, so the codex
plugin's activity switch matched `event_msg` and decayed to ready/idle,
never surfacing `blocked` or `waiting_input`. The approval_request/error
branches were dead code for payload-wrapped sessions, which is the exact
format this PR series is migrating to.
- readLastJsonlEntry now returns payloadType alongside lastType.
- Codex getActivityState prefers payloadType when present and classifies
task_started/agent_reasoning as active, task_complete as ready, and
the approval/error variants as waiting_input/blocked.
- New tests cover the payload-wrapped approval_request, exec_approval_request,
error, task_started, and task_complete cases end-to-end.
- Core utils gains coverage for payloadType extraction and null fallbacks.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4ab844e47f
commit
df9f3c8b5d
|
|
@ -90,6 +90,31 @@ describe("readLastJsonlEntry", () => {
|
|||
const result = await readLastJsonlEntry(path);
|
||||
expect(result!.modifiedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("extracts payloadType from nested payload.type", async () => {
|
||||
// Real Codex writes records like {"type":"event_msg","payload":{"type":"error",...}}
|
||||
// Consumers need the inner payload.type to classify activity correctly.
|
||||
const path = setup(
|
||||
'{"type":"event_msg","payload":{"type":"error","message":"bad"}}\n',
|
||||
);
|
||||
const result = await readLastJsonlEntry(path);
|
||||
expect(result!.lastType).toBe("event_msg");
|
||||
expect(result!.payloadType).toBe("error");
|
||||
});
|
||||
|
||||
it("returns payloadType null when payload has no type field", async () => {
|
||||
const path = setup('{"type":"session_meta","payload":{"cwd":"/workspace"}}\n');
|
||||
const result = await readLastJsonlEntry(path);
|
||||
expect(result!.lastType).toBe("session_meta");
|
||||
expect(result!.payloadType).toBeNull();
|
||||
});
|
||||
|
||||
it("returns payloadType null when payload is not an object", async () => {
|
||||
const path = setup('{"type":"x","payload":"string"}\n');
|
||||
const result = await readLastJsonlEntry(path);
|
||||
expect(result!.lastType).toBe("x");
|
||||
expect(result!.payloadType).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGitBranchNameSafe", () => {
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ async function readLastLine(filePath: string): Promise<string | null> {
|
|||
*/
|
||||
export async function readLastJsonlEntry(
|
||||
filePath: string,
|
||||
): Promise<{ lastType: string | null; modifiedAt: Date } | null> {
|
||||
): Promise<{ lastType: string | null; payloadType: string | null; modifiedAt: Date } | null> {
|
||||
try {
|
||||
const [line, fileStat] = await Promise.all([readLastLine(filePath), stat(filePath)]);
|
||||
|
||||
|
|
@ -148,10 +148,15 @@ export async function readLastJsonlEntry(
|
|||
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const lastType = typeof obj.type === "string" ? obj.type : null;
|
||||
return { lastType, modifiedAt: fileStat.mtime };
|
||||
let payloadType: string | null = null;
|
||||
if (typeof obj.payload === "object" && obj.payload !== null && !Array.isArray(obj.payload)) {
|
||||
const payload = obj.payload as Record<string, unknown>;
|
||||
if (typeof payload.type === "string") payloadType = payload.type;
|
||||
}
|
||||
return { lastType, payloadType, modifiedAt: fileStat.mtime };
|
||||
}
|
||||
|
||||
return { lastType: null, modifiedAt: fileStat.mtime };
|
||||
return { lastType: null, payloadType: null, modifiedAt: fileStat.mtime };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -739,6 +739,94 @@ describe("getActivityState", () => {
|
|||
expect(result?.state).toBe("ready");
|
||||
});
|
||||
|
||||
it("returns waiting_input when payload.type is approval_request on event_msg", async () => {
|
||||
// Real Codex writes {"type":"event_msg","payload":{"type":"approval_request",...}}
|
||||
// Without payloadType handling, this decays to ready/idle via the event_msg case.
|
||||
mockTmuxWithProcess("codex");
|
||||
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
||||
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
||||
setupMockOpen(content);
|
||||
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
||||
mockReadLastJsonlEntry.mockResolvedValue({
|
||||
lastType: "event_msg",
|
||||
payloadType: "approval_request",
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns waiting_input for exec_approval_request payload type", async () => {
|
||||
mockTmuxWithProcess("codex");
|
||||
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
||||
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
||||
setupMockOpen(content);
|
||||
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
||||
mockReadLastJsonlEntry.mockResolvedValue({
|
||||
lastType: "event_msg",
|
||||
payloadType: "exec_approval_request",
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns blocked when payload.type is error on event_msg", async () => {
|
||||
// Real Codex writes {"type":"event_msg","payload":{"type":"error",...}}
|
||||
mockTmuxWithProcess("codex");
|
||||
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
||||
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
||||
setupMockOpen(content);
|
||||
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
||||
mockReadLastJsonlEntry.mockResolvedValue({
|
||||
lastType: "event_msg",
|
||||
payloadType: "error",
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("blocked");
|
||||
});
|
||||
|
||||
it("returns active when payload.type is task_started on a recent event_msg", async () => {
|
||||
mockTmuxWithProcess("codex");
|
||||
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
||||
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
||||
setupMockOpen(content);
|
||||
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
||||
mockReadLastJsonlEntry.mockResolvedValue({
|
||||
lastType: "event_msg",
|
||||
payloadType: "task_started",
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("active");
|
||||
});
|
||||
|
||||
it("returns ready when payload.type is task_complete on a recent event_msg", async () => {
|
||||
mockTmuxWithProcess("codex");
|
||||
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
||||
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
||||
setupMockOpen(content);
|
||||
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
||||
mockReadLastJsonlEntry.mockResolvedValue({
|
||||
lastType: "event_msg",
|
||||
payloadType: "task_complete",
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result?.state).toBe("ready");
|
||||
});
|
||||
|
||||
it("detects activity from payload-wrapped Codex session_meta files", async () => {
|
||||
mockTmuxWithProcess("codex");
|
||||
const content =
|
||||
|
|
|
|||
|
|
@ -508,30 +508,47 @@ function createCodexAgent(): Agent {
|
|||
const ageMs = Date.now() - entry.modifiedAt.getTime();
|
||||
const timestamp = entry.modifiedAt;
|
||||
|
||||
// Real Codex wraps the semantic type in `payload.type` on event_msg
|
||||
// records (e.g. `{"type":"event_msg","payload":{"type":"error",...}}`).
|
||||
// Prefer payloadType when present so approval_request/error surface
|
||||
// correctly instead of decaying to ready/idle via the event_msg case.
|
||||
const effectiveType = entry.payloadType ?? entry.lastType;
|
||||
|
||||
// Map Codex JSONL entry types to activity states.
|
||||
// Confirmed types: session_meta, event_msg. Others are best-effort.
|
||||
const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold);
|
||||
switch (entry.lastType) {
|
||||
switch (effectiveType) {
|
||||
case "approval_request":
|
||||
case "exec_approval_request":
|
||||
case "apply_patch_approval_request":
|
||||
return { state: "waiting_input", timestamp };
|
||||
|
||||
case "error":
|
||||
case "stream_error":
|
||||
return { state: "blocked", timestamp };
|
||||
|
||||
case "task_started":
|
||||
case "agent_reasoning":
|
||||
case "response_item":
|
||||
case "turn_context":
|
||||
case "user_input":
|
||||
case "tool_call":
|
||||
case "exec_command":
|
||||
case "exec_command_begin":
|
||||
case "exec_command_end":
|
||||
if (ageMs <= activeWindowMs) return { state: "active", timestamp };
|
||||
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
|
||||
|
||||
case "task_complete":
|
||||
case "turn_aborted":
|
||||
case "agent_message":
|
||||
case "assistant_message":
|
||||
case "session_meta":
|
||||
case "event_msg":
|
||||
case "compacted":
|
||||
case "token_count":
|
||||
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
|
||||
|
||||
case "approval_request":
|
||||
return { state: "waiting_input", timestamp };
|
||||
|
||||
case "error":
|
||||
return { state: "blocked", timestamp };
|
||||
|
||||
default:
|
||||
if (ageMs <= activeWindowMs) return { state: "active", timestamp };
|
||||
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
|
||||
|
|
|
|||
Loading…
Reference in New Issue