From 1f012112704329ac27d3399bf65ebe8d4a63e789 Mon Sep 17 00:00:00 2001 From: priva7eer Date: Sat, 17 Oct 2026 00:47:19 +0200 Subject: [PATCH 1/8] Add opt-in live session status icons --- CHANGELOG.md | 7 ++ README.md | 33 ++++- docs/SESSION-STATUS.md | 60 +++++++++ extension.js | 79 +++++++++++- package.json | 5 + scripts/validate.mjs | 5 + session-index.js | 20 ++- session-live-status.js | 201 ++++++++++++++++++++++++++++++ session-status.js | 117 +++++++++++++++++ test/extension.test.cjs | 148 +++++++++++++++++++++- test/session-live-status.test.cjs | 137 ++++++++++++++++++++ test/session-status.test.cjs | 82 ++++++++++++ 12 files changed, 878 insertions(+), 16 deletions(-) create mode 100644 docs/SESSION-STATUS.md create mode 100644 session-live-status.js create mode 100644 session-status.js create mode 100644 test/session-live-status.test.cjs create mode 100644 test/session-status.test.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 848f26d..3f52361 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to Codex Extras are documented in this file. +## Unreleased + +- Add opt-in session status icons, using experimental local Codex IPC for live + activity and bounded log reads for recorded results. Never infer running from + unfinished logs; invalidate live activity on disconnection. Keep the original + conversation icons by default. + ## 1.1.0 - Add a Codex Chats Activity Bar panel with local chat history, automatic diff --git a/README.md b/README.md index b5c5777..e47058e 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,9 @@ conversations ordered by their most recent activity. The panel reads Codex's local session index from `$CODEX_HOME/session_index.jsonl` when `CODEX_HOME` is set, or from `~/.codex/session_index.jsonl` otherwise. -It reads only the small metadata prefix needed to identify each chat's workspace; -transcript contents are not parsed or copied. +By default, it reads only the small metadata prefix needed to identify each +chat's workspace; transcript contents are not parsed or copied. The optional +status-icons mode also reads lifecycle events from a bounded log tail (see Settings). ### New Codex Agent toolbar button @@ -47,6 +48,29 @@ The official Codex extension is declared as a dependency and is installed automa | Setting | Default | Description | | --- | --- | --- | | `codexExtras.showNewAgentButton` | `true` | Shows the New Codex Agent button in the editor toolbar. | +| `codexExtras.showSessionStatusIcons` | `false` | Experimental: replaces conversation icons with live task status and recorded completion events. | + +Enable **Codex Extras: Show Session Status Icons** for a spinner while working, +a check mark after completion, a stop icon for interrupted turns, or an error icon +for failed turns. A question mark means no supported lifecycle event was found. +A bell means Codex is waiting for input or approval, and a hollow circle means idle. +A clock means activity is unconfirmed, not that the task has completed. +Changing the setting takes effect immediately. + +This optional mode reads at most the last 256 KiB of each local session log and +caches the result. Only explicit lifecycle events determine status; assistant +messages and file modification times do not imply completion. File system events +refresh recorded results without polling. A spinner requires a live `active` +state from Codex's internal local IPC channel; logs alone never produce a spinner. +Losing the connection or receiving an owner-disconnection event invalidates live +status immediately. There is no inactivity timeout for a confirmed running task. + +The IPC adapter is experimental and was checked against Codex extension +`26.1007.21434` (stream protocol version 11). An incompatible protocol falls back +to unknown/unconfirmed status. This is an observer only: it does not start, +resume, interrupt, or take ownership of tasks. See [status implementation notes](docs/SESSION-STATUS.md). +When disabled, session bodies are not read, no status watcher is registered, +and no IPC connection is opened. ## Install @@ -68,7 +92,10 @@ code --install-extension codex-extras-1.1.0.vsix Codex Extras contains no telemetry or network requests. It reads the local Codex session index to populate the chat list and opens editor panels provided by the -official Codex extension. +official Codex extension. If status icons are enabled, it also reads a bounded +tail of session logs locally to identify lifecycle events and receives local IPC +state snapshots. Only runtime status is retained from snapshots; conversation +messages are discarded and are never displayed, saved, or sent elsewhere. ## Contributing diff --git a/docs/SESSION-STATUS.md b/docs/SESSION-STATUS.md new file mode 100644 index 0000000..703d84e --- /dev/null +++ b/docs/SESSION-STATUS.md @@ -0,0 +1,60 @@ +# Experimental session status icons + +`codexExtras.showSessionStatusIcons` is off by default. The original chat icon, +metadata-only reads, and watcher behavior remain the default. + +## Sources of status + +- Live state: Codex's internal cross-window IPC, not a separate app-server process. + A different app-server instance cannot reliably report another instance's work. +- Historical results: at most the trailing 256 KiB of a local JSONL session file, + cached by file identity, size, and modification time. Only `event_msg` lifecycle + records count. Unfinished logs always return unconfirmed activity. +- No status polling or inactivity-based completion. File events refresh log + results; IPC messages update runtime status. Reconnection uses a delayed retry + only after the IPC connection closes. + +The public app-server protocol documents `thread/status/changed` and runtime +thread status: . +The installed IDE extension owns a stdio app-server connection and does not +export a public status subscription for other VS Code extensions. + +## Internal IPC contract observed locally + +Verified with `openai.chatgpt` version `26.1007.21434` on Windows. Sources of +confirmation: installed extension implementation, successful observer handshake, +read-only ownership discovery, and repeated runtime-state snapshots. + +- Windows endpoint: `\\.\pipe\codex-ipc`. +- Unix endpoints: `$CODEX_HOME/ipc/ipc.sock` and the legacy per-user socket under + the operating system temporary directory. Unix paths are derived from the + installed implementation; live connectivity was verified on Windows only. +- Framing: 4-byte little-endian byte length followed by UTF-8 JSON. +- Initialize: `initialize`, version 0; receives a client ID. +- Subscribe/unsubscribe: `thread-stream-following-changed`, version 1, + `{hostId: "local", conversationId, following}`. +- Runtime stream: `thread-stream-state-changed`, version 11. Snapshots contain + `change.conversationState.threadRuntimeStatus`. Patches carry base and new + revisions. Only runtime-status fields are retained; transcript patches are ignored. +- `active` means working; nonempty `activeFlags` means waiting for input/approval. + `idle` means idle, `systemError` means failed, other states are unconfirmed. +- An owner-disconnection event or lost IPC connection clears live states. +- A revision gap requests a fresh snapshot. Unsupported versions cannot retain + a running indicator. Discovery requests are answered with `canHandle: false`. + +The adapter never becomes an owner and sends no task-control requests. Receiving +snapshots can temporarily decode conversation data in memory; it keeps no messages +and does not write source logs, configuration, or transcripts. + +## Limits + +This is an undocumented protocol and may change with Codex updates. A socket +connection and the last runtime state cannot prove progress if an owner remains +connected but stops responding. IPC disconnections are handled immediately; +silent hangs cannot be ruled out. A missing log result remains unknown rather +than becoming a successful completion. Remote hosts and compressed active logs +are not currently supported for status discovery. + +Tests cover option toggling, log parsing and bounds, incomplete writes, live +snapshots and runtime patches, waiting states, owner and transport disconnection, +revision mismatch, protocol mismatch, frame bounds, and observer-only behavior. diff --git a/extension.js b/extension.js index aae4c3d..b232d40 100644 --- a/extension.js +++ b/extension.js @@ -4,10 +4,13 @@ const fs = require("node:fs"); const { readFile } = require("node:fs/promises"); const path = require("node:path"); const vscode = require("vscode"); +const { SessionStatusReader, watchSessionStatus } = require("./session-status"); +const { LiveSessionStatus } = require("./session-live-status"); const { getSessionIndexPath, getSessionsDirectory, isPathInsideWorkspace, + loadSessionFiles, loadSessionWorkspaces, parseSessionIndex, } = require("./session-index"); @@ -27,16 +30,33 @@ const CODEX_NEW_PANEL_PATH = "/extension/panel/new"; let panelSequence = 0; let openQueue = Promise.resolve(); +const SESSION_STATUSES = { + working: { icon: "loading~spin", label: "Working (live)" }, + waiting: { icon: "bell", label: "Waiting for input or approval (live)" }, + idle: { icon: "circle-outline", label: "Idle (live)" }, + unconfirmed: { icon: "clock", label: "Activity unconfirmed" }, + completed: { icon: "pass", label: "Completed" }, + interrupted: { icon: "debug-stop", label: "Interrupted" }, + failed: { icon: "error", label: "Failed" }, +}; + class CodexChatItem extends vscode.TreeItem { constructor(session) { super(session.title, vscode.TreeItemCollapsibleState.None); this.id = session.id; + const status = session.showStatusIcons + ? SESSION_STATUSES[session.status] ?? { icon: "question", label: "Status unknown" } + : undefined; this.description = [session.workspaceName, formatUpdatedAt(session.updatedAt)] .filter(Boolean) .join(" · "); this.tooltip = [ session.title, + status ? `Status: ${status.label}` : undefined, + session.status === "unconfirmed" && status + ? "Live status is unavailable. An unfinished log does not confirm the task is still running." + : undefined, session.workspacePath ? `Workspace: ${session.workspacePath}` : undefined, session.updatedAt ? `Updated: ${new Date(session.updatedAt).toLocaleString()}` @@ -44,7 +64,7 @@ class CodexChatItem extends vscode.TreeItem { ] .filter(Boolean) .join("\n"); - this.iconPath = new vscode.ThemeIcon("comment-discussion"); + this.iconPath = new vscode.ThemeIcon(status?.icon ?? "comment-discussion"); this.contextValue = "codexChat"; this.command = { command: OPEN_CHAT_COMMAND_ID, @@ -52,7 +72,7 @@ class CodexChatItem extends vscode.TreeItem { arguments: [session.id], }; this.accessibilityInformation = { - label: `${session.title}, Codex chat`, + label: `${session.title}, Codex chat${status ? `, ${status.label}` : ""}`, }; } } @@ -82,6 +102,8 @@ class CodexChatHistoryProvider { this.sessionsDirectory = sessionsDirectory; this.getWorkspaceFolders = getWorkspaceFolders; this.workspaceBySessionId = new Map(); + this.showStatusIcons = false; + this.sessionStatusReader = new SessionStatusReader(); this.changeEmitter = new vscode.EventEmitter(); this.statusEmitter = new vscode.EventEmitter(); this.onDidChangeTreeData = this.changeEmitter.event; @@ -111,9 +133,15 @@ class CodexChatHistoryProvider { const missingWorkspaceIds = sessions .filter((session) => !this.workspaceBySessionId.has(session.id)) .map((session) => session.id); + const showStatusIcons = this.showStatusIcons; + this.liveStatus?.setThreads(sessions.map(({ id }) => id)); + const sessionFiles = showStatusIcons + ? await loadSessionFiles(this.sessionsDirectory, sessions.map(({ id }) => id)) + : undefined; const loadedWorkspaces = await loadSessionWorkspaces( this.sessionsDirectory, missingWorkspaceIds, + sessionFiles, ); for (const [sessionId, workspacePath] of loadedWorkspaces) { @@ -121,10 +149,21 @@ class CodexChatHistoryProvider { } const workspaceFolders = this.getWorkspaceFolders(); - const enrichedSessions = sessions.map((session) => ({ - ...session, - workspacePath: this.workspaceBySessionId.get(session.id), + const enrichedSessions = await Promise.all(sessions.map(async (session) => { + const historicalStatus = sessionFiles?.has(session.id) + ? await this.sessionStatusReader.read(sessionFiles.get(session.id)) + : undefined; + const liveStatus = this.liveStatus?.get(session.id); + return { + ...session, + workspacePath: this.workspaceBySessionId.get(session.id), + showStatusIcons, + status: liveStatus && liveStatus !== "idle" ? liveStatus + : liveStatus === "idle" && historicalStatus === "unconfirmed" ? "idle" + : historicalStatus ?? liveStatus, + }; })); + if (sessionFiles) this.sessionStatusReader.retain(sessionFiles.values()); this.statusEmitter.fire(undefined); return createChatGroups(enrichedSessions, workspaceFolders); @@ -140,6 +179,7 @@ class CodexChatHistoryProvider { } dispose() { + this.sessionStatusReader.clear(); this.changeEmitter.dispose(); this.statusEmitter.dispose(); } @@ -377,6 +417,30 @@ function activate(context) { const workspaceSubscription = vscode.workspace.onDidChangeWorkspaceFolders( () => historyProvider.refresh(), ); + let sessionStatusWatcher; + const updateStatusIcons = () => { + const enabled = vscode.workspace.getConfiguration("codexExtras") + .get("showSessionStatusIcons", false); + sessionStatusWatcher?.dispose(); + historyProvider.liveStatus?.dispose(); + historyProvider.liveStatus = undefined; + sessionStatusWatcher = undefined; + historyProvider.showStatusIcons = enabled; + historyProvider.sessionStatusReader.clear(); + if (enabled) { + historyProvider.liveStatus = new LiveSessionStatus({ + codexHome: path.dirname(sessionIndexPath), onChange: () => historyProvider.refresh(), + }); + sessionStatusWatcher = watchSessionStatus( + historyProvider.sessionsDirectory, () => historyProvider.refresh(), vscode, + ); + } + historyProvider.refresh(); + }; + const configurationSubscription = vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("codexExtras.showSessionStatusIcons")) updateStatusIcons(); + }); + updateStatusIcons(); context.subscriptions.push( vscode.commands.registerCommand(COMMAND_ID, openNewChat), @@ -390,6 +454,11 @@ function activate(context) { statusSubscription, visibilitySubscription, workspaceSubscription, + configurationSubscription, + { dispose() { + sessionStatusWatcher?.dispose(); + historyProvider.liveStatus?.dispose(); + } }, watchSessionIndex(sessionIndexPath, () => historyProvider.refresh()), ); } diff --git a/package.json b/package.json index 72f4ffb..1973aef 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,11 @@ "configuration": { "title": "Codex Extras", "properties": { + "codexExtras.showSessionStatusIcons": { + "type": "boolean", + "default": false, + "description": "Experimental: replace chat icons with live task status via Codex's internal local IPC protocol, plus recorded completion/interruption events. Show a spinner only when live status confirms activity; otherwise show unknown/unconfirmed. Disabled by default." + }, "codexExtras.showNewAgentButton": { "type": "boolean", "default": true, diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 3e95090..89e4566 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -16,6 +16,11 @@ assert.equal(manifest.license, "MIT"); assert.equal(manifest.repository.url, "https://github.com/netroforge/codex-extras.git"); assert.ok(manifest.extensionDependencies.includes("openai.chatgpt")); assert.equal(manifest.main, "./extension.js"); +assert.equal( + manifest.contributes.configuration.properties["codexExtras.showSessionStatusIcons"].default, + false, + "Status icons must remain opt-in", +); assert.ok( manifest.activationEvents.includes("onCommand:codexExtras.newCodexAgent"), ); diff --git a/session-index.js b/session-index.js index b3dc8d9..18ed497 100644 --- a/session-index.js +++ b/session-index.js @@ -129,16 +129,13 @@ async function findSessionFiles(directory, requestedSessionIds, results) { requestedSessionIds.has(sessionId) && !results.has(sessionId) ) { - const workspacePath = await readSessionWorkspace(entryPath); - if (workspacePath) { - results.set(sessionId, workspacePath); - } + results.set(sessionId, entryPath); } }), ); } -async function loadSessionWorkspaces(sessionsDirectory, sessionIds) { +async function loadSessionFiles(sessionsDirectory, sessionIds) { const requestedSessionIds = new Set(sessionIds); const results = new Map(); @@ -149,6 +146,18 @@ async function loadSessionWorkspaces(sessionsDirectory, sessionIds) { return results; } +async function loadSessionWorkspaces(sessionsDirectory, sessionIds, sessionFiles) { + const files = sessionFiles ?? await loadSessionFiles(sessionsDirectory, sessionIds); + const results = new Map(); + await Promise.all(sessionIds.map(async (id) => { + const filePath = files.get(id); + if (!filePath) return; + const workspacePath = await readSessionWorkspace(filePath); + if (workspacePath) results.set(id, workspacePath); + })); + return results; +} + function isPathInsideWorkspace(candidatePath, workspacePath) { if (!candidatePath || !workspacePath) { return false; @@ -171,6 +180,7 @@ module.exports = { getSessionIndexPath, getSessionsDirectory, isPathInsideWorkspace, + loadSessionFiles, loadSessionWorkspaces, parseSessionIndex, }; diff --git a/session-live-status.js b/session-live-status.js new file mode 100644 index 0000000..c52418d --- /dev/null +++ b/session-live-status.js @@ -0,0 +1,201 @@ +"use strict"; + +const net = require("node:net"); +const os = require("node:os"); +const path = require("node:path"); +const { randomUUID } = require("node:crypto"); + +// Experimental: this is Codex's internal cross-window protocol, not its public +// app-server API. Reject unknown stream versions rather than infer activity. +const STREAM_VERSION = 11; +const MAX_FRAME_BYTES = 32 * 1024 * 1024; + +function runtimeStatus(value) { + if (value?.type === "active") { + return value.activeFlags?.length ? "waiting" : "working"; + } + if (value?.type === "idle") return "idle"; + if (value?.type === "systemError") return "failed"; + return "unconfirmed"; +} + +class LiveSessionStatus { + constructor({ codexHome, onChange, connect = net.connect }) { + this.onChange = onChange; + this.connect = connect; + this.endpoint = process.platform === "win32" + ? "\\\\.\\pipe\\codex-ipc" + : path.join(codexHome, "ipc", "ipc.sock"); + this.legacyEndpoint = process.platform === "win32" ? undefined + : path.join(os.tmpdir(), "codex-ipc", process.getuid?.() ? `ipc-${process.getuid()}.sock` : "ipc.sock"); + this.threads = new Set(); + this.states = new Map(); + this.buffer = Buffer.alloc(0); + this.disposed = false; + this.start(); + } + + start(useLegacy = false) { + if (this.disposed) return; + this.clientId = undefined; + this.buffer = Buffer.alloc(0); + const socket = this.connect(useLegacy ? this.legacyEndpoint : this.endpoint); + this.socket = socket; + socket.on("connect", () => { + this.initializeId = randomUUID(); + this.send({ type: "request", method: "initialize", version: 0, + requestId: this.initializeId, sourceClientId: "initializing-client", + params: { clientType: "codex-extras" } }); + }); + socket.on("data", (data) => { + try { + this.buffer = Buffer.concat([this.buffer, data]); + while (this.buffer.length >= 4) { + const length = this.buffer.readUInt32LE(0); + if (!length || length > MAX_FRAME_BYTES) throw new Error("Invalid IPC frame"); + if (this.buffer.length < length + 4) break; + const message = JSON.parse(this.buffer.subarray(4, length + 4)); + this.buffer = this.buffer.length === length + 4 + ? Buffer.alloc(0) : Buffer.from(this.buffer.subarray(length + 4)); + this.receive(message); + } + } catch { + socket.destroy(); + } + }); + socket.on("error", () => {}); + socket.on("close", () => { + if (this.socket !== socket) return; + this.clientId = undefined; + this.buffer = Buffer.alloc(0); + this.states.clear(); + this.onChange(); + if (!this.disposed) { + this.retry = setTimeout(() => this.start(!useLegacy && !!this.legacyEndpoint), 2000); + this.retry.unref(); + } + }); + } + + send(message) { + if (!this.socket?.writable) return; + const body = Buffer.from(JSON.stringify(message)); + const header = Buffer.alloc(4); + header.writeUInt32LE(body.length); + this.socket.write(Buffer.concat([header, body])); + } + + follow(id, following) { + if (!this.clientId) return; + this.send({ type: "broadcast", method: "thread-stream-following-changed", + version: 1, sourceClientId: this.clientId, + params: { hostId: "local", conversationId: id, following } }); + } + + setThreads(ids) { + const next = new Set(ids); + for (const id of this.threads) { + if (!next.has(id)) { + this.follow(id, false); + this.states.delete(id); + } + } + for (const id of next) if (!this.threads.has(id)) this.follow(id, true); + this.threads = next; + } + + receive(message) { + if (message.type === "response" && message.requestId === this.initializeId) { + if (message.resultType !== "success" || typeof message.result?.clientId !== "string") { + this.socket.destroy(); + return; + } + this.clientId = message.result.clientId; + for (const id of this.threads) this.follow(id, true); + return; + } + // This client observes state only. Never accept ownership or execution requests. + if (message.type === "client-discovery-request") { + this.send({ type: "client-discovery-response", requestId: message.requestId, + response: { canHandle: false } }); + return; + } + if (message.type !== "broadcast") return; + if (message.targetClientIds && !message.targetClientIds.includes(this.clientId)) return; + if (message.method === "client-status-changed") { + if (message.params?.status === "disconnected") { + for (const [id, state] of this.states) { + if (state.owner === message.params.clientId) this.states.delete(id); + } + this.onChange(); + } + return; + } + // An existing owner asks followers to announce themselves after reconnecting. + if (message.method === "thread-stream-following-status-requested") { + if (message.params?.hostId === "local" && this.threads.has(message.params.conversationId)) { + this.follow(message.params.conversationId, true); + } + return; + } + if (message.method !== "thread-stream-state-changed") return; + const { conversationId: id, hostId, change } = message.params ?? {}; + if (hostId !== "local" || !this.threads.has(id)) return; + const previous = this.states.get(id); + if (message.version !== STREAM_VERSION) { + this.states.delete(id); + this.onChange(); + return; + } + let value; + if (change?.type === "snapshot") { + // Keep only runtime status; discard conversation messages immediately. + value = change.conversationState?.threadRuntimeStatus; + } else if (change?.type === "patches") { + if (!previous || previous.owner !== message.sourceClientId || + previous.revision !== change.baseRevision) { + this.states.delete(id); + this.follow(id, true); + this.onChange(); + return; + } + value = previous.runtime; + for (const patch of change.patches ?? []) { + const segments = Array.isArray(patch.path) ? patch.path + : typeof patch.path === "string" ? patch.path.split("/").slice(1) : []; + if (!segments.length) { + value = patch.value?.threadRuntimeStatus; + } else if (segments[0] === "threadRuntimeStatus") { + if (segments.length === 1) value = patch.op === "remove" ? undefined : patch.value; + else if (segments.length === 2 && segments[1] === "type") value = { ...value, type: patch.value }; + else if (segments[1] === "activeFlags") { + // Request a fresh snapshot for nested flag updates; no history patches needed. + value = undefined; + this.follow(id, true); + } + } + } + } else { + this.states.delete(id); + this.onChange(); + return; + } + const status = runtimeStatus(value); + this.states.set(id, { owner: message.sourceClientId, revision: change.revision, runtime: value, status }); + if (status !== previous?.status) this.onChange(); + } + + get(id) { + return this.clientId ? this.states.get(id)?.status : undefined; + } + + dispose() { + this.disposed = true; + clearTimeout(this.retry); + for (const id of this.threads) this.follow(id, false); + this.states.clear(); + this.socket?.destroy(); + } +} + +module.exports = { LiveSessionStatus, runtimeStatus }; diff --git a/session-status.js b/session-status.js new file mode 100644 index 0000000..f8d80a2 --- /dev/null +++ b/session-status.js @@ -0,0 +1,117 @@ +"use strict"; + +const { open } = require("node:fs/promises"); + +const STATUS_TAIL_BYTES = 256 * 1024; +const STATUS_BY_EVENT = new Map([ + ["task_started", "working"], + ["task_complete", "completed"], + ["turn_aborted", "interrupted"], + ["task_failed", "failed"], +]); + +// Only lifecycle events count. An assistant message or a quiet log does not +// mean a turn has ended. Ignore incomplete JSONL records while Codex writes. +function parseSessionStatus(contents, startOffset = 0, previous) { + let result = previous; + let offset = startOffset; + const lines = contents.split("\n"); + for (const line of lines.slice(0, -1)) { + const eventOffset = offset; + offset += Buffer.byteLength(line) + 1; + if (previous && eventOffset <= previous.offset) continue; + let event; + try { + event = JSON.parse(line); + } catch { + continue; + } + if (event.type !== "event_msg") continue; + const status = STATUS_BY_EVENT.get(event.payload?.type); + if (!status) continue; + const turnId = event.payload.turn_id; + if (status !== "working" && result?.status === "working" && + turnId && result.turnId && turnId !== result.turnId) continue; + result = { status, turnId, offset: eventOffset }; + } + return result; +} + +class SessionStatusReader { + constructor() { + this.cache = new Map(); + } + + displayStatus(entry) { + // Logs provide history, not liveness. Only IPC may produce a spinner. + return entry.event?.status === "working" ? "unconfirmed" : entry.event?.status; + } + + async read(filePath) { + let handle; + try { + handle = await open(filePath, "r"); + const stat = await handle.stat(); + const cached = this.cache.get(filePath); + if (cached?.size === stat.size && cached.mtimeMs === stat.mtimeMs && + cached.ino === stat.ino) return this.displayStatus(cached); + const growing = cached && cached.ino === stat.ino && stat.size > cached.size; + const previous = growing ? cached.event : undefined; + const start = Math.max(0, stat.size - STATUS_TAIL_BYTES); + const buffer = Buffer.alloc(stat.size - start); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, start); + // A tail may begin in a JSON string. Skip its first partial record. + const firstLineEnd = start > 0 ? buffer.indexOf(10) : -1; + const skip = start > 0 ? (firstLineEnd < 0 ? bytesRead : firstLineEnd + 1) : 0; + const event = parseSessionStatus( + buffer.toString("utf8", skip, bytesRead), start + skip, previous, + ); + const entry = { size: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino, event }; + this.cache.set(filePath, entry); + return this.displayStatus(entry); + } catch { + this.cache.delete(filePath); + return undefined; + } finally { + await handle?.close().catch(() => undefined); + } + } + + retain(filePaths) { + const retained = new Set(filePaths); + for (const filePath of this.cache.keys()) { + if (!retained.has(filePath)) { + this.cache.delete(filePath); + } + } + } + + clear() { + this.cache.clear(); + } +} + +function watchSessionStatus(directory, onChange, vscode) { + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(directory, "**/*.jsonl"), + ); + let timer; + const refresh = () => { + clearTimeout(timer); + timer = setTimeout(onChange, 100); + }; + const subscriptions = [ + watcher.onDidCreate(refresh), + watcher.onDidChange(refresh), + watcher.onDidDelete(refresh), + ]; + return { + dispose() { + clearTimeout(timer); + for (const subscription of subscriptions) subscription.dispose(); + watcher.dispose(); + }, + }; +} + +module.exports = { parseSessionStatus, SessionStatusReader, watchSessionStatus }; diff --git a/test/extension.test.cjs b/test/extension.test.cjs index 24202a6..8f010ea 100644 --- a/test/extension.test.cjs +++ b/test/extension.test.cjs @@ -1,7 +1,7 @@ "use strict"; const assert = require("node:assert/strict"); -const { mkdtemp, mkdir, rm, writeFile } = require("node:fs/promises"); +const { appendFile, mkdtemp, mkdir, rm, writeFile } = require("node:fs/promises"); const Module = require("node:module"); const os = require("node:os"); const path = require("node:path"); @@ -10,12 +10,15 @@ const test = require("node:test"); const extensionPath = path.resolve(__dirname, "..", "extension.js"); const originalLoad = Module._load; -function loadExtension(vscode) { +function loadExtension(vscode, LiveStatus) { delete require.cache[extensionPath]; Module._load = function load(request, parent, isMain) { if (request === "vscode") { return vscode; } + if (request === "./session-live-status" && LiveStatus) { + return { LiveSessionStatus: LiveStatus }; + } return originalLoad.call(this, request, parent, isMain); }; @@ -62,7 +65,25 @@ function createHarness(t, tabs = [], options = {}) { } } + const configurationChanged = new EventEmitter(); + let showStatusIcons = options.showSessionStatusIcons ?? false; + const fileWatchers = []; + const liveMonitors = []; + class LiveStatus { + constructor({ onChange }) { + this.states = new Map(); + this.onChange = onChange; + liveMonitors.push(this); + } + setThreads(ids) { this.threads = new Set(ids); } + get(id) { return this.states.get(id); } + dispose() { this.disposed = true; this.states.clear(); } + } + const vscode = { + RelativePattern: class RelativePattern { + constructor(base, pattern) { this.base = base; this.pattern = pattern; } + }, commands: { registerCommand(command, handler) { commandHandlers.set(command, handler); @@ -93,6 +114,53 @@ function createHarness(t, tabs = [], options = {}) { Expanded: 2, }, workspace: { + getConfiguration(section) { + assert.equal(section, "codexExtras"); + return { get: (key, fallback) => key === "showSessionStatusIcons" ? showStatusIcons : fallback }; + }, + onDidChangeConfiguration: configurationChanged.event, + createFileSystemWatcher(pattern) { + const created = new EventEmitter(); + const changed = new EventEmitter(); + const deleted = new EventEmitter(); + // Exercise real filesystem changes in the temporary Codex home. The + // production extension delegates watcher lifecycle to VS Code. + let nativeWatcher; + if (options.codexHome) { + nativeWatcher = require("node:fs").watch( + options.codexHome, { recursive: true }, (event, filename) => { + if (!filename) return; + const fsPath = path.join(options.codexHome, filename); + const relative = path.relative(pattern.base, fsPath); + const matches = pattern.pattern === "*" + ? relative !== "" && !relative.includes(path.sep) && !relative.startsWith("..") + : relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative); + if (matches) { + const emitter = event === "change" ? changed + : require("node:fs").existsSync(fsPath) ? created : deleted; + emitter.fire({ fsPath }); + } + }, + ); + nativeWatcher.on("error", () => {}); + } + const watcher = { + pattern, + disposed: false, + onDidCreate: created.event, + onDidChange: changed.event, + onDidDelete: deleted.event, + dispose() { + this.disposed = true; + nativeWatcher?.close(); + created.dispose(); + changed.dispose(); + deleted.dispose(); + }, + }; + fileWatchers.push(watcher); + return watcher; + }, workspaceFolders: options.workspaceFolders ?? [ { name: "codex-extras", @@ -132,7 +200,7 @@ function createHarness(t, tabs = [], options = {}) { } try { - loadExtension(vscode).activate(context); + loadExtension(vscode, LiveStatus).activate(context); } finally { if (originalCodexHome === undefined) { delete process.env.CODEX_HOME; @@ -151,6 +219,14 @@ function createHarness(t, tabs = [], options = {}) { calls, commandHandlers, treeViews, + fileWatchers, + liveMonitors, + setStatusIcons(value) { + showStatusIcons = value; + configurationChanged.fire({ + affectsConfiguration: (key) => key === "codexExtras.showSessionStatusIcons", + }); + }, runCommand: (command = "codexExtras.newCodexAgent", ...args) => commandHandlers.get(command)(...args), }; @@ -345,3 +421,69 @@ test("groups current workspace chats before other workspaces", async (t) => { assert.equal(otherChats[0].label, "Other chat"); assert.match(otherChats[0].description, /^other · /); }); + +test("status icons are opt-in, update on live events, and toggle without reloading", async (t) => { + const codexHome = await mkdtemp(path.join(os.tmpdir(), "codex-extras-status-ui-")); + t.after(() => rm(codexHome, { recursive: true, force: true })); + const sessions = path.join(codexHome, "sessions"); + await mkdir(sessions); + const id = "019f9c23-f7ec-72c3-8eb6-0018b17bea4f"; + const filePath = path.join(sessions, `rollout-${id}.jsonl`); + const lifecycle = (type) => JSON.stringify({ + timestamp: new Date().toISOString(), type: "event_msg", payload: { type, turn_id: "one" }, + }) + "\n"; + await writeFile(filePath, '{"type":"session_meta","payload":{"cwd":"/work/current"}}\n' + lifecycle("task_complete")); + await writeFile(path.join(codexHome, "session_index.jsonl"), JSON.stringify({ id, thread_name: "Status test" })); + const harness = createHarness(t, [], { codexHome, workspaceFolders: [] }); + const provider = harness.treeViews[0].options.treeDataProvider; + let reads = 0; + const read = provider.sessionStatusReader.read.bind(provider.sessionStatusReader); + provider.sessionStatusReader.read = (...args) => { reads++; return read(...args); }; + const item = async () => { + const groups = await provider.getChildren(); + return (await provider.getChildren(groups[0]))[0]; + }; + assert.equal((await item()).iconPath.id, "comment-discussion"); + assert.equal(reads, 0, "disabled mode must not read status logs"); + assert.equal(harness.fileWatchers.length, 2, "only archive watchers when disabled"); + assert.equal(harness.liveMonitors.length, 0, "no IPC connection when disabled"); + harness.setStatusIcons(true); + assert.equal((await item()).iconPath.id, "pass"); + const watcher = harness.fileWatchers.at(-1); + assert.equal(watcher.pattern.base, sessions); + assert.equal(watcher.pattern.pattern, "**/*.jsonl"); + let changes = 0; + provider.onDidChangeTreeData(() => changes++); + const waitForRefresh = async (previous) => { + const deadline = Date.now() + 5000; + while (changes === previous && Date.now() < deadline) await delay(25); + assert.ok(changes > previous, "session log changes should refresh icons"); + }; + let previous = changes; + await appendFile(filePath, lifecycle("task_started")); + await waitForRefresh(previous); + assert.equal((await item()).iconPath.id, "clock", "log starts alone must not spin"); + const live = harness.liveMonitors.at(-1); + live.states.set(id, "working"); + live.onChange(); + assert.equal((await item()).iconPath.id, "loading~spin"); + assert.match((await item()).accessibilityInformation.label, /Working/); + live.states.clear(); + live.onChange(); + assert.equal((await item()).iconPath.id, "clock", "losing live status removes spinner"); + previous = changes; + await appendFile(filePath, lifecycle("turn_aborted")); + await waitForRefresh(previous); + assert.equal((await item()).iconPath.id, "debug-stop"); + await appendFile(filePath, lifecycle("task_failed")); + assert.equal((await item()).iconPath.id, "error"); + await writeFile(filePath, "{}\n"); + assert.equal((await item()).iconPath.id, "question"); + harness.setStatusIcons(false); + assert.ok(watcher.disposed); + assert.ok(live.disposed); + const previousReads = reads; + assert.equal((await item()).iconPath.id, "comment-discussion"); + assert.equal(reads, previousReads); + assert.equal(provider.sessionStatusReader.cache.size, 0); +}); diff --git a/test/session-live-status.test.cjs b/test/session-live-status.test.cjs new file mode 100644 index 0000000..3aa5478 --- /dev/null +++ b/test/session-live-status.test.cjs @@ -0,0 +1,137 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { EventEmitter } = require("node:events"); +const test = require("node:test"); +const { LiveSessionStatus, runtimeStatus } = require("../session-live-status"); + +function frame(message) { + const body = Buffer.from(JSON.stringify(message)); + const header = Buffer.alloc(4); + header.writeUInt32LE(body.length); + return Buffer.concat([header, body]); +} + +function harness(t) { + class Socket extends EventEmitter { + writable = true; + writes = []; + write(buffer) { this.writes.push(JSON.parse(buffer.subarray(4))); } + destroy() { + if (!this.writable) return; + this.writable = false; + this.emit("close"); + } + } + const socket = new Socket(); + let changes = 0; + const live = new LiveSessionStatus({ + codexHome: "/tmp/codex-extras-test-home", + onChange: () => changes++, + connect: () => socket, + }); + t.after(() => live.dispose()); + live.setThreads(["thread-one"]); + socket.emit("connect"); + const initialize = socket.writes[0]; + socket.emit("data", frame({ type: "response", requestId: initialize.requestId, + method: "initialize", resultType: "success", result: { clientId: "observer" } })); + const snapshot = (runtime, revision = 1, extra = {}) => ({ + type: "broadcast", method: "thread-stream-state-changed", version: 11, + sourceClientId: "owner", params: { + hostId: "local", conversationId: "thread-one", + change: { type: "snapshot", revision, conversationState: { threadRuntimeStatus: runtime } }, + }, ...extra, + }); + return { live, socket, snapshot, changes: () => changes }; +} + +test("maps runtime activity, approval waits, idle, errors, and unknown states", () => { + assert.equal(runtimeStatus({ type: "active", activeFlags: [] }), "working"); + assert.equal(runtimeStatus({ type: "active", activeFlags: ["waitingOnApproval"] }), "waiting"); + assert.equal(runtimeStatus({ type: "idle" }), "idle"); + assert.equal(runtimeStatus({ type: "systemError" }), "failed"); + assert.equal(runtimeStatus({ type: "notLoaded" }), "unconfirmed"); + assert.equal(runtimeStatus({ type: "future-state" }), "unconfirmed"); +}); + +test("initializes an observer and decodes split and combined IPC frames", (t) => { + const { live, socket, snapshot } = harness(t); + assert.equal(socket.writes[0].method, "initialize"); + assert.deepEqual(socket.writes[1].params, { + hostId: "local", conversationId: "thread-one", following: true, + }); + const message = frame(snapshot({ type: "active", activeFlags: [] })); + socket.emit("data", message.subarray(0, 2)); + assert.equal(live.get("thread-one"), undefined); + socket.emit("data", message.subarray(2)); + assert.equal(live.get("thread-one"), "working"); + socket.emit("data", Buffer.concat([ + frame(snapshot({ type: "idle" }, 2)), + frame(snapshot({ type: "systemError" }, 3)), + ])); + assert.equal(live.get("thread-one"), "failed"); + socket.emit("data", frame({ type: "client-discovery-request", requestId: "discovery" })); + assert.deepEqual(socket.writes.at(-1).response, { canHandle: false }); +}); + +test("owner disconnection and IPC loss immediately invalidate the spinner", (t) => { + const { live, socket, snapshot } = harness(t); + socket.emit("data", frame(snapshot({ type: "active", activeFlags: [] }))); + socket.emit("data", frame({ type: "broadcast", method: "client-status-changed", + params: { clientId: "owner", status: "disconnected" } })); + assert.equal(live.get("thread-one"), undefined); + socket.emit("data", frame(snapshot({ type: "active", activeFlags: [] }))); + assert.equal(live.get("thread-one"), "working"); + socket.destroy(); + assert.equal(live.get("thread-one"), undefined); +}); + +test("applies runtime patches and invalidates gaps or unsupported protocol versions", (t) => { + const { live, socket, snapshot } = harness(t); + socket.emit("data", frame(snapshot({ type: "active", activeFlags: [] }))); + const patch = (baseRevision, revision, patches) => ({ + type: "broadcast", method: "thread-stream-state-changed", version: 11, + sourceClientId: "owner", params: { hostId: "local", conversationId: "thread-one", + change: { type: "patches", baseRevision, revision, patches } }, + }); + socket.emit("data", frame(patch(1, 2, [{ op: "replace", path: ["threadRuntimeStatus"], value: { type: "idle" } }]))); + assert.equal(live.get("thread-one"), "idle"); + socket.emit("data", frame(patch(2, 3, [{ op: "replace", path: "/threadRuntimeStatus", value: { type: "active", activeFlags: [] } }]))); + assert.equal(live.get("thread-one"), "working"); + socket.emit("data", frame(patch(4, 5, []))); + assert.equal(live.get("thread-one"), undefined); + assert.equal(socket.writes.at(-1).params.following, true, "revision gaps request a fresh snapshot"); + socket.emit("data", frame(snapshot({ type: "active", activeFlags: [] }))); + socket.emit("data", frame(snapshot({ type: "active", activeFlags: [] }, 2, { version: 999 }))); + assert.equal(live.get("thread-one"), undefined, "unsupported versions cannot retain a spinner"); +}); + +test("ignores messages for other clients, threads, and hosts; retains no transcript", (t) => { + const { live, socket, snapshot } = harness(t); + socket.emit("data", frame(snapshot({ type: "active" }, 1, { targetClientIds: ["someone-else"] }))); + assert.equal(live.get("thread-one"), undefined); + const other = snapshot({ type: "active" }); + other.params.hostId = "remote"; + socket.emit("data", frame(other)); + assert.equal(live.get("thread-one"), undefined); + const valid = snapshot({ type: "active", activeFlags: [] }); + valid.params.change.conversationState.turns = [{ text: "private transcript" }]; + socket.emit("data", frame(valid)); + assert.equal(JSON.stringify([...live.states]).includes("private transcript"), false); + live.setThreads([]); + assert.equal(live.states.size, 0); + assert.equal(socket.writes.at(-1).params.following, false); +}); + +test("rejects oversized frames and closes the connection on dispose", (t) => { + const { live, socket, snapshot } = harness(t); + socket.emit("data", frame(snapshot({ type: "active", activeFlags: [] }))); + const header = Buffer.alloc(4); + header.writeUInt32LE(33 * 1024 * 1024); + socket.emit("data", header); + assert.equal(socket.writable, false); + assert.equal(live.get("thread-one"), undefined); + live.dispose(); + assert.equal(live.states.size, 0); +}); diff --git a/test/session-status.test.cjs b/test/session-status.test.cjs new file mode 100644 index 0000000..2280aa9 --- /dev/null +++ b/test/session-status.test.cjs @@ -0,0 +1,82 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { appendFile, mkdtemp, rm, writeFile } = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { parseSessionStatus, SessionStatusReader } = require("../session-status"); + +const event = (type, turnId = "turn-1") => + JSON.stringify({ timestamp: new Date().toISOString(), type: "event_msg", payload: { type, turn_id: turnId } }) + "\n"; + +test("tracks explicit starts, completion, interruption, and failure across turns", () => { + assert.equal(parseSessionStatus(event("task_started")).status, "working"); + assert.equal(parseSessionStatus(event("task_started") + event("task_complete")).status, "completed"); + assert.equal(parseSessionStatus(event("turn_aborted")).status, "interrupted"); + assert.equal(parseSessionStatus(event("task_failed")).status, "failed"); + assert.equal(parseSessionStatus(event("task_complete") + event("task_started", "turn-2")).status, "working"); + assert.equal(parseSessionStatus(event("task_started", "new") + event("task_complete", "old")).status, "working"); +}); + +test("does not infer completion from messages, tool output, or partial JSONL", () => { + const message = JSON.stringify({ type: "response_item", payload: { type: "message", role: "assistant" } }) + "\n"; + assert.equal(parseSessionStatus(message), undefined); + assert.equal(parseSessionStatus(event("task_started") + message).status, "working"); + assert.equal(parseSessionStatus("not JSON\n" + event("unknown")), undefined); + assert.equal(parseSessionStatus(event("task_started") + event("task_complete").trimEnd()).status, "working"); +}); + +test("reads bounded tails, retains status while growing, resets on truncation and deletion", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "codex-extras-status-")); + t.after(() => rm(root, { recursive: true, force: true })); + const filePath = path.join(root, "session.jsonl"); + const reader = new SessionStatusReader(); + t.after(() => reader.clear()); + assert.equal(await reader.read(filePath), undefined); + await writeFile(filePath, event("task_started")); + assert.equal(await reader.read(filePath), "unconfirmed"); + assert.equal(await reader.read(filePath), "unconfirmed"); + await appendFile(filePath, JSON.stringify({ type: "response_item", text: "x".repeat(300 * 1024) }) + "\n"); + assert.equal(await reader.read(filePath), "unconfirmed", "cached start survives large appended records"); + const freshReader = new SessionStatusReader(); + t.after(() => freshReader.clear()); + assert.equal(await freshReader.read(filePath), undefined, "does not read beyond the tail budget"); + await appendFile(filePath, event("task_complete")); + assert.equal(await reader.read(filePath), "completed"); + assert.equal(await freshReader.read(filePath), "completed"); + await writeFile(filePath, "{}\n"); + assert.equal(await reader.read(filePath), undefined, "old state cannot survive truncation"); + await rm(filePath); + assert.equal(await reader.read(filePath), undefined); + assert.equal(reader.cache.size, 0); +}); + +test("handles partial writes and UTF-8 without interpreting a partial first record", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "codex-extras-status-")); + t.after(() => rm(root, { recursive: true, force: true })); + const filePath = path.join(root, "session.jsonl"); + const reader = new SessionStatusReader(); + t.after(() => reader.clear()); + await writeFile(filePath, event("task_started") + event("task_complete").trimEnd()); + assert.equal(await reader.read(filePath), "unconfirmed"); + await appendFile(filePath, "\n"); + assert.equal(await reader.read(filePath), "completed"); + await writeFile(filePath, JSON.stringify({ text: "ą".repeat(300 * 1024) }) + "\n" + event("turn_aborted")); + assert.equal(await new SessionStatusReader().read(filePath), "interrupted"); + reader.retain([]); + assert.equal(reader.cache.size, 0); +}); + +test("logs alone never show a spinner, even with fresh token events", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "codex-extras-status-")); + t.after(() => rm(root, { recursive: true, force: true })); + const filePath = path.join(root, "session.jsonl"); + const reader = new SessionStatusReader(); + await writeFile(filePath, event("task_started")); + assert.equal(await reader.read(filePath), "unconfirmed"); + await appendFile(filePath, event("token_count")); + assert.equal(await reader.read(filePath), "unconfirmed"); + await appendFile(filePath, event("task_complete")); + assert.equal(await reader.read(filePath), "completed"); +}); From 0ad196d4f02da36eb28185d338cbd9bf2f34ea24 Mon Sep 17 00:00:00 2001 From: priva7eer Date: Sat, 17 Oct 2026 00:53:06 +0200 Subject: [PATCH 2/8] Use consistent theme colors for session status icons --- CHANGELOG.md | 3 +++ README.md | 5 +++++ extension.js | 23 ++++++++++++++--------- test/extension.test.cjs | 22 ++++++++++++++++++++++ 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f52361..c026ba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to Codex Extras are documented in this file. ## Unreleased +- Apply consistent, theme-aware colors to status icons for working, completed, + waiting, interrupted, failed, and inactive/unknown states. + - Add opt-in session status icons, using experimental local Codex IPC for live activity and bounded log reads for recorded results. Never infer running from unfinished logs; invalidate live activity on disconnection. Keep the original diff --git a/README.md b/README.md index e47058e..ad60551 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,11 @@ A bell means Codex is waiting for input or approval, and a hollow circle means i A clock means activity is unconfirmed, not that the task has completed. Changing the setting takes effect immediately. +Status icons use theme-aware colors: blue for working, green for completed, +yellow for waiting, orange for interrupted, red for failed, and muted foreground +for idle or unknown/unconfirmed states. Shapes and tooltip labels also identify +each state, so color is not the only cue. + This optional mode reads at most the last 256 KiB of each local session log and caches the result. Only explicit lifecycle events determine status; assistant messages and file modification times do not imply completion. File system events diff --git a/extension.js b/extension.js index b232d40..2b8ff40 100644 --- a/extension.js +++ b/extension.js @@ -31,13 +31,13 @@ let panelSequence = 0; let openQueue = Promise.resolve(); const SESSION_STATUSES = { - working: { icon: "loading~spin", label: "Working (live)" }, - waiting: { icon: "bell", label: "Waiting for input or approval (live)" }, - idle: { icon: "circle-outline", label: "Idle (live)" }, - unconfirmed: { icon: "clock", label: "Activity unconfirmed" }, - completed: { icon: "pass", label: "Completed" }, - interrupted: { icon: "debug-stop", label: "Interrupted" }, - failed: { icon: "error", label: "Failed" }, + working: { icon: "loading~spin", color: "charts.blue", label: "Working (live)" }, + waiting: { icon: "bell", color: "charts.yellow", label: "Waiting for input or approval (live)" }, + idle: { icon: "circle-outline", color: "descriptionForeground", label: "Idle (live)" }, + unconfirmed: { icon: "clock", color: "descriptionForeground", label: "Activity unconfirmed" }, + completed: { icon: "pass", color: "testing.iconPassed", label: "Completed" }, + interrupted: { icon: "debug-stop", color: "charts.orange", label: "Interrupted" }, + failed: { icon: "error", color: "errorForeground", label: "Failed" }, }; class CodexChatItem extends vscode.TreeItem { @@ -46,7 +46,9 @@ class CodexChatItem extends vscode.TreeItem { this.id = session.id; const status = session.showStatusIcons - ? SESSION_STATUSES[session.status] ?? { icon: "question", label: "Status unknown" } + ? SESSION_STATUSES[session.status] ?? { + icon: "question", color: "descriptionForeground", label: "Status unknown", + } : undefined; this.description = [session.workspaceName, formatUpdatedAt(session.updatedAt)] .filter(Boolean) @@ -64,7 +66,10 @@ class CodexChatItem extends vscode.TreeItem { ] .filter(Boolean) .join("\n"); - this.iconPath = new vscode.ThemeIcon(status?.icon ?? "comment-discussion"); + this.iconPath = new vscode.ThemeIcon( + status?.icon ?? "comment-discussion", + status ? new vscode.ThemeColor(status.color) : undefined, + ); this.contextValue = "codexChat"; this.command = { command: OPEN_CHAT_COMMAND_ID, diff --git a/test/extension.test.cjs b/test/extension.test.cjs index 8f010ea..4e83f5b 100644 --- a/test/extension.test.cjs +++ b/test/extension.test.cjs @@ -103,6 +103,12 @@ function createHarness(t, tabs = [], options = {}) { }, EventEmitter, ThemeIcon: class ThemeIcon { + constructor(id, color) { + this.id = id; + this.color = color; + } + }, + ThemeColor: class ThemeColor { constructor(id) { this.id = id; } @@ -444,11 +450,13 @@ test("status icons are opt-in, update on live events, and toggle without reloadi return (await provider.getChildren(groups[0]))[0]; }; assert.equal((await item()).iconPath.id, "comment-discussion"); + assert.equal((await item()).iconPath.color, undefined); assert.equal(reads, 0, "disabled mode must not read status logs"); assert.equal(harness.fileWatchers.length, 2, "only archive watchers when disabled"); assert.equal(harness.liveMonitors.length, 0, "no IPC connection when disabled"); harness.setStatusIcons(true); assert.equal((await item()).iconPath.id, "pass"); + assert.equal((await item()).iconPath.color.id, "testing.iconPassed"); const watcher = harness.fileWatchers.at(-1); assert.equal(watcher.pattern.base, sessions); assert.equal(watcher.pattern.pattern, "**/*.jsonl"); @@ -467,18 +475,32 @@ test("status icons are opt-in, update on live events, and toggle without reloadi live.states.set(id, "working"); live.onChange(); assert.equal((await item()).iconPath.id, "loading~spin"); + assert.equal((await item()).iconPath.color.id, "charts.blue"); assert.match((await item()).accessibilityInformation.label, /Working/); + for (const [state, icon, color] of [ + ["waiting", "bell", "charts.yellow"], + ["idle", "circle-outline", "descriptionForeground"], + ]) { + live.states.set(id, state); + const chat = await item(); + assert.equal(chat.iconPath.id, icon); + assert.equal(chat.iconPath.color.id, color); + } live.states.clear(); live.onChange(); assert.equal((await item()).iconPath.id, "clock", "losing live status removes spinner"); + assert.equal((await item()).iconPath.color.id, "descriptionForeground"); previous = changes; await appendFile(filePath, lifecycle("turn_aborted")); await waitForRefresh(previous); assert.equal((await item()).iconPath.id, "debug-stop"); + assert.equal((await item()).iconPath.color.id, "charts.orange"); await appendFile(filePath, lifecycle("task_failed")); assert.equal((await item()).iconPath.id, "error"); + assert.equal((await item()).iconPath.color.id, "errorForeground"); await writeFile(filePath, "{}\n"); assert.equal((await item()).iconPath.id, "question"); + assert.equal((await item()).iconPath.color.id, "descriptionForeground"); harness.setStatusIcons(false); assert.ok(watcher.disposed); assert.ok(live.disposed); From d515c7dfa09093bf36e9b35f09905116d0490d5f Mon Sep 17 00:00:00 2001 From: priva7eer Date: Sat, 17 Oct 2026 01:08:58 +0200 Subject: [PATCH 3/8] Track unanswered questions and reviewed task results --- CHANGELOG.md | 6 +++ README.md | 19 +++++-- docs/SESSION-STATUS.md | 25 ++++++++- extension.js | 57 ++++++++++++++++---- session-live-status.js | 42 +++++++++++++-- session-questions.js | 76 ++++++++++++++++++++++++++ session-status.js | 6 +++ test/extension.test.cjs | 88 ++++++++++++++++++++++++++++++- test/session-live-status.test.cjs | 56 ++++++++++++++++++++ test/session-questions.test.cjs | 63 ++++++++++++++++++++++ 10 files changed, 416 insertions(+), 22 deletions(-) create mode 100644 session-questions.js create mode 100644 test/session-questions.test.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index c026ba2..50265ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to Codex Extras are documented in this file. ## Unreleased +- Show a yellow question mark for unanswered async questions, with blocking waits + taking priority. Match replies by question ID and keep unknown status distinct. +- Treat green check marks as new results to review. Hide a specific result's icon + after its editor opens successfully, persist review state across reloads, and + show green again for a newer completed turn. + - Apply consistent, theme-aware colors to status icons for working, completed, waiting, interrupted, failed, and inactive/unknown states. diff --git a/README.md b/README.md index ad60551..5545515 100644 --- a/README.md +++ b/README.md @@ -51,14 +51,23 @@ The official Codex extension is declared as a dependency and is installed automa | `codexExtras.showSessionStatusIcons` | `false` | Experimental: replaces conversation icons with live task status and recorded completion events. | Enable **Codex Extras: Show Session Status Icons** for a spinner while working, -a check mark after completion, a stop icon for interrupted turns, or an error icon -for failed turns. A question mark means no supported lifecycle event was found. +a green check mark for a new result to review, a stop icon for interrupted turns, +or an error icon for failed turns. A yellow question mark means an unanswered +non-blocking question; the agent may still be working. It disappears after all +questions have matching replies. A gray slashed circle means status is unknown. A bell means Codex is waiting for input or approval, and a hollow circle means idle. A clock means activity is unconfirmed, not that the task has completed. Changing the setting takes effect immediately. -Status icons use theme-aware colors: blue for working, green for completed, -yellow for waiting, orange for interrupted, red for failed, and muted foreground +Opening a green chat through Codex Extras marks that specific result as reviewed +after the open command succeeds and its editor tab exists. Its status icon then +disappears. This is saved in VS Code extension storage across reloads. A later +completed turn gets a new green mark; opening an older result cannot consume it. +Failed opens do not mark results as reviewed. Blocking waits take priority over +non-blocking questions, and unanswered questions take priority over other states. + +Status icons use theme-aware colors: blue for working, green for new results, +yellow for questions/waiting, orange for interrupted, red for failed, and muted foreground for idle or unknown/unconfirmed states. Shapes and tooltip labels also identify each state, so color is not the only cue. @@ -99,7 +108,7 @@ Codex Extras contains no telemetry or network requests. It reads the local Codex session index to populate the chat list and opens editor panels provided by the official Codex extension. If status icons are enabled, it also reads a bounded tail of session logs locally to identify lifecycle events and receives local IPC -state snapshots. Only runtime status is retained from snapshots; conversation +state snapshots. Only runtime status and unanswered question IDs are retained from snapshots; conversation messages are discarded and are never displayed, saved, or sent elsewhere. ## Contributing diff --git a/docs/SESSION-STATUS.md b/docs/SESSION-STATUS.md index 703d84e..8617c0e 100644 --- a/docs/SESSION-STATUS.md +++ b/docs/SESSION-STATUS.md @@ -35,13 +35,32 @@ read-only ownership discovery, and repeated runtime-state snapshots. `{hostId: "local", conversationId, following}`. - Runtime stream: `thread-stream-state-changed`, version 11. Snapshots contain `change.conversationState.threadRuntimeStatus`. Patches carry base and new - revisions. Only runtime-status fields are retained; transcript patches are ignored. + revisions. Retain runtime status and unresolved question IDs, never message text. - `active` means working; nonempty `activeFlags` means waiting for input/approval. `idle` means idle, `systemError` means failed, other states are unconfirmed. - An owner-disconnection event or lost IPC connection clears live states. - A revision gap requests a fresh snapshot. Unsupported versions cannot retain a running indicator. Discovery requests are answered with `canHandle: false`. +## Questions and reviewed results + +Async questions are `agentMessage` items with `questions`. Their IDs are the JSON +tuple `["request_user_input_async", item.id, questionIndex]`. User and steering +messages carrying `` resolve those exact IDs. +Inspect both `turns` and canonical `turnHistory.history.entitiesByKey`; deduplicate +items and match replies independently of entity order. Retain only unresolved IDs. +Question/reply patches request a debounced fresh snapshot; ordinary assistant text +streaming does not trigger snapshot requests. Blocking runtime waits have priority, +then unanswered async questions, then the underlying task state. + +A completion's identity is its rollout path plus turn ID (or lifecycle byte offset +for older logs without a turn ID). Opening a green result through Extras records +that identity in `globalState` under `codexExtras.reviewedResults`, only after +`vscode.openWith` resolves and its local conversation tab exists. Capture the +displayed identity before opening so a newer completion cannot be marked reviewed +by a stale click. Reviewed completions have no status icon; a later completion has +a different identity and is green again. No source transcripts are modified. + The adapter never becomes an owner and sends no task-control requests. Receiving snapshots can temporarily decode conversation data in memory; it keeps no messages and does not write source logs, configuration, or transcripts. @@ -57,4 +76,6 @@ are not currently supported for status discovery. Tests cover option toggling, log parsing and bounds, incomplete writes, live snapshots and runtime patches, waiting states, owner and transport disconnection, -revision mismatch, protocol mismatch, frame bounds, and observer-only behavior. +revision mismatch, protocol mismatch, frame bounds, observer-only behavior, async +question/reply matching, and persistent review markers including failed opens and +new completions racing with old editor opens. diff --git a/extension.js b/extension.js index 2b8ff40..73cfed1 100644 --- a/extension.js +++ b/extension.js @@ -26,6 +26,7 @@ const CODEX_EDITOR_VIEW_TYPE = "chatgpt.conversationEditor"; const CODEX_NEW_PANEL_SCHEME = "openai-codex"; const CODEX_ROUTE_AUTHORITY = "route"; const CODEX_NEW_PANEL_PATH = "/extension/panel/new"; +const REVIEWED_RESULTS_KEY = "codexExtras.reviewedResults"; let panelSequence = 0; let openQueue = Promise.resolve(); @@ -33,9 +34,11 @@ let openQueue = Promise.resolve(); const SESSION_STATUSES = { working: { icon: "loading~spin", color: "charts.blue", label: "Working (live)" }, waiting: { icon: "bell", color: "charts.yellow", label: "Waiting for input or approval (live)" }, + question: { icon: "question", color: "charts.yellow", label: "Unanswered non-blocking question (agent may still be working)" }, idle: { icon: "circle-outline", color: "descriptionForeground", label: "Idle (live)" }, unconfirmed: { icon: "clock", color: "descriptionForeground", label: "Activity unconfirmed" }, - completed: { icon: "pass", color: "testing.iconPassed", label: "Completed" }, + completed: { icon: "pass", color: "testing.iconPassed", label: "New result to review" }, + reviewed: { label: "Result reviewed" }, interrupted: { icon: "debug-stop", color: "charts.orange", label: "Interrupted" }, failed: { icon: "error", color: "errorForeground", label: "Failed" }, }; @@ -45,9 +48,10 @@ class CodexChatItem extends vscode.TreeItem { super(session.title, vscode.TreeItemCollapsibleState.None); this.id = session.id; + this.resultKey = session.status === "completed" ? session.resultKey : null; const status = session.showStatusIcons ? SESSION_STATUSES[session.status] ?? { - icon: "question", color: "descriptionForeground", label: "Status unknown", + icon: "circle-slash", color: "descriptionForeground", label: "Status unknown", } : undefined; this.description = [session.workspaceName, formatUpdatedAt(session.updatedAt)] @@ -66,7 +70,7 @@ class CodexChatItem extends vscode.TreeItem { ] .filter(Boolean) .join("\n"); - this.iconPath = new vscode.ThemeIcon( + this.iconPath = status && !status.icon ? undefined : new vscode.ThemeIcon( status?.icon ?? "comment-discussion", status ? new vscode.ThemeColor(status.color) : undefined, ); @@ -74,7 +78,7 @@ class CodexChatItem extends vscode.TreeItem { this.command = { command: OPEN_CHAT_COMMAND_ID, title: "Open Codex Chat", - arguments: [session.id], + arguments: [{ id: session.id, resultKey: this.resultKey }], }; this.accessibilityInformation = { label: `${session.title}, Codex chat${status ? `, ${status.label}` : ""}`, @@ -102,10 +106,12 @@ class CodexChatGroupItem extends vscode.TreeItem { } class CodexChatHistoryProvider { - constructor(sessionIndexPath, sessionsDirectory, getWorkspaceFolders) { + constructor(sessionIndexPath, sessionsDirectory, getWorkspaceFolders, getReviewedResults) { this.sessionIndexPath = sessionIndexPath; this.sessionsDirectory = sessionsDirectory; this.getWorkspaceFolders = getWorkspaceFolders; + this.getReviewedResults = getReviewedResults; + this.displayedResults = new Map(); this.workspaceBySessionId = new Map(); this.showStatusIcons = false; this.sessionStatusReader = new SessionStatusReader(); @@ -159,15 +165,25 @@ class CodexChatHistoryProvider { ? await this.sessionStatusReader.read(sessionFiles.get(session.id)) : undefined; const liveStatus = this.liveStatus?.get(session.id); + const resultKey = sessionFiles?.has(session.id) + ? this.sessionStatusReader.resultKey(sessionFiles.get(session.id)) : undefined; + let status = liveStatus && liveStatus !== "idle" ? liveStatus + : liveStatus === "idle" && historicalStatus === "unconfirmed" ? "idle" + : historicalStatus ?? liveStatus; + if (status === "completed" && resultKey === this.getReviewedResults()[session.id]) { + status = "reviewed"; + } return { ...session, workspacePath: this.workspaceBySessionId.get(session.id), showStatusIcons, - status: liveStatus && liveStatus !== "idle" ? liveStatus - : liveStatus === "idle" && historicalStatus === "unconfirmed" ? "idle" - : historicalStatus ?? liveStatus, + status, + resultKey, }; })); + this.displayedResults = new Map(enrichedSessions.map((session) => [ + session.id, session.status === "completed" ? session.resultKey : undefined, + ])); if (sessionFiles) this.sessionStatusReader.retain(sessionFiles.values()); this.statusEmitter.fire(undefined); @@ -395,6 +411,8 @@ function watchSessionIndex(sessionIndexPath, onChange) { * @param {import("vscode").ExtensionContext} context */ function activate(context) { + let reviewedResults = context.globalState.get(REVIEWED_RESULTS_KEY, {}); + let reviewQueue = Promise.resolve(); const openNewChat = () => { openQueue = openQueue.catch(() => undefined).then(openNewCodexAgent); return openQueue; @@ -404,7 +422,28 @@ function activate(context) { sessionIndexPath, getSessionsDirectory(), () => vscode.workspace.workspaceFolders ?? [], + () => reviewedResults, ); + const openChat = async (session) => { + const id = typeof session === "string" ? session : session?.id; + // Capture the displayed result before opening: a newer turn may finish + // while the editor is being created and must remain unread. + const resultKey = session && typeof session === "object" && "resultKey" in session + ? session.resultKey : historyProvider.displayedResults.get(id); + await openCodexChat(session); + if (!resultKey || !vscode.window.tabGroups.all.some((group) => group.tabs.some((tab) => { + const uri = tab.input?.uri; + return uri?.scheme === CODEX_NEW_PANEL_SCHEME && uri.authority === CODEX_ROUTE_AUTHORITY && + uri.path === `/local/${id}`; + }))) return; + reviewQueue = reviewQueue.catch(() => {}).then(async () => { + const next = { ...reviewedResults, [id]: resultKey }; + await context.globalState.update(REVIEWED_RESULTS_KEY, next); + reviewedResults = next; + historyProvider.refresh(); + }); + await reviewQueue; + }; const historyView = vscode.window.createTreeView(CHAT_HISTORY_VIEW_ID, { treeDataProvider: historyProvider, showCollapseAll: false, @@ -450,7 +489,7 @@ function activate(context) { context.subscriptions.push( vscode.commands.registerCommand(COMMAND_ID, openNewChat), vscode.commands.registerCommand(NEW_CHAT_COMMAND_ID, openNewChat), - vscode.commands.registerCommand(OPEN_CHAT_COMMAND_ID, openCodexChat), + vscode.commands.registerCommand(OPEN_CHAT_COMMAND_ID, openChat), vscode.commands.registerCommand(REFRESH_CHATS_COMMAND_ID, () => historyProvider.refresh(), ), diff --git a/session-live-status.js b/session-live-status.js index c52418d..b5ede52 100644 --- a/session-live-status.js +++ b/session-live-status.js @@ -4,6 +4,7 @@ const net = require("node:net"); const os = require("node:os"); const path = require("node:path"); const { randomUUID } = require("node:crypto"); +const { pendingQuestionIds, questionPatchMayChange } = require("./session-questions"); // Experimental: this is Codex's internal cross-window protocol, not its public // app-server API. Reject unknown stream versions rather than infer activity. @@ -30,6 +31,7 @@ class LiveSessionStatus { : path.join(os.tmpdir(), "codex-ipc", process.getuid?.() ? `ipc-${process.getuid()}.sock` : "ipc.sock"); this.threads = new Set(); this.states = new Map(); + this.snapshotRequests = new Map(); this.buffer = Buffer.alloc(0); this.disposed = false; this.start(); @@ -69,6 +71,7 @@ class LiveSessionStatus { this.clientId = undefined; this.buffer = Buffer.alloc(0); this.states.clear(); + this.clearSnapshotRequests(); this.onChange(); if (!this.disposed) { this.retry = setTimeout(() => this.start(!useLegacy && !!this.legacyEndpoint), 2000); @@ -98,12 +101,29 @@ class LiveSessionStatus { if (!next.has(id)) { this.follow(id, false); this.states.delete(id); + clearTimeout(this.snapshotRequests.get(id)); + this.snapshotRequests.delete(id); } } for (const id of next) if (!this.threads.has(id)) this.follow(id, true); this.threads = next; } + requestSnapshot(id) { + if (this.snapshotRequests.has(id)) return; + const timer = setTimeout(() => { + this.snapshotRequests.delete(id); + if (this.threads.has(id)) this.follow(id, true); + }, 100); + timer.unref(); + this.snapshotRequests.set(id, timer); + } + + clearSnapshotRequests() { + for (const timer of this.snapshotRequests.values()) clearTimeout(timer); + this.snapshotRequests.clear(); + } + receive(message) { if (message.type === "response" && message.requestId === this.initializeId) { if (message.resultType !== "success" || typeof message.result?.clientId !== "string") { @@ -125,7 +145,11 @@ class LiveSessionStatus { if (message.method === "client-status-changed") { if (message.params?.status === "disconnected") { for (const [id, state] of this.states) { - if (state.owner === message.params.clientId) this.states.delete(id); + if (state.owner === message.params.clientId) { + this.states.delete(id); + clearTimeout(this.snapshotRequests.get(id)); + this.snapshotRequests.delete(id); + } } this.onChange(); } @@ -148,9 +172,13 @@ class LiveSessionStatus { return; } let value; + let pendingQuestions = previous?.pendingQuestions ?? new Set(); if (change?.type === "snapshot") { - // Keep only runtime status; discard conversation messages immediately. + // Retain runtime status and unresolved IDs, never question/answer text. value = change.conversationState?.threadRuntimeStatus; + pendingQuestions = pendingQuestionIds(change.conversationState); + clearTimeout(this.snapshotRequests.get(id)); + this.snapshotRequests.delete(id); } else if (change?.type === "patches") { if (!previous || previous.owner !== message.sourceClientId || previous.revision !== change.baseRevision) { @@ -163,6 +191,7 @@ class LiveSessionStatus { for (const patch of change.patches ?? []) { const segments = Array.isArray(patch.path) ? patch.path : typeof patch.path === "string" ? patch.path.split("/").slice(1) : []; + if (questionPatchMayChange(patch, segments)) this.requestSnapshot(id); if (!segments.length) { value = patch.value?.threadRuntimeStatus; } else if (segments[0] === "threadRuntimeStatus") { @@ -180,8 +209,12 @@ class LiveSessionStatus { this.onChange(); return; } - const status = runtimeStatus(value); - this.states.set(id, { owner: message.sourceClientId, revision: change.revision, runtime: value, status }); + const baseStatus = runtimeStatus(value); + const status = baseStatus === "waiting" ? "waiting" + : pendingQuestions.size ? "question" : baseStatus; + this.states.set(id, { + owner: message.sourceClientId, revision: change.revision, runtime: value, status, pendingQuestions, + }); if (status !== previous?.status) this.onChange(); } @@ -192,6 +225,7 @@ class LiveSessionStatus { dispose() { this.disposed = true; clearTimeout(this.retry); + this.clearSnapshotRequests(); for (const id of this.threads) this.follow(id, false); this.states.clear(); this.socket?.destroy(); diff --git a/session-questions.js b/session-questions.js new file mode 100644 index 0000000..a0ef6c9 --- /dev/null +++ b/session-questions.js @@ -0,0 +1,76 @@ +"use strict"; + +const REPLY_START = ""; +const REPLY_END = ""; + +function itemQuestionIds(item) { + const asked = []; + const answered = []; + if (item?.type === "agentMessage" && typeof item.id === "string" && Array.isArray(item.questions)) { + for (let index = 0; index < item.questions.length; index++) { + asked.push(JSON.stringify(["request_user_input_async", item.id, index])); + } + } + if (item?.type === "userMessage" || item?.type === "steeringUserMessage") { + const content = item.type === "userMessage" ? item.content : item.input; + if (!Array.isArray(content)) return { asked, answered }; + const text = content.filter((part) => part?.type === "text") + .map((part) => part.text ?? "").join("\n").trim(); + if (!text.startsWith(REPLY_START) || !text.endsWith(REPLY_END)) return { asked, answered }; + try { + const parsed = JSON.parse(text.slice(REPLY_START.length, -REPLY_END.length)); + for (const reply of Array.isArray(parsed) ? parsed : [parsed]) { + if (typeof reply?.questionItemId !== "string") continue; + const id = JSON.parse(reply.questionItemId); + if (Array.isArray(id) && id.length === 3 && id[0] === "request_user_input_async" && + typeof id[1] === "string" && Number.isInteger(id[2]) && id[2] >= 0) { + answered.push(JSON.stringify(id)); + } + } + } catch { + // Unsupported or malformed reply markup cannot resolve a question. + } + } + return { asked, answered }; +} + +function pendingQuestionIds(state) { + const asked = new Set(); + const answered = new Set(); + const turns = [ + ...(Array.isArray(state?.turns) ? state.turns : []), + ...Object.values(state?.turnHistory?.history?.entitiesByKey ?? {}), + ]; + for (const turn of turns) { + if (!Array.isArray(turn?.items)) continue; + for (const item of turn.items) { + const ids = itemQuestionIds(item); + for (const id of ids.asked) asked.add(id); + for (const id of ids.answered) answered.add(id); + } + } + for (const id of answered) asked.delete(id); + return asked; +} + +// Refresh a question projection from a snapshot when its source changes. +// Ignore ordinary assistant text deltas; never retain conversation bodies. +function questionPatchMayChange(patch, segments) { + if (!segments.length) return true; + if (segments[0] !== "turnHistory" && segments[0] !== "turns") return false; + if (segments.includes("questions")) return true; + if (segments[0] === "turns" && segments.length <= 2) return true; + if (segments[0] === "turnHistory" && segments.length <= 4 && + !["generation", "isComplete", "islands"].includes(segments.at(-1))) return true; + const itemIndex = segments.indexOf("items"); + if (itemIndex < 0) return false; + if (segments.length === itemIndex + 1) return true; + if (segments.length === itemIndex + 2) { + if (patch.op === "remove") return true; + const ids = itemQuestionIds(patch.value); + return ids.asked.length > 0 || ids.answered.length > 0; + } + return ["input", "content", "type", "id"].includes(segments[itemIndex + 2]); +} + +module.exports = { itemQuestionIds, pendingQuestionIds, questionPatchMayChange }; diff --git a/session-status.js b/session-status.js index f8d80a2..42591f7 100644 --- a/session-status.js +++ b/session-status.js @@ -86,6 +86,12 @@ class SessionStatusReader { } } + resultKey(filePath) { + const entry = this.cache.get(filePath); + if (entry?.event?.status !== "completed") return undefined; + return JSON.stringify([filePath, entry.event.turnId ?? entry.event.offset]); + } + clear() { this.cache.clear(); } diff --git a/test/extension.test.cjs b/test/extension.test.cjs index 4e83f5b..cecd100 100644 --- a/test/extension.test.cjs +++ b/test/extension.test.cjs @@ -91,6 +91,11 @@ function createHarness(t, tabs = [], options = {}) { }, async executeCommand(...args) { calls.push(args); + if (options.beforeOpen && args[0] === "vscode.openWith") await options.beforeOpen(); + if (options.failOpen) throw new Error("Synthetic editor opening failure"); + if (args[0] === "vscode.openWith" && args[1]?.path.startsWith("/local/") && !options.skipTabCreation) { + tabs.push({ input: { uri: args[1] } }); + } }, }, Uri: { @@ -198,7 +203,14 @@ function createHarness(t, tabs = [], options = {}) { }, }, }; - const context = { subscriptions: [] }; + const storedState = options.storedState ?? new Map(); + const context = { + subscriptions: [], + globalState: { + get: (key, fallback) => storedState.get(key) ?? fallback, + async update(key, value) { storedState.set(key, value); }, + }, + }; const originalCodexHome = process.env.CODEX_HOME; if (options.codexHome) { @@ -227,6 +239,7 @@ function createHarness(t, tabs = [], options = {}) { treeViews, fileWatchers, liveMonitors, + storedState, setStatusIcons(value) { showStatusIcons = value; configurationChanged.fire({ @@ -479,6 +492,7 @@ test("status icons are opt-in, update on live events, and toggle without reloadi assert.match((await item()).accessibilityInformation.label, /Working/); for (const [state, icon, color] of [ ["waiting", "bell", "charts.yellow"], + ["question", "question", "charts.yellow"], ["idle", "circle-outline", "descriptionForeground"], ]) { live.states.set(id, state); @@ -499,7 +513,7 @@ test("status icons are opt-in, update on live events, and toggle without reloadi assert.equal((await item()).iconPath.id, "error"); assert.equal((await item()).iconPath.color.id, "errorForeground"); await writeFile(filePath, "{}\n"); - assert.equal((await item()).iconPath.id, "question"); + assert.equal((await item()).iconPath.id, "circle-slash"); assert.equal((await item()).iconPath.color.id, "descriptionForeground"); harness.setStatusIcons(false); assert.ok(watcher.disposed); @@ -509,3 +523,73 @@ test("status icons are opt-in, update on live events, and toggle without reloadi assert.equal(reads, previousReads); assert.equal(provider.sessionStatusReader.cache.size, 0); }); + +async function resultFixture(t) { + const codexHome = await mkdtemp(path.join(os.tmpdir(), "codex-extras-reviewed-")); + t.after(() => rm(codexHome, { recursive: true, force: true })); + const id = "019f9c23-f7ec-72c3-8eb6-0018b17bea4f"; + await mkdir(path.join(codexHome, "sessions")); + const filePath = path.join(codexHome, "sessions", `rollout-${id}.jsonl`); + const lifecycle = (type, turnId) => JSON.stringify({ + type: "event_msg", payload: { type, turn_id: turnId }, + }) + "\n"; + await writeFile(filePath, lifecycle("task_complete", "turn-one")); + await writeFile(path.join(codexHome, "session_index.jsonl"), JSON.stringify({ id, thread_name: "Review test" })); + const options = { codexHome, workspaceFolders: [], showSessionStatusIcons: true }; + const item = async (harness) => { + const provider = harness.treeViews[0].options.treeDataProvider; + const groups = await provider.getChildren(); + return (await provider.getChildren(groups[0]))[0]; + }; + return { id, filePath, lifecycle, options, item }; +} + +test("opening a green result hides its icon across reloads; a new result turns green again", async (t) => { + const { id, filePath, lifecycle, options, item } = await resultFixture(t); + const harness = createHarness(t, [], options); + assert.equal((await item(harness)).iconPath.id, "pass"); + await harness.runCommand("codexExtras.openChat", id); + assert.equal((await item(harness)).iconPath, undefined); + assert.match((await item(harness)).tooltip, /Result reviewed/); + const reloaded = createHarness(t, [], { ...options, storedState: harness.storedState }); + assert.equal((await item(reloaded)).iconPath, undefined); + await appendFile(filePath, lifecycle("task_started", "turn-two")); + assert.equal((await item(reloaded)).iconPath.id, "clock"); + await appendFile(filePath, lifecycle("task_complete", "turn-two")); + assert.equal((await item(reloaded)).iconPath.id, "pass"); + await reloaded.runCommand("codexExtras.openChat", { id }); + assert.equal((await item(reloaded)).iconPath, undefined); +}); + +test("a failed open or missing editor tab cannot consume a result", async (t) => { + const { id, options, item } = await resultFixture(t); + const harness = createHarness(t, [], { ...options, failOpen: true }); + assert.equal((await item(harness)).iconPath.id, "pass"); + await assert.rejects(harness.runCommand("codexExtras.openChat", id), /opening failure/); + assert.equal((await item(harness)).iconPath.id, "pass"); + assert.equal(harness.storedState.size, 0); + const noTab = createHarness(t, [], { ...options, skipTabCreation: true }); + await item(noTab); + await noTab.runCommand("codexExtras.openChat", id); + assert.equal((await item(noTab)).iconPath.id, "pass"); +}); + +test("opening an old result never consumes a newer turn that finishes during opening", async (t) => { + const { id, filePath, lifecycle, options, item } = await resultFixture(t); + const harness = createHarness(t, [], { ...options, async beforeOpen() { + await appendFile(filePath, lifecycle("task_started", "turn-two") + lifecycle("task_complete", "turn-two")); + } }); + await item(harness); + await harness.runCommand("codexExtras.openChat", id); + assert.equal((await item(harness)).iconPath.id, "pass"); +}); + +test("a stale tree item consumes only the result that item displayed", async (t) => { + const { filePath, lifecycle, options, item } = await resultFixture(t); + const harness = createHarness(t, [], options); + const oldItem = await item(harness); + await appendFile(filePath, lifecycle("task_complete", "turn-two")); + await item(harness); + await harness.runCommand(oldItem.command.command, ...oldItem.command.arguments); + assert.equal((await item(harness)).iconPath.id, "pass"); +}); diff --git a/test/session-live-status.test.cjs b/test/session-live-status.test.cjs index 3aa5478..d48a7d5 100644 --- a/test/session-live-status.test.cjs +++ b/test/session-live-status.test.cjs @@ -135,3 +135,59 @@ test("rejects oversized frames and closes the connection on dispose", (t) => { live.dispose(); assert.equal(live.states.size, 0); }); + +test("unanswered async questions override work or idle; blocking waits take priority", (t) => { + const { live, socket, snapshot } = harness(t); + const withQuestions = (runtime, answered = false) => { + const message = snapshot(runtime); + const items = [{ type: "agentMessage", id: "call-one", questions: [{ title: "Private question" }] }]; + if (answered) items.push({ type: "userMessage", content: [{ type: "text", text: + '' + JSON.stringify([{ + questionItemId: JSON.stringify(["request_user_input_async", "call-one", 0]), + answer: "Private answer", + }]) + '', + }] }); + message.params.change.conversationState.turnHistory = { history: { entitiesByKey: { one: { items } } } }; + return message; + }; + socket.emit("data", frame(withQuestions({ type: "active", activeFlags: [] }))); + assert.equal(live.get("thread-one"), "question"); + const retained = [...live.states.values()][0]; + assert.equal(retained.pendingQuestions.size, 1); + assert.equal(JSON.stringify({ ...retained, pendingQuestions: [...retained.pendingQuestions] }).includes("Private"), false); + socket.emit("data", frame(withQuestions({ type: "idle" }))); + assert.equal(live.get("thread-one"), "question", "questions remain visible after agent finishes"); + socket.emit("data", frame(withQuestions({ type: "active", activeFlags: ["waitingOnUserInput"] }))); + assert.equal(live.get("thread-one"), "waiting"); + socket.emit("data", frame(withQuestions({ type: "active", activeFlags: [] }, true))); + assert.equal(live.get("thread-one"), "working"); + socket.emit("data", frame(withQuestions({ type: "idle" }, true))); + assert.equal(live.get("thread-one"), "idle"); +}); + +test("coalesces question patch reconciliation and cancels it on disconnect", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const { live, socket, snapshot } = harness(t); + socket.emit("data", frame(snapshot({ type: "active", activeFlags: [] }))); + const before = socket.writes.length; + const questionPath = ["turnHistory", "history", "entitiesByKey", "one", "items", 0, "questions"]; + const patch = (baseRevision, revision, path) => ({ + type: "broadcast", method: "thread-stream-state-changed", version: 11, + sourceClientId: "owner", params: { hostId: "local", conversationId: "thread-one", + change: { type: "patches", baseRevision, revision, patches: [{ op: "add", path, value: [] }] } }, + }); + socket.emit("data", frame(patch(1, 2, questionPath))); + socket.emit("data", frame(patch(2, 3, questionPath))); + assert.equal(socket.writes.length, before); + t.mock.timers.tick(100); + assert.equal(socket.writes.length, before + 1); + assert.equal(socket.writes.at(-1).params.following, true); + socket.emit("data", frame(patch(3, 4, questionPath.slice(0, -1).concat("text")))); + t.mock.timers.tick(100); + assert.equal(socket.writes.length, before + 1, "text streaming does not request snapshots"); + socket.emit("data", frame(patch(4, 5, questionPath))); + socket.destroy(); + t.mock.timers.tick(100); + assert.equal(socket.writes.length, before + 1, "disconnect cancels pending reconciliation"); + assert.equal(live.get("thread-one"), undefined); +}); diff --git a/test/session-questions.test.cjs b/test/session-questions.test.cjs new file mode 100644 index 0000000..fff1962 --- /dev/null +++ b/test/session-questions.test.cjs @@ -0,0 +1,63 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { itemQuestionIds, pendingQuestionIds, questionPatchMayChange } = require("../session-questions"); + +const id = (call, index) => JSON.stringify(["request_user_input_async", call, index]); +const ask = (call, count) => ({ + type: "agentMessage", id: call, + questions: Array.from({ length: count }, () => ({ title: "Synthetic question" })), +}); +const reply = (ids, type = "steeringUserMessage") => ({ + type, + [type === "userMessage" ? "content" : "input"]: [{ type: "text", text: + '\n' + JSON.stringify(ids.map((questionItemId) => ({ + questionItemId, question: "Synthetic question", answer: "Synthetic answer", + }))) + '\n', + }], +}); + +test("tracks each async question independently and clears only exact matching replies", () => { + const state = { turns: [{ items: [ask("call-one", 2), ask("call-two", 1), reply([id("call-one", 0)])] }] }; + assert.deepEqual([...pendingQuestionIds(state)].sort(), [id("call-one", 1), id("call-two", 0)].sort()); + state.turns[0].items.push(reply([id("call-one", 1), id("call-two", 0)], "userMessage")); + assert.equal(pendingQuestionIds(state).size, 0); +}); + +test("reconstructs canonical history regardless of entity order and duplicate items", () => { + const question = ask("call-one", 1); + const state = { + turns: [{ items: [question] }], + turnHistory: { history: { entitiesByKey: { + later: { items: [reply([id("call-one", 0)])] }, + earlier: { items: [question] }, + } } }, + }; + assert.equal(pendingQuestionIds(state).size, 0); +}); + +test("ordinary user text and assistant-quoted reply markup cannot resolve questions", () => { + const markup = reply([id("call-one", 0)]).input; + assert.deepEqual(itemQuestionIds({ type: "agentMessage", content: markup }).answered, []); + const state = { turns: [{ items: [ask("call-one", 1), + { type: "userMessage", content: [{ type: "text", text: "I answered elsewhere" }] }, + { type: "userInputResponse", questions: [{ id: "blocking-question" }], completed: true }, + reply([id("different-call", 0)]), reply([id("call-one", 5)]), + ] }] }; + assert.deepEqual([...pendingQuestionIds(state)], [id("call-one", 0)]); + assert.deepEqual(itemQuestionIds({ type: "userMessage", content: [{ type: "text", text: + "not JSON", + }] }).answered, []); +}); + +test("question and reply patches request reconciliation, text streaming does not", () => { + const prefix = ["turnHistory", "history", "entitiesByKey", "turn-one", "items"]; + assert.equal(questionPatchMayChange({ op: "add", value: ask("call-one", 1) }, [...prefix, 3]), true); + assert.equal(questionPatchMayChange({ op: "add", value: reply([id("call-one", 0)]) }, [...prefix, 4]), true); + assert.equal(questionPatchMayChange({ op: "replace" }, [...prefix, 3, "questions"]), true); + assert.equal(questionPatchMayChange({ op: "remove" }, [...prefix, 3]), true); + assert.equal(questionPatchMayChange({ op: "replace" }, [...prefix, 3, "text"]), false); + assert.equal(questionPatchMayChange({ op: "replace" }, ["threadRuntimeStatus"]), false); + assert.equal(questionPatchMayChange({ op: "add", value: { type: "toolCall", id: "tool" } }, [...prefix, 3]), false); +}); From b6bde5ec7a265059f7956b547faa95237e67a77b Mon Sep 17 00:00:00 2001 From: priva7eer Date: Sat, 17 Oct 2026 01:13:58 +0200 Subject: [PATCH 4/8] Expire stale questions and preserve status icon spacing --- CHANGELOG.md | 4 ++++ README.md | 4 +++- assets/status-empty.svg | 1 + docs/SESSION-STATUS.md | 8 ++++++- extension.js | 6 +++++- session-questions.js | 38 +++++++++++++++++++++++++++------ test/extension.test.cjs | 6 +++--- test/session-questions.test.cjs | 33 ++++++++++++++++++++++++++++ 8 files changed, 88 insertions(+), 12 deletions(-) create mode 100644 assets/status-empty.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 50265ce..64f957d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Codex Extras are documented in this file. ## Unreleased +- Expire superseded async questions using actual conversation order, and discard + questions from interrupted/failed turns instead of showing stale question marks. +- Preserve label alignment with a transparent icon slot after a result is reviewed. + - Show a yellow question mark for unanswered async questions, with blocking waits taking priority. Match replies by question ID and keep unknown status distinct. - Treat green check marks as new results to review. Hide a specific result's icon diff --git a/README.md b/README.md index 5545515..65c4d57 100644 --- a/README.md +++ b/README.md @@ -55,13 +55,15 @@ a green check mark for a new result to review, a stop icon for interrupted turns or an error icon for failed turns. A yellow question mark means an unanswered non-blocking question; the agent may still be working. It disappears after all questions have matching replies. A gray slashed circle means status is unknown. +Starting a new ordinary user turn supersedes questions from earlier turns; +interrupted or failed turns do not keep abandoned questions active. A bell means Codex is waiting for input or approval, and a hollow circle means idle. A clock means activity is unconfirmed, not that the task has completed. Changing the setting takes effect immediately. Opening a green chat through Codex Extras marks that specific result as reviewed after the open command succeeds and its editor tab exists. Its status icon then -disappears. This is saved in VS Code extension storage across reloads. A later +disappears, with an empty icon slot preserving text alignment. This is saved in VS Code extension storage across reloads. A later completed turn gets a new green mark; opening an older result cannot consume it. Failed opens do not mark results as reviewed. Blocking waits take priority over non-blocking questions, and unanswered questions take priority over other states. diff --git a/assets/status-empty.svg b/assets/status-empty.svg new file mode 100644 index 0000000..51217fc --- /dev/null +++ b/assets/status-empty.svg @@ -0,0 +1 @@ + diff --git a/docs/SESSION-STATUS.md b/docs/SESSION-STATUS.md index 8617c0e..664e7fa 100644 --- a/docs/SESSION-STATUS.md +++ b/docs/SESSION-STATUS.md @@ -49,6 +49,11 @@ tuple `["request_user_input_async", item.id, questionIndex]`. User and steering messages carrying `` resolve those exact IDs. Inspect both `turns` and canonical `turnHistory.history.entitiesByKey`; deduplicate items and match replies independently of entity order. Retain only unresolved IDs. +Use canonical island entry order (or recorded turn start times), not entity map +insertion order, to expire old questions when a newer ordinary user turn starts. +A structured async reply resolves its IDs without superseding remaining questions. +Questions in interrupted/failed turns are abandoned. If history order cannot be +established, do not guess which prompt supersedes which question. Question/reply patches request a debounced fresh snapshot; ordinary assistant text streaming does not trigger snapshot requests. Blocking runtime waits have priority, then unanswered async questions, then the underlying task state. @@ -58,7 +63,8 @@ for older logs without a turn ID). Opening a green result through Extras records that identity in `globalState` under `codexExtras.reviewedResults`, only after `vscode.openWith` resolves and its local conversation tab exists. Capture the displayed identity before opening so a newer completion cannot be marked reviewed -by a stale click. Reviewed completions have no status icon; a later completion has +by a stale click. Reviewed completions use a transparent 16 px placeholder to +preserve label alignment without a visible status icon; a later completion has a different identity and is green again. No source transcripts are modified. The adapter never becomes an owner and sends no task-control requests. Receiving diff --git a/extension.js b/extension.js index 73cfed1..8e1ffa0 100644 --- a/extension.js +++ b/extension.js @@ -27,6 +27,10 @@ const CODEX_NEW_PANEL_SCHEME = "openai-codex"; const CODEX_ROUTE_AUTHORITY = "route"; const CODEX_NEW_PANEL_PATH = "/extension/panel/new"; const REVIEWED_RESULTS_KEY = "codexExtras.reviewedResults"; +const EMPTY_STATUS_ICON = { + light: path.join(__dirname, "assets", "status-empty.svg"), + dark: path.join(__dirname, "assets", "status-empty.svg"), +}; let panelSequence = 0; let openQueue = Promise.resolve(); @@ -70,7 +74,7 @@ class CodexChatItem extends vscode.TreeItem { ] .filter(Boolean) .join("\n"); - this.iconPath = status && !status.icon ? undefined : new vscode.ThemeIcon( + this.iconPath = status && !status.icon ? EMPTY_STATUS_ICON : new vscode.ThemeIcon( status?.icon ?? "comment-discussion", status ? new vscode.ThemeColor(status.color) : undefined, ); diff --git a/session-questions.js b/session-questions.js index a0ef6c9..6613ec8 100644 --- a/session-questions.js +++ b/session-questions.js @@ -34,18 +34,43 @@ function itemQuestionIds(item) { return { asked, answered }; } +function orderedTurns(state) { + const history = state?.turnHistory?.history; + const entities = history?.entitiesByKey; + if (entities && Object.keys(entities).length) { + const entries = (history.islands ?? []).flatMap((island) => island.entries ?? []); + if (entries.length) { + // Hydrated entity insertion order is not conversation order. + return { turns: entries.map((entry) => entities[entry.value]).filter(Boolean), ordered: true }; + } + const turns = Object.values(entities); + const ordered = turns.every((turn) => Number.isFinite(turn?.turnStartedAtMs)); + if (ordered) turns.sort((left, right) => left.turnStartedAtMs - right.turnStartedAtMs); + return { turns, ordered }; + } + return { turns: Array.isArray(state?.turns) ? state.turns : [], ordered: true }; +} + +function isNewUserPrompt(item) { + if (item?.type !== "userMessage" || !Array.isArray(item.content)) return false; + const text = item.content.filter((part) => part?.type === "text") + .map((part) => part.text ?? "").join("\n").trim(); + // Even an unsupported/malformed structured reply is not a new task. + return text.length > 0 && !text.startsWith(REPLY_START); +} + function pendingQuestionIds(state) { const asked = new Set(); const answered = new Set(); - const turns = [ - ...(Array.isArray(state?.turns) ? state.turns : []), - ...Object.values(state?.turnHistory?.history?.entitiesByKey ?? {}), - ]; + const { turns, ordered } = orderedTurns(state); for (const turn of turns) { if (!Array.isArray(turn?.items)) continue; + if (ordered && turn.items.some(isNewUserPrompt)) asked.clear(); for (const item of turn.items) { const ids = itemQuestionIds(item); - for (const id of ids.asked) asked.add(id); + if (turn.status !== "interrupted" && turn.status !== "failed") { + for (const id of ids.asked) asked.add(id); + } for (const id of ids.answered) answered.add(id); } } @@ -59,6 +84,7 @@ function questionPatchMayChange(patch, segments) { if (!segments.length) return true; if (segments[0] !== "turnHistory" && segments[0] !== "turns") return false; if (segments.includes("questions")) return true; + if ((segments.at(-1) === "status" && !segments.includes("items")) || segments.includes("islands")) return true; if (segments[0] === "turns" && segments.length <= 2) return true; if (segments[0] === "turnHistory" && segments.length <= 4 && !["generation", "isComplete", "islands"].includes(segments.at(-1))) return true; @@ -68,7 +94,7 @@ function questionPatchMayChange(patch, segments) { if (segments.length === itemIndex + 2) { if (patch.op === "remove") return true; const ids = itemQuestionIds(patch.value); - return ids.asked.length > 0 || ids.answered.length > 0; + return ids.asked.length > 0 || ids.answered.length > 0 || isNewUserPrompt(patch.value); } return ["input", "content", "type", "id"].includes(segments[itemIndex + 2]); } diff --git a/test/extension.test.cjs b/test/extension.test.cjs index cecd100..4a64236 100644 --- a/test/extension.test.cjs +++ b/test/extension.test.cjs @@ -549,16 +549,16 @@ test("opening a green result hides its icon across reloads; a new result turns g const harness = createHarness(t, [], options); assert.equal((await item(harness)).iconPath.id, "pass"); await harness.runCommand("codexExtras.openChat", id); - assert.equal((await item(harness)).iconPath, undefined); + assert.equal((await item(harness)).iconPath.light, path.resolve(__dirname, "..", "assets", "status-empty.svg")); assert.match((await item(harness)).tooltip, /Result reviewed/); const reloaded = createHarness(t, [], { ...options, storedState: harness.storedState }); - assert.equal((await item(reloaded)).iconPath, undefined); + assert.equal((await item(reloaded)).iconPath.light, path.resolve(__dirname, "..", "assets", "status-empty.svg")); await appendFile(filePath, lifecycle("task_started", "turn-two")); assert.equal((await item(reloaded)).iconPath.id, "clock"); await appendFile(filePath, lifecycle("task_complete", "turn-two")); assert.equal((await item(reloaded)).iconPath.id, "pass"); await reloaded.runCommand("codexExtras.openChat", { id }); - assert.equal((await item(reloaded)).iconPath, undefined); + assert.equal((await item(reloaded)).iconPath.dark, path.resolve(__dirname, "..", "assets", "status-empty.svg")); }); test("a failed open or missing editor tab cannot consume a result", async (t) => { diff --git a/test/session-questions.test.cjs b/test/session-questions.test.cjs index fff1962..6fc0397 100644 --- a/test/session-questions.test.cjs +++ b/test/session-questions.test.cjs @@ -60,4 +60,37 @@ test("question and reply patches request reconciliation, text streaming does not assert.equal(questionPatchMayChange({ op: "replace" }, [...prefix, 3, "text"]), false); assert.equal(questionPatchMayChange({ op: "replace" }, ["threadRuntimeStatus"]), false); assert.equal(questionPatchMayChange({ op: "add", value: { type: "toolCall", id: "tool" } }, [...prefix, 3]), false); + assert.equal(questionPatchMayChange({ op: "add", value: { + type: "userMessage", content: [{ type: "text", text: "New task" }], + } }, [...prefix, 4]), true); +}); + +test("a newer ordinary user turn supersedes old async questions in canonical history order", () => { + const prompt = { type: "userMessage", content: [{ type: "text", text: "Continue with the next task" }] }; + const state = { turnHistory: { history: { + // Entity insertion order differs from conversation order, as in a hydrated UI. + entitiesByKey: { + new: { items: [prompt] }, + old: { items: [ask("old-call", 1)] }, + }, + islands: [{ entries: [{ value: "old" }, { value: "new" }] }], + } } }; + assert.equal(pendingQuestionIds(state).size, 0); + state.turnHistory.history.entitiesByKey.new.items.push(ask("new-call", 1)); + assert.deepEqual([...pendingQuestionIds(state)], [id("new-call", 0)]); +}); + +test("an async reply in a newer turn does not supersede other pending questions", () => { + const state = { turns: [ + { items: [ask("call-one", 2)] }, + { items: [reply([id("call-one", 0)], "userMessage")] }, + ] }; + assert.deepEqual([...pendingQuestionIds(state)], [id("call-one", 1)]); +}); + +test("interrupted and failed turns do not keep abandoned questions active", () => { + for (const status of ["interrupted", "failed"]) { + assert.equal(pendingQuestionIds({ turns: [{ status, items: [ask("old-call", 1)] }] }).size, 0); + } + assert.equal(pendingQuestionIds({ turns: [{ status: "completed", items: [ask("current-call", 1)] }] }).size, 1); }); From c8cefa0eb91f4170c016624fae7b9abdf42f4626 Mon Sep 17 00:00:00 2001 From: priva7eer Date: Sat, 17 Oct 2026 01:23:01 +0200 Subject: [PATCH 5/8] Resolve answered questions in migrated conversation histories --- CHANGELOG.md | 3 +++ README.md | 3 ++- docs/SESSION-STATUS.md | 7 +++++- session-questions.js | 38 ++++++++++++++++++++++++++++----- test/session-questions.test.cjs | 24 +++++++++++++++++++-- 5 files changed, 66 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64f957d..a39cac2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to Codex Extras are documented in this file. ## Unreleased +- Fix false async-question indicators in migrated histories whose call IDs were + replaced with `item-N`, and process follow-up messages within merged turns. + - Expire superseded async questions using actual conversation order, and discard questions from interrupted/failed turns instead of showing stale question marks. - Preserve label alignment with a transparent icon slot after a result is reviewed. diff --git a/README.md b/README.md index 65c4d57..483a14f 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,8 @@ a green check mark for a new result to review, a stop icon for interrupted turns or an error icon for failed turns. A yellow question mark means an unanswered non-blocking question; the agent may still be working. It disappears after all questions have matching replies. A gray slashed circle means status is unknown. -Starting a new ordinary user turn supersedes questions from earlier turns; +Sending a new ordinary user message supersedes earlier questions, including +older histories migrated into a single turn; interrupted or failed turns do not keep abandoned questions active. A bell means Codex is waiting for input or approval, and a hollow circle means idle. A clock means activity is unconfirmed, not that the task has completed. diff --git a/docs/SESSION-STATUS.md b/docs/SESSION-STATUS.md index 664e7fa..aba035b 100644 --- a/docs/SESSION-STATUS.md +++ b/docs/SESSION-STATUS.md @@ -50,7 +50,12 @@ messages carrying `` resolve those exact IDs. Inspect both `turns` and canonical `turnHistory.history.entitiesByKey`; deduplicate items and match replies independently of entity order. Retain only unresolved IDs. Use canonical island entry order (or recorded turn start times), not entity map -insertion order, to expire old questions when a newer ordinary user turn starts. +insertion order, to expire old questions when a newer ordinary user message appears. +Process messages inside each turn in order as well: older migrated histories may +combine several user follow-ups into a single turn. If rehydration replaced the +original call ID with `item-N`, an answer may resolve it only through a unique, +exact question-title/index match from a later reply in known conversation order. +Stable call IDs never use this fallback, and ambiguous matches remain unresolved. A structured async reply resolves its IDs without superseding remaining questions. Questions in interrupted/failed turns are abandoned. If history order cannot be established, do not guess which prompt supersedes which question. diff --git a/session-questions.js b/session-questions.js index 6613ec8..f422ad4 100644 --- a/session-questions.js +++ b/session-questions.js @@ -6,6 +6,7 @@ const REPLY_END = ""; function itemQuestionIds(item) { const asked = []; const answered = []; + const replies = []; if (item?.type === "agentMessage" && typeof item.id === "string" && Array.isArray(item.questions)) { for (let index = 0; index < item.questions.length; index++) { asked.push(JSON.stringify(["request_user_input_async", item.id, index])); @@ -13,25 +14,27 @@ function itemQuestionIds(item) { } if (item?.type === "userMessage" || item?.type === "steeringUserMessage") { const content = item.type === "userMessage" ? item.content : item.input; - if (!Array.isArray(content)) return { asked, answered }; + if (!Array.isArray(content)) return { asked, answered, replies }; const text = content.filter((part) => part?.type === "text") .map((part) => part.text ?? "").join("\n").trim(); - if (!text.startsWith(REPLY_START) || !text.endsWith(REPLY_END)) return { asked, answered }; + if (!text.startsWith(REPLY_START) || !text.endsWith(REPLY_END)) return { asked, answered, replies }; try { const parsed = JSON.parse(text.slice(REPLY_START.length, -REPLY_END.length)); for (const reply of Array.isArray(parsed) ? parsed : [parsed]) { if (typeof reply?.questionItemId !== "string") continue; - const id = JSON.parse(reply.questionItemId); + let id; + try { id = JSON.parse(reply.questionItemId); } catch { continue; } if (Array.isArray(id) && id.length === 3 && id[0] === "request_user_input_async" && typeof id[1] === "string" && Number.isInteger(id[2]) && id[2] >= 0) { answered.push(JSON.stringify(id)); + replies.push({ id: JSON.stringify(id), index: id[2], title: reply.question }); } } } catch { // Unsupported or malformed reply markup cannot resolve a question. } } - return { asked, answered }; + return { asked, answered, replies }; } function orderedTurns(state) { @@ -62,19 +65,44 @@ function isNewUserPrompt(item) { function pendingQuestionIds(state) { const asked = new Set(); const answered = new Set(); + const knownIds = new Set(); + const legacy = new Map(); + const replies = []; + let position = 0; const { turns, ordered } = orderedTurns(state); for (const turn of turns) { if (!Array.isArray(turn?.items)) continue; - if (ordered && turn.items.some(isNewUserPrompt)) asked.clear(); for (const item of turn.items) { + position++; + if (ordered && isNewUserPrompt(item)) { + asked.clear(); + legacy.clear(); + } const ids = itemQuestionIds(item); + for (const id of ids.asked) knownIds.add(id); if (turn.status !== "interrupted" && turn.status !== "failed") { for (const id of ids.asked) asked.add(id); + if (/^item-\d+$/.test(item.id ?? "")) { + for (let index = 0; index < ids.asked.length; index++) { + legacy.set(ids.asked[index], { title: item.questions[index]?.title, index, position }); + } + } } for (const id of ids.answered) answered.add(id); + for (const reply of ids.replies) replies.push({ ...reply, position }); } } for (const id of answered) asked.delete(id); + if (ordered) { + for (const reply of replies) { + if (knownIds.has(reply.id) || typeof reply.title !== "string" || !reply.title) continue; + const candidates = [...legacy].filter(([id, question]) => asked.has(id) && + question.position < reply.position && question.index === reply.index && question.title === reply.title); + // Legacy rehydration can replace a call ID with item-N. Use only a unique, + // exact question-title/index match; never fuzzy-match or inspect answers. + if (candidates.length === 1) asked.delete(candidates[0][0]); + } + } return asked; } diff --git a/test/session-questions.test.cjs b/test/session-questions.test.cjs index 6fc0397..8c04768 100644 --- a/test/session-questions.test.cjs +++ b/test/session-questions.test.cjs @@ -37,11 +37,12 @@ test("reconstructs canonical history regardless of entity order and duplicate it assert.equal(pendingQuestionIds(state).size, 0); }); -test("ordinary user text and assistant-quoted reply markup cannot resolve questions", () => { +test("assistant-quoted markup and replies for other IDs cannot resolve questions", () => { const markup = reply([id("call-one", 0)]).input; assert.deepEqual(itemQuestionIds({ type: "agentMessage", content: markup }).answered, []); - const state = { turns: [{ items: [ask("call-one", 1), + const state = { turns: [{ items: [ { type: "userMessage", content: [{ type: "text", text: "I answered elsewhere" }] }, + ask("call-one", 1), { type: "userInputResponse", questions: [{ id: "blocking-question" }], completed: true }, reply([id("different-call", 0)]), reply([id("call-one", 5)]), ] }] }; @@ -51,6 +52,25 @@ test("ordinary user text and assistant-quoted reply markup cannot resolve questi }] }).answered, []); }); +test("legacy rehydrated question IDs match a unique exact question title and index", () => { + assert.equal(pendingQuestionIds({ turns: [{ items: [ + ask("item-3", 1), reply([id("original-call", 0)], "userMessage"), + ] }] }).size, 0); + assert.equal(pendingQuestionIds({ turns: [{ items: [ + ask("stable-call", 1), reply([id("different-call", 0)], "userMessage"), + ] }] }).size, 1, "stable IDs must never use title fallback"); + assert.equal(pendingQuestionIds({ turns: [{ items: [ + ask("item-3", 1), ask("item-4", 1), reply([id("original-call", 0)], "userMessage"), + ] }] }).size, 2, "ambiguous titles must not guess which question was answered"); +}); + +test("a new user prompt supersedes earlier questions even inside a migrated single turn", () => { + const items = [ask("item-3", 1), { type: "userMessage", content: [{ type: "text", text: "New instructions" }] }]; + assert.equal(pendingQuestionIds({ turns: [{ items }] }).size, 0); + items.push(ask("new-call", 1)); + assert.deepEqual([...pendingQuestionIds({ turns: [{ items }] })], [id("new-call", 0)]); +}); + test("question and reply patches request reconciliation, text streaming does not", () => { const prefix = ["turnHistory", "history", "entitiesByKey", "turn-one", "items"]; assert.equal(questionPatchMayChange({ op: "add", value: ask("call-one", 1) }, [...prefix, 3]), true); From d6dce7773487b8807eddb2171bae3bffdf503554 Mon Sep 17 00:00:00 2001 From: priva7eer Date: Sat, 17 Oct 2026 01:27:27 +0200 Subject: [PATCH 6/8] Hide idle and unknown indicators while preserving alignment --- CHANGELOG.md | 2 ++ README.md | 7 ++++--- extension.js | 4 ++-- test/extension.test.cjs | 10 +++++++--- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a39cac2..b270165 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to Codex Extras are documented in this file. ## Unreleased +- Hide idle and unknown icons while preserving their space and tooltip status. + - Fix false async-question indicators in migrated histories whose call IDs were replaced with `item-N`, and process follow-up messages within merged turns. diff --git a/README.md b/README.md index 483a14f..10b25ea 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,12 @@ Enable **Codex Extras: Show Session Status Icons** for a spinner while working, a green check mark for a new result to review, a stop icon for interrupted turns, or an error icon for failed turns. A yellow question mark means an unanswered non-blocking question; the agent may still be working. It disappears after all -questions have matching replies. A gray slashed circle means status is unknown. +questions have matching replies. Idle and unknown states leave the icon slot empty, +preserving text alignment; their status remains available in the tooltip. Sending a new ordinary user message supersedes earlier questions, including older histories migrated into a single turn; interrupted or failed turns do not keep abandoned questions active. -A bell means Codex is waiting for input or approval, and a hollow circle means idle. +A bell means Codex is waiting for input or approval. A clock means activity is unconfirmed, not that the task has completed. Changing the setting takes effect immediately. @@ -71,7 +72,7 @@ non-blocking questions, and unanswered questions take priority over other states Status icons use theme-aware colors: blue for working, green for new results, yellow for questions/waiting, orange for interrupted, red for failed, and muted foreground -for idle or unknown/unconfirmed states. Shapes and tooltip labels also identify +for unconfirmed activity. Shapes and tooltip labels also identify each state, so color is not the only cue. This optional mode reads at most the last 256 KiB of each local session log and diff --git a/extension.js b/extension.js index 8e1ffa0..b396834 100644 --- a/extension.js +++ b/extension.js @@ -39,7 +39,7 @@ const SESSION_STATUSES = { working: { icon: "loading~spin", color: "charts.blue", label: "Working (live)" }, waiting: { icon: "bell", color: "charts.yellow", label: "Waiting for input or approval (live)" }, question: { icon: "question", color: "charts.yellow", label: "Unanswered non-blocking question (agent may still be working)" }, - idle: { icon: "circle-outline", color: "descriptionForeground", label: "Idle (live)" }, + idle: { label: "Idle (live)" }, unconfirmed: { icon: "clock", color: "descriptionForeground", label: "Activity unconfirmed" }, completed: { icon: "pass", color: "testing.iconPassed", label: "New result to review" }, reviewed: { label: "Result reviewed" }, @@ -55,7 +55,7 @@ class CodexChatItem extends vscode.TreeItem { this.resultKey = session.status === "completed" ? session.resultKey : null; const status = session.showStatusIcons ? SESSION_STATUSES[session.status] ?? { - icon: "circle-slash", color: "descriptionForeground", label: "Status unknown", + label: "Status unknown", } : undefined; this.description = [session.workspaceName, formatUpdatedAt(session.updatedAt)] diff --git a/test/extension.test.cjs b/test/extension.test.cjs index 4a64236..17e4345 100644 --- a/test/extension.test.cjs +++ b/test/extension.test.cjs @@ -493,13 +493,16 @@ test("status icons are opt-in, update on live events, and toggle without reloadi for (const [state, icon, color] of [ ["waiting", "bell", "charts.yellow"], ["question", "question", "charts.yellow"], - ["idle", "circle-outline", "descriptionForeground"], ]) { live.states.set(id, state); const chat = await item(); assert.equal(chat.iconPath.id, icon); assert.equal(chat.iconPath.color.id, color); } + live.states.set(id, "idle"); + const idle = await item(); + assert.equal(idle.iconPath.light, path.resolve(__dirname, "..", "assets", "status-empty.svg")); + assert.match(idle.tooltip, /Idle \(live\)/); live.states.clear(); live.onChange(); assert.equal((await item()).iconPath.id, "clock", "losing live status removes spinner"); @@ -513,8 +516,9 @@ test("status icons are opt-in, update on live events, and toggle without reloadi assert.equal((await item()).iconPath.id, "error"); assert.equal((await item()).iconPath.color.id, "errorForeground"); await writeFile(filePath, "{}\n"); - assert.equal((await item()).iconPath.id, "circle-slash"); - assert.equal((await item()).iconPath.color.id, "descriptionForeground"); + const unknown = await item(); + assert.deepEqual(unknown.iconPath, idle.iconPath, "inactive states preserve the same empty icon slot"); + assert.match(unknown.tooltip, /Status unknown/); harness.setStatusIcons(false); assert.ok(watcher.disposed); assert.ok(live.disposed); From 81954d70d69cdec0b939870a96064d6a002f2d1e Mon Sep 17 00:00:00 2001 From: priva7eer Date: Sat, 17 Oct 2026 01:30:25 +0200 Subject: [PATCH 7/8] Use a small gray dot for neutral session states --- CHANGELOG.md | 3 +++ README.md | 4 ++-- assets/status-empty.svg | 1 - assets/status-neutral.svg | 1 + docs/SESSION-STATUS.md | 2 +- extension.js | 8 ++++---- test/extension.test.cjs | 10 +++++----- 7 files changed, 16 insertions(+), 13 deletions(-) delete mode 100644 assets/status-empty.svg create mode 100644 assets/status-neutral.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index b270165..48c102a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to Codex Extras are documented in this file. ## Unreleased +- Replace the blank status placeholder with a small gray dot for reviewed, idle, + and unknown states, keeping labels aligned. + - Hide idle and unknown icons while preserving their space and tooltip status. - Fix false async-question indicators in migrated histories whose call IDs were diff --git a/README.md b/README.md index 10b25ea..98dc614 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Enable **Codex Extras: Show Session Status Icons** for a spinner while working, a green check mark for a new result to review, a stop icon for interrupted turns, or an error icon for failed turns. A yellow question mark means an unanswered non-blocking question; the agent may still be working. It disappears after all -questions have matching replies. Idle and unknown states leave the icon slot empty, +questions have matching replies. Idle and unknown states show a small gray dot, preserving text alignment; their status remains available in the tooltip. Sending a new ordinary user message supersedes earlier questions, including older histories migrated into a single turn; @@ -65,7 +65,7 @@ Changing the setting takes effect immediately. Opening a green chat through Codex Extras marks that specific result as reviewed after the open command succeeds and its editor tab exists. Its status icon then -disappears, with an empty icon slot preserving text alignment. This is saved in VS Code extension storage across reloads. A later +becomes a small gray dot, preserving text alignment. This is saved in VS Code extension storage across reloads. A later completed turn gets a new green mark; opening an older result cannot consume it. Failed opens do not mark results as reviewed. Blocking waits take priority over non-blocking questions, and unanswered questions take priority over other states. diff --git a/assets/status-empty.svg b/assets/status-empty.svg deleted file mode 100644 index 51217fc..0000000 --- a/assets/status-empty.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/status-neutral.svg b/assets/status-neutral.svg new file mode 100644 index 0000000..ea55330 --- /dev/null +++ b/assets/status-neutral.svg @@ -0,0 +1 @@ + diff --git a/docs/SESSION-STATUS.md b/docs/SESSION-STATUS.md index aba035b..9dd21bb 100644 --- a/docs/SESSION-STATUS.md +++ b/docs/SESSION-STATUS.md @@ -68,7 +68,7 @@ for older logs without a turn ID). Opening a green result through Extras records that identity in `globalState` under `codexExtras.reviewedResults`, only after `vscode.openWith` resolves and its local conversation tab exists. Capture the displayed identity before opening so a newer completion cannot be marked reviewed -by a stale click. Reviewed completions use a transparent 16 px placeholder to +by a stale click. Reviewed completions use a small gray dot in a 16 px icon slot to preserve label alignment without a visible status icon; a later completion has a different identity and is green again. No source transcripts are modified. diff --git a/extension.js b/extension.js index b396834..604955e 100644 --- a/extension.js +++ b/extension.js @@ -27,9 +27,9 @@ const CODEX_NEW_PANEL_SCHEME = "openai-codex"; const CODEX_ROUTE_AUTHORITY = "route"; const CODEX_NEW_PANEL_PATH = "/extension/panel/new"; const REVIEWED_RESULTS_KEY = "codexExtras.reviewedResults"; -const EMPTY_STATUS_ICON = { - light: path.join(__dirname, "assets", "status-empty.svg"), - dark: path.join(__dirname, "assets", "status-empty.svg"), +const NEUTRAL_STATUS_ICON = { + light: path.join(__dirname, "assets", "status-neutral.svg"), + dark: path.join(__dirname, "assets", "status-neutral.svg"), }; let panelSequence = 0; @@ -74,7 +74,7 @@ class CodexChatItem extends vscode.TreeItem { ] .filter(Boolean) .join("\n"); - this.iconPath = status && !status.icon ? EMPTY_STATUS_ICON : new vscode.ThemeIcon( + this.iconPath = status && !status.icon ? NEUTRAL_STATUS_ICON : new vscode.ThemeIcon( status?.icon ?? "comment-discussion", status ? new vscode.ThemeColor(status.color) : undefined, ); diff --git a/test/extension.test.cjs b/test/extension.test.cjs index 17e4345..4bc91ef 100644 --- a/test/extension.test.cjs +++ b/test/extension.test.cjs @@ -501,7 +501,7 @@ test("status icons are opt-in, update on live events, and toggle without reloadi } live.states.set(id, "idle"); const idle = await item(); - assert.equal(idle.iconPath.light, path.resolve(__dirname, "..", "assets", "status-empty.svg")); + assert.equal(idle.iconPath.light, path.resolve(__dirname, "..", "assets", "status-neutral.svg")); assert.match(idle.tooltip, /Idle \(live\)/); live.states.clear(); live.onChange(); @@ -517,7 +517,7 @@ test("status icons are opt-in, update on live events, and toggle without reloadi assert.equal((await item()).iconPath.color.id, "errorForeground"); await writeFile(filePath, "{}\n"); const unknown = await item(); - assert.deepEqual(unknown.iconPath, idle.iconPath, "inactive states preserve the same empty icon slot"); + assert.deepEqual(unknown.iconPath, idle.iconPath, "inactive states preserve the same neutral dot"); assert.match(unknown.tooltip, /Status unknown/); harness.setStatusIcons(false); assert.ok(watcher.disposed); @@ -553,16 +553,16 @@ test("opening a green result hides its icon across reloads; a new result turns g const harness = createHarness(t, [], options); assert.equal((await item(harness)).iconPath.id, "pass"); await harness.runCommand("codexExtras.openChat", id); - assert.equal((await item(harness)).iconPath.light, path.resolve(__dirname, "..", "assets", "status-empty.svg")); + assert.equal((await item(harness)).iconPath.light, path.resolve(__dirname, "..", "assets", "status-neutral.svg")); assert.match((await item(harness)).tooltip, /Result reviewed/); const reloaded = createHarness(t, [], { ...options, storedState: harness.storedState }); - assert.equal((await item(reloaded)).iconPath.light, path.resolve(__dirname, "..", "assets", "status-empty.svg")); + assert.equal((await item(reloaded)).iconPath.light, path.resolve(__dirname, "..", "assets", "status-neutral.svg")); await appendFile(filePath, lifecycle("task_started", "turn-two")); assert.equal((await item(reloaded)).iconPath.id, "clock"); await appendFile(filePath, lifecycle("task_complete", "turn-two")); assert.equal((await item(reloaded)).iconPath.id, "pass"); await reloaded.runCommand("codexExtras.openChat", { id }); - assert.equal((await item(reloaded)).iconPath.dark, path.resolve(__dirname, "..", "assets", "status-empty.svg")); + assert.equal((await item(reloaded)).iconPath.dark, path.resolve(__dirname, "..", "assets", "status-neutral.svg")); }); test("a failed open or missing editor tab cannot consume a result", async (t) => { From 9be86a054ec0e6c77bf985c596ef141e3d4d2950 Mon Sep 17 00:00:00 2001 From: priva7eer Date: Sat, 17 Oct 2026 01:32:16 +0200 Subject: [PATCH 8/8] Prepare independent status feature contribution --- CHANGELOG.md | 34 +++++++++------------------------- docs/SESSION-STATUS.md | 2 +- test/extension.test.cjs | 3 ++- 3 files changed, 12 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48c102a..2ddbf01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,31 +4,15 @@ All notable changes to Codex Extras are documented in this file. ## Unreleased -- Replace the blank status placeholder with a small gray dot for reviewed, idle, - and unknown states, keeping labels aligned. - -- Hide idle and unknown icons while preserving their space and tooltip status. - -- Fix false async-question indicators in migrated histories whose call IDs were - replaced with `item-N`, and process follow-up messages within merged turns. - -- Expire superseded async questions using actual conversation order, and discard - questions from interrupted/failed turns instead of showing stale question marks. -- Preserve label alignment with a transparent icon slot after a result is reviewed. - -- Show a yellow question mark for unanswered async questions, with blocking waits - taking priority. Match replies by question ID and keep unknown status distinct. -- Treat green check marks as new results to review. Hide a specific result's icon - after its editor opens successfully, persist review state across reloads, and - show green again for a newer completed turn. - -- Apply consistent, theme-aware colors to status icons for working, completed, - waiting, interrupted, failed, and inactive/unknown states. - -- Add opt-in session status icons, using experimental local Codex IPC for live - activity and bounded log reads for recorded results. Never infer running from - unfinished logs; invalidate live activity on disconnection. Keep the original - conversation icons by default. +- Add experimental, opt-in session status icons. Use local Codex IPC for live + work and input/approval waits, and bounded log reads for recorded results. + Keep the original chat icons and metadata-only reads by default. +- Show unanswered non-blocking questions, resolve replies independently, and + discard superseded or abandoned questions, including migrated histories. +- Mark new results in green until opened successfully through Codex Extras; + persist review state and protect newer results from stale clicks. +- Use theme-aware status colors and a small gray dot for reviewed, idle, and + unknown states, preserving label alignment. Document protocol limitations. ## 1.1.0 diff --git a/docs/SESSION-STATUS.md b/docs/SESSION-STATUS.md index 9dd21bb..d61ff20 100644 --- a/docs/SESSION-STATUS.md +++ b/docs/SESSION-STATUS.md @@ -69,7 +69,7 @@ that identity in `globalState` under `codexExtras.reviewedResults`, only after `vscode.openWith` resolves and its local conversation tab exists. Capture the displayed identity before opening so a newer completion cannot be marked reviewed by a stale click. Reviewed completions use a small gray dot in a 16 px icon slot to -preserve label alignment without a visible status icon; a later completion has +preserve label alignment; a later completion has a different identity and is green again. No source transcripts are modified. The adapter never becomes an owner and sends no task-control requests. Receiving diff --git a/test/extension.test.cjs b/test/extension.test.cjs index 4bc91ef..1ef3981 100644 --- a/test/extension.test.cjs +++ b/test/extension.test.cjs @@ -6,6 +6,7 @@ const Module = require("node:module"); const os = require("node:os"); const path = require("node:path"); const test = require("node:test"); +const { setTimeout: delay } = require("node:timers/promises"); const extensionPath = path.resolve(__dirname, "..", "extension.js"); const originalLoad = Module._load; @@ -465,7 +466,7 @@ test("status icons are opt-in, update on live events, and toggle without reloadi assert.equal((await item()).iconPath.id, "comment-discussion"); assert.equal((await item()).iconPath.color, undefined); assert.equal(reads, 0, "disabled mode must not read status logs"); - assert.equal(harness.fileWatchers.length, 2, "only archive watchers when disabled"); + assert.equal(harness.fileWatchers.length, 0, "no status watchers when disabled"); assert.equal(harness.liveMonitors.length, 0, "no IPC connection when disabled"); harness.setStatusIcons(true); assert.equal((await item()).iconPath.id, "pass");