From 0245c2c1215c3207803c9756496e462e359c6b66 Mon Sep 17 00:00:00 2001 From: Cui Chen Date: Sat, 31 Oct 2026 20:11:47 +0700 Subject: [PATCH 1/3] Let Codex look at the typeset PDF New Codex tasks get a view_pdf tool. Codex asks for the pages where source lines are typeset, or for printed page numbers, in its worktree or in the accepted text. The review engine compiles them like a review round and draws the pages as PNG files, which Codex opens with its own view_image tool. The tool returns file paths rather than images: in testing, Codex's code mode printed returned images as base64 text, about 25k tokens for one page, against about 1.5k for a page opened by path. The engine compiles only the chapters that the edits and the asked-for places need when the paper allows partial builds, so the review after the turn reuses the build, and it reports compiler errors to the agent when the worktree does not compile. Papilot answers the call itself, without asking the author, and the conversation shows each look as "Viewed the PDF". The instructions ask Codex to use the tool when the look of the pages matters and not for wording alone. The tool is a dynamic tool of the app-server's experimental API, so Papilot now initializes Codex with experimentalApi. A Codex that refuses the tool starts its threads without it. Resumed threads keep the tool; tasks started before this change do not get it. --- CHANGELOG.md | 2 + docs/guide.md | 2 +- src/server/codex.ts | 3 +- src/server/review.ts | 2 +- src/server/workspace.ts | 77 ++++++++- src/shared.ts | 2 + src/web/main.tsx | 9 +- src/web/zh-CN.ts | 1 + tests/workspace.test.ts | 58 +++++++ vendor/latex-review/src/core/view.ts | 195 ++++++++++++++++++++++ vendor/latex-review/src/server.ts | 7 + vendor/latex-review/tests/ts/view.test.ts | 55 ++++++ 12 files changed, 398 insertions(+), 15 deletions(-) create mode 100644 vendor/latex-review/src/core/view.ts create mode 100644 vendor/latex-review/tests/ts/view.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 20264dc..5c38c52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Notable changes to Papilot, newest first. The format follows [Keep a Changelog]( ### Added +- **Codex can see the PDF.** New Codex tasks get a `view_pdf` tool: Codex asks for the pages where source lines or printed page numbers appear, in its worktree or in the accepted text, and Papilot compiles them and draws the pages as images that Codex opens with its image viewer. Codex uses it when the look of the page matters, such as line breaks and spacing, and the conversation shows each look. Only the chapters needed are compiled, and the review after the turn reuses the build. + - **More agents, found automatically.** Cursor (through the Cursor SDK, with `CURSOR_API_KEY`), GLM (Claude Code on Zhipu's Anthropic-compatible endpoint, with `ZHIPU_API_KEY` or `ZAI_API_KEY`), DeepSeek (`dsh acp` from DeepSeek Harness), Qwen Code (`qwen --acp`) and OpenCode (`opencode acp`) join the agent menu. Papilot offers only the agents it finds on the host, looks again on **Reconnect**, and names the rest in Settings → Runtime & diagnostics. DeepSeek keeps its workspace sandbox, Qwen runs in Auto Edit mode, and OpenCode asks before commands and anything outside the worktree. `papilot doctor` checks each of them and lists agents that are not set up for information only. - **Kimi Code support.** Choose Kimi for paper-editing tasks using your local Kimi Code CLI login. The ACP adapter supports model and thinking-level selection, streamed replies, image inputs, tool approvals, cancellation and session recovery. Diagnostics include Kimi CLI availability; set `PAPILOT_KIMI_BIN` for a custom executable path. diff --git a/docs/guide.md b/docs/guide.md index 9726e99..fe78b85 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -49,7 +49,7 @@ Each task runs on one agent, chosen with **Agent** below the prompt; Papilot rem CLI agents use their own login and configuration on this host. The SDK-based agents (Claude, Cursor and GLM) take their key from Papilot's environment and receive it only for their own service. Papilot never stores a credential. Every agent edits only the task's worktree and leaves compiling and publishing the review to Papilot. -- **Codex** runs through the Codex app-server with its workspace-write sandbox. Commands outside the sandbox and Codex's questions come to the conversation for approval. +- **Codex** runs through the Codex app-server with its workspace-write sandbox. Commands outside the sandbox and Codex's questions come to the conversation for approval. Codex can also look at the typeset paper: Papilot gives each new Codex task a `view_pdf` tool that compiles the task worktree as it is and draws the pages where given source lines, or given printed page numbers, appear, or the same places in the accepted text, as image files that Codex opens with its own image viewer. Codex decides when to use it, for example when line breaks, spacing, figures or formulas matter; it compiles only the chapters it needs when the paper allows partial builds, and the review after the turn reuses that build. The conversation shows each look as **Viewed the PDF**. This uses the app-server's experimental API; a Codex without it runs tasks without the tool. - **Claude** runs through the Claude Agent SDK, which runs Claude Code with the login on this host. An installed Papilot downloads the SDK into `agents` in the data folder the first time it starts with an API key. Edits in the worktree are accepted, commands run in Claude Code's sandbox, and anything that needs approval, including Claude's questions, comes to the conversation. Claude tasks need an Anthropic API key: Anthropic does not allow a Claude subscription (Pro or Max) login to be used through the Agent SDK and refuses such requests with 403. Run Papilot with `ANTHROPIC_API_KEY` set (or with Claude Code configured for Amazon Bedrock, Google Vertex AI or Microsoft Foundry); usage is billed to that key or provider. With only a subscription login, Claude is listed as unavailable and says why. Claude can also run on another service's Anthropic-compatible endpoint. Set `ANTHROPIC_BASE_URL` to the endpoint and `ANTHROPIC_AUTH_TOKEN` (or `ANTHROPIC_API_KEY`) to that service's key, and map the model menu onto the service's models; usage is billed by that service. If the service rejects Anthropic's beta headers, `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1` sends only the ones Claude Code needs. diff --git a/src/server/codex.ts b/src/server/codex.ts index 407af60..fc427b4 100644 --- a/src/server/codex.ts +++ b/src/server/codex.ts @@ -34,7 +34,8 @@ export class Codex extends EventEmitter { }; child.once('error', failed); child.once('exit', (code, signal) => failed(new Error(`Codex exited (${signal || code})`))); - await this.request('initialize', { clientInfo: { name: 'papilot', title: 'Papilot', version: VERSION } }); + // The experimental API carries the tools Papilot gives its threads (see VIEW_PDF_TOOL). + await this.request('initialize', { clientInfo: { name: 'papilot', title: 'Papilot', version: VERSION }, capabilities: { experimentalApi: true } }); this.notify('initialized', {}); this.ready = true; } private receive(message: any) { diff --git a/src/server/review.ts b/src/server/review.ts index 77eff57..8cc9a91 100644 --- a/src/server/review.ts +++ b/src/server/review.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { ENGINE } from './paths.ts'; import type { Task } from '../shared.ts'; -export interface ReviewClient { url: string; token: string; get(route: string): Promise; post(route: string, body?: unknown): Promise } +export interface ReviewClient { url: string; token: string; get(route: string): Promise; post(route: string, body?: unknown, timeout?: number): Promise } let modulePromise: Promise<{ connect: (root: string) => Promise }> | null = null; export function reviewClient(root: string): Promise { modulePromise ??= import(pathToFileURL(path.join(ENGINE, 'src/client.ts')).href); diff --git a/src/server/workspace.ts b/src/server/workspace.ts index d65162f..1ecfaf8 100644 --- a/src/server/workspace.ts +++ b/src/server/workspace.ts @@ -57,6 +57,24 @@ const BUSY = new Set(['running', 'waiting_input', 'building', 'uncertain', 'dele // publish.ts), and the request keys of the turns that send them back to it. const FIXABLE = /^(Some edits are outside the task scope|The revised document could not be compiled)/; const BUILD_FIX = 'build-fix-'; +// The tool Papilot gives Codex threads for looking at the typeset paper. The +// review engine compiles the task worktree as it is and draws the pages. +export const VIEW_PDF = 'view_pdf'; +export const VIEW_PDF_TOOL = { + type: 'function', name: VIEW_PDF, + description: 'Compile the paper in your task worktree as it is now and draw typeset pages as PNG files. Ask for the pages where source lines are typeset (locations, such as "main/chapters/intro.tex:42") or for page numbers as printed on the pages; up to 4 pages per call. The result lists each page\'s file: open the ones you need with your image viewer (view_image) to see them. Set accepted to true to draw the author\'s accepted text, before your edits, instead. Only the chapters needed are compiled when the paper allows it, but this can still take a minute or more.', + inputSchema: { + type: 'object', additionalProperties: false, + properties: { + locations: { type: 'array', items: { type: 'string' }, maxItems: 4, description: 'Source lines as file:line, relative to the worktree.' }, + pages: { type: 'array', items: { type: 'string' }, maxItems: 4, description: 'Page numbers as printed on the pages, such as "12" or "iv".' }, + accepted: { type: 'boolean', description: 'Show the accepted text instead of your worktree.' }, + }, + }, +}; +const VIEW_PDF_UNSUPPORTED = /dynamicTools|experimental/i; +// What a view_pdf call asked for, as the chat shows it. +const viewedPlaces = (args: any) => [...(Array.isArray(args?.locations) ? args.locations : []), ...(Array.isArray(args?.pages) ? args.pages.map((page: unknown) => `p. ${page}`) : [])].filter(place => typeof place === 'string').join(', ') + (args?.accepted === true ? ' (accepted)' : ''); const AGENTS: AgentId[] = AGENT_IDS; // The small model an agent offers for short text such as a commit message, @@ -98,6 +116,8 @@ export class Workspace extends EventEmitter { private messages = new Map>(); private titles = new Map>(); private helperStops = new Set<() => void>(); + // Set when Codex refuses the view_pdf tool, so later threads start without it. + private noViewPdf = false; private projectChanges = new Set(); private projectRequests = new Map(); importsBusy = () => false; @@ -377,14 +397,14 @@ export class Workspace extends EventEmitter { return files.some(file => file === '' || file === '.') ? [] : files; } catch { return []; } } - private instructions(task: Task, scope: string[] = []) { + private instructions(task: Task, scope: string[] = [], viewPdf = false) { return `You are ${this.agentOf(task) === 'codex' ? 'the official Codex agent' : AGENT_NAMES[this.agentOf(task)]} running inside Papilot, a paper editing and review workspace. Your task is already created. Your editable working directory is ${task.worktree}. Original task: ${task.instructions} Only edit this isolated task worktree. Never edit the manuscript checkout, other task worktrees, or Papilot's own source/state. Never commit, push, stash, or accept/reject/undo review decisions. ${scope.length ? `The review covers only ${scope.join(', ')} in this worktree, and an edit to any other file stops the PDF build. Leave other files unchanged even when project instructions ask for a matching update; mention that update in your summary instead.\n` : ''}Papilot owns the LaTeX review lifecycle: it compiles and publishes automatically AFTER your turn finishes, and delivers review feedback in a new turn. Do not create another review task, invoke publish, or wait on feedback --wait. This application workflow replaces the standalone latex-review skill's publish-and-wait loop. End your turn after completing the requested edits, with a concise summary for the author of what you changed and why; do not describe Papilot's review workflow. Preserve meaning and mathematical correctness. Read relevant project instructions. Treat reviewer comments as instructions tied to their round, file and changes. Rejected edits must not be reintroduced unless explicitly requested. If there is a conflict with accepted text, explain it and propose a revision for a new review round; never overwrite the accepted manuscript. -${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to you here and any attempt is refused, which ends your turn: read, search and edit files in the task worktree with your file tools only.' : 'You may run tools in the task worktree to inspect and validate your changes.'} If a decision requires the user, ask through the available user-input tool or explain it in your final response. Use English for your progress updates, questions, and final summaries unless the user explicitly requests another language. Preserve the manuscript language unless asked to translate it.`; +${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to you here and any attempt is refused, which ends your turn: read, search and edit files in the task worktree with your file tools only.' : 'You may run tools in the task worktree to inspect and validate your changes.'} ${viewPdf ? `When the look of the pages matters, such as line breaks, spacing, figures, tables or displayed formulas, use the ${VIEW_PDF} tool and open the pages it draws with your image viewer to see them as the author does. It compiles the paper, so do not call it for wording alone. ` : ''}If a decision requires the user, ask through the available user-input tool or explain it in your final response. Use English for your progress updates, questions, and final summaries unless the user explicitly requests another language. Preserve the manuscript language unless asked to translate it.`; } private async startRun(run: Run) { let task = this.store.task(run.taskId); @@ -396,12 +416,23 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo this.store.saveRun({ ...run, status: 'running' }); this.store.updateTask(task.id, { status: 'running', turnId: null, error: null }); if (!task.threadId || !this.loaded.has(task.threadId)) { - const options = { cwd: task.worktree, approvalPolicy: 'on-request', sandbox: 'workspace-write', developerInstructions: this.instructions(task, await this.reviewScope(task)), ...(task.model ? { model: task.model } : {}) }; - const result = task.threadId - ? await agent.request('thread/resume', { ...options, threadId: task.threadId, excludeTurns: true }) - : await agent.request('thread/start', options); + // Codex threads get the view_pdf tool when they start, and keep it when resumed. + const scope = await this.reviewScope(task), codex = this.agentOf(task) === 'codex'; + let viewPdf = codex && (task.threadId ? Boolean(task.viewPdf) : !this.noViewPdf); + const options = (tool: boolean) => ({ cwd: task.worktree, approvalPolicy: 'on-request', sandbox: 'workspace-write', developerInstructions: this.instructions(task, scope, tool), ...(task.model ? { model: task.model } : {}) }); + let result; + if (task.threadId) result = await agent.request('thread/resume', { ...options(viewPdf), threadId: task.threadId, excludeTurns: true }); + else { + try { result = await agent.request('thread/start', viewPdf ? { ...options(true), dynamicTools: [VIEW_PDF_TOOL] } : options(false)); } + catch (error) { + // A Codex without the experimental tool API starts its threads without it. + if (!viewPdf || !(error instanceof RpcResponseError) || !VIEW_PDF_UNSUPPORTED.test(error.message)) throw error; + this.noViewPdf = true; viewPdf = false; + result = await agent.request('thread/start', options(false)); + } + } if (this.active.get(task.id)?.id !== run.id) return; - task = this.store.updateTask(task.id, { threadId: result.thread.id, model: result.model || task.model }); + task = this.store.updateTask(task.id, { threadId: result.thread.id, model: result.model || task.model, ...(task.threadId ? {} : { viewPdf }) }); this.loaded.add(result.thread.id); } this.store.updateTask(task.id, { pendingRunId: run.id }); @@ -478,6 +509,11 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo if (item.type === 'fileChange') { entry.type = 'files'; entry.text = (item.changes || []).map((change: any) => change.path).join('\n'); } if (item.type === 'mcpToolCall') { entry.type = 'tool'; entry.text = `${item.server} / ${item.tool}`; } if (item.type === 'toolCall') entry.type = 'tool'; + if (item.type === 'dynamicToolCall') { + if (item.tool !== VIEW_PDF) { entry.type = 'tool'; entry.text = item.tool || ''; } + else { entry.type = 'pdf'; entry.text = viewedPlaces(item.arguments); } + if (item.success === false) entry.status = 'failed'; + } if (entry.text || entry.detail) this.store.saveItem(entry); } else if (method === 'serverRequest/resolved') { const buffered = this.earlyRequests.get(task.id); @@ -511,7 +547,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo } private serverRequest(message: any, agent: AgentId = 'codex') { const task = this.store.tasks().find(t => t.threadId === message.params?.threadId), runtime = this.agents[agent]; - const allowed = ['item/commandExecution/requestApproval', 'item/fileChange/requestApproval', 'item/permissions/requestApproval', 'item/tool/requestUserInput']; + const allowed = ['item/commandExecution/requestApproval', 'item/fileChange/requestApproval', 'item/permissions/requestApproval', 'item/tool/requestUserInput', 'item/tool/call']; if (!task || this.agentOf(task) !== agent || !this.active.has(task.id) || !allowed.includes(message.method)) { runtime.reject(message.id, 'This request is not supported or its turn is no longer active.'); return; } if (message.params?.turnId && task.turnId && message.params.turnId !== task.turnId) { runtime.reject(message.id, 'This turn is no longer active.'); return; } if (!task.turnId && message.params?.turnId) { @@ -520,10 +556,35 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo else runtime.reject(message.id, 'Too many pending requests.'); return; } + // Papilot answers its own tools; the author is not asked. + if (message.method === 'item/tool/call') { void this.toolCall(task, message, runtime); return; } const id = randomUUID(); this.requests.set(id, { id, taskId: task.id, rpcId: message.id, agent, method: message.method, params: message.params, createdAt: Date.now() }); this.store.updateTask(task.id, { status: 'waiting_input' }); } + // The agent looks at typeset pages: the review engine compiles the task + // worktree and draws them as files, which the agent opens with its own + // image viewer. Returning the images themselves would put them in front of + // Codex's code mode as base64 text. + private async toolCall(task: Task, message: any, runtime: AgentRuntime) { + const { tool, arguments: args } = message.params || {}; + const reply = (success: boolean, text: string) => runtime.respond(message.id, { success, contentItems: [{ type: 'inputText', text }] }); + if (tool !== VIEW_PDF) { reply(false, `Papilot has no tool named ${tool}.`); return; } + const strings = (value: unknown) => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string').slice(0, 4) : []; + try { + const client = await this.connectReview(this.store.project(task.projectId).path); + const shown = await client.post<{ pages: { index: number; label: string; shows: string[]; file: string }[]; missing: string[]; targets: string[]; error?: string; details?: string[] }>( + `${taskRoute(task.name)}/view`, { locations: strings(args?.locations), pages: strings(args?.pages), ...(args?.accepted === true ? { accepted: true } : {}) }, 30 * 60000); + if (shown.error) { reply(false, [shown.error, ...(shown.details || [])].join('\n')); return; } + const compiled = shown.targets[0] === 'full' ? 'the whole paper' : `only ${shown.targets.join(', ')}, so page positions differ from the full PDF while printed page numbers match`; + reply(true, [ + `${args?.accepted === true ? 'The accepted text' : 'Your worktree'} was compiled (${compiled}).`, + ...shown.pages.map(page => `Page ${page.label} shows ${page.shows.join(', ')}: ${page.file}`), + ...shown.missing, + ...(shown.pages.length ? ['Open the page files you need with your image viewer (view_image).'] : []), + ].join('\n')); + } catch (error) { reply(false, 'Papilot could not show the pages: ' + errorText(error)); } + } answer(id: string, input: { decision?: string; answers?: Record }) { const request = this.requests.get(id); if (!request) throw new Error('This request has ended or the connection has restarted.'); if (this.store.task(request.taskId).status === 'deleting') throw new Error('This task is being deleted.'); diff --git a/src/shared.ts b/src/shared.ts index 5f6ddb5..5dc964c 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -25,6 +25,8 @@ export interface Task { id: string; projectId: string; name: string; title: string; instructions: string; titleSource?: 'prompt' | 'generated' | 'manual'; titleRevision?: number; titleGeneration?: 'pending' | 'failed' | null; worktree: string; threadId: string | null; turnId: string | null; agent?: AgentId; model: string | null; effort?: string | null; serviceTier?: string; + // The agent's thread was started with Papilot's view_pdf tool. + viewPdf?: boolean; status: TaskStatus; createdAt: number; updatedAt: number; feedbackCursor: number; archivedAt?: number | null; error: string | null; review: ReviewSummary | null; diff --git a/src/web/main.tsx b/src/web/main.tsx index 99608af..97e8609 100644 --- a/src/web/main.tsx +++ b/src/web/main.tsx @@ -252,18 +252,19 @@ function TimelineEntry({ item, agent, root }: { item: TimelineItem; agent: strin if (item.type === 'user') return ; if (item.type === 'assistant') return
; const files = item.type === 'files' ? editedFiles(item, root) : []; - const label = item.type === 'command' ? shellCommand(item.text) || t('Run command') : item.type === 'files' ? (files.length === 1 ? t('Edited {file}', { file: files[0] }) : t('Edited {n} files', { n: files.length })) : item.text || t('Run tool'); + const label = item.type === 'command' ? shellCommand(item.text) || t('Run command') : item.type === 'files' ? (files.length === 1 ? t('Edited {file}', { file: files[0] }) : t('Edited {n} files', { n: files.length })) : item.type === 'pdf' ? (item.text ? t('Viewed the PDF: {places}', { places: item.text }) : t('Viewed the PDF')) : item.text || t('Run tool'); const detail = item.type === 'files' ? files.join('\n') : item.detail; const state = toolState(item); - return
{label}{state === 'running' ? : state && {item.status === 'declined' ? t('Declined') : t('Failed')}}{detail &&
{detail}
}
; + return
{label}{state === 'running' ? : state && {item.status === 'declined' ? t('Declined') : t('Failed')}}{detail &&
{detail}
}
; } // Consecutive commands, edits and tool calls fold into one line that opens to the list. function ToolGroup({ items, agent, root }: { items: TimelineItem[]; agent: string; root: string }) { const commands = items.filter(item => item.type === 'command').length, edits = items.filter(item => item.type === 'files'); - const files = [...new Set(edits.flatMap(item => editedFiles(item, root)))], tools = items.length - commands - edits.length; + const pdfs = items.filter(item => item.type === 'pdf').length; + const files = [...new Set(edits.flatMap(item => editedFiles(item, root)))], tools = items.length - commands - edits.length - pdfs; const running = items.some(item => toolState(item) === 'running'), failed = items.filter(item => toolState(item) === 'failed').length; - const parts = [commands ? tn(commands, 'ran {n} command', 'ran {n} commands') : '', files.length ? (files.length === 1 ? t('edited {file}', { file: files[0] }) : t('edited {n} files', { n: files.length })) : '', tools ? tn(tools, 'used {n} tool', 'used {n} tools') : ''].filter(Boolean).join(t(', ')); + const parts = [commands ? tn(commands, 'ran {n} command', 'ran {n} commands') : '', files.length ? (files.length === 1 ? t('edited {file}', { file: files[0] }) : t('edited {n} files', { n: files.length })) : '', pdfs ? t('viewed the PDF') : '', tools ? tn(tools, 'used {n} tool', 'used {n} tools') : ''].filter(Boolean).join(t(', ')); return
{parts[0].toUpperCase() + parts.slice(1)}{running ? : failed ? {t('{n} failed', { n: failed })} : null}
{items.map(item => )}
; diff --git a/src/web/zh-CN.ts b/src/web/zh-CN.ts index acb592c..630e19f 100644 --- a/src/web/zh-CN.ts +++ b/src/web/zh-CN.ts @@ -139,6 +139,7 @@ export const zhCN: Record = { 'You, {time}': '你,{time}', 'Note on the PDF': 'PDF 上的批注', '{n} notes on the PDF': 'PDF 上的 {n} 条批注', 'Marked region': '标记的区域', '{n} accepted': '{n} 处已接受', '{n} rejected': '{n} 处已拒绝', '{n} comment': '{n} 条评论', '{n} comments': '{n} 条评论', '{n} undone': '{n} 处已撤销', 'build error': '编译错误', 'You sent review feedback, {time}': '你发送了审阅反馈,{time}', 'Review feedback': '审阅反馈', + 'Viewed the PDF: {places}': '查看了 PDF:{places}', 'Viewed the PDF': '查看了 PDF', 'viewed the PDF': '查看了 PDF', 'Build failed · sent to {agent}': '编译失败 · 已发给 {agent}', 'Papilot sent the build error to {agent}, {time}': 'Papilot 已将编译错误发给 {agent},{time}', 'With review feedback': '附带审阅反馈', 'Run command': '运行命令', 'Edited {file}': '编辑了 {file}', 'Edited {n} files': '编辑了 {n} 个文件', 'Run tool': '使用工具', diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts index 9f08d0c..7219638 100644 --- a/tests/workspace.test.ts +++ b/tests/workspace.test.ts @@ -1249,6 +1249,64 @@ test('a build the agent broke goes back to it once, and its instructions name wh } finally { f.close(); } }); +test('Codex threads get the view_pdf tool, which Papilot answers with typeset pages', async () => { + const views: unknown[] = []; + const f = fixture(async (_method, route, body) => { + if (route.endsWith('/view')) { views.push(body); return { pages: [{ index: 3, label: '1', shows: ['main.tex:12'], file: '/paper/.git/latex-review/views/p-task/1/page-1-3.png' }], missing: ['Page 99: no page has this printed number.'], targets: ['chapters/intro'] }; } + if (route.includes('/feedback')) return feedback; + return summary; + }); + try { + f.store.updateTask(f.task.id, { threadId: null }); + f.codex.ready = true; + await f.workspace.enqueue(f.task.id, 'Fix the runt on page 1.', 'request', false); + await tick(); await tick(); + const start = f.codex.calls.find(call => call.method === 'thread/start')!.params; + assert.deepEqual(start.dynamicTools.map((tool: any) => tool.name), ['view_pdf']); + assert.match(start.developerInstructions, /use the view_pdf tool and open the pages it draws with your image viewer/); + assert.equal(f.store.task(f.task.id).viewPdf, true); + // Papilot answers the call itself: the author is not asked. + const call = (id: number, tool: string, args: object) => (f.workspace as any).serverRequest({ id, method: 'item/tool/call', params: { threadId: 'thread', turnId: 'turn-new', callId: `call-${id}`, tool, arguments: args } }); + call(41, 'view_pdf', { locations: ['main.tex:12'], pages: ['99', 7], accepted: 'yes' }); + for (let i = 0; i < 20 && !f.codex.replies.length; i++) await tick(); + assert.deepEqual(views, [{ locations: ['main.tex:12'], pages: ['99'] }]); + const reply = f.codex.replies[0] as { id: number; result: { success: boolean; contentItems: any[] } }; + assert.equal(reply.id, 41); + assert.equal(reply.result.success, true); + // Only text comes back: the page files, which the agent opens with its image viewer. + assert.deepEqual(reply.result.contentItems, [{ type: 'inputText', text: 'Your worktree was compiled (only chapters/intro, so page positions differ from the full PDF while printed page numbers match).\nPage 1 shows main.tex:12: /paper/.git/latex-review/views/p-task/1/page-1-3.png\nPage 99: no page has this printed number.\nOpen the page files you need with your image viewer (view_image).' }]); + assert.equal(f.workspace.requests.size, 0); + assert.equal(f.store.task(f.task.id).status, 'running'); + call(42, 'other_tool', {}); + for (let i = 0; i < 20 && f.codex.replies.length < 2; i++) await tick(); + assert.deepEqual(f.codex.replies[1], { id: 42, result: { success: false, contentItems: [{ type: 'inputText', text: 'Papilot has no tool named other_tool.' }] } }); + // The chat shows what the agent looked at. + await (f.workspace as any).notification({ method: 'item/completed', params: { threadId: 'thread', turnId: 'turn-new', item: { id: 'look', type: 'dynamicToolCall', tool: 'view_pdf', arguments: { locations: ['main.tex:12'], pages: ['99'] }, status: 'completed', success: true } } }); + const item = f.store.items(f.task.id).find(entry => entry.id === `${f.task.id}:turn-new:look`)!; + assert.deepEqual([item.type, item.text, item.status], ['pdf', 'main.tex:12, p. 99', 'completed']); + } finally { f.close(); } +}); + +test('a Codex without the experimental tool API starts threads without view_pdf', async () => { + const f = fixture(); + try { + f.codex.handler = async (method, params) => { + if (method === 'thread/start' && params.dynamicTools) throw new RpcResponseError('Invalid request: thread/start.dynamicTools requires experimentalApi capability'); + return method === 'turn/start' ? { turn: { id: 'turn-new' } } : { thread: { id: 'thread' } }; + }; + f.store.updateTask(f.task.id, { threadId: null }); + f.codex.ready = true; + await f.workspace.enqueue(f.task.id, 'Tighten the abstract.', 'request', false); + for (let i = 0; i < 10 && !f.codex.calls.some(call => call.method === 'turn/start'); i++) await tick(); + const starts = f.codex.calls.filter(call => call.method === 'thread/start').map(call => call.params); + assert.equal(starts.length, 2); + assert.equal(starts[1].dynamicTools, undefined); + assert.doesNotMatch(starts[1].developerInstructions, /view_pdf/); + assert.equal(f.store.task(f.task.id).viewPdf, false); + assert.equal(f.store.task(f.task.id).status, 'running'); + } finally { f.close(); } +}); + test('a build that fails for reasons outside the agent\'s edits is not sent back to it', async () => { const f = fixture(async (_method, route) => { if (route.endsWith('/publish')) return { job: 'job' }; diff --git a/vendor/latex-review/src/core/view.ts b/vendor/latex-review/src/core/view.ts new file mode 100644 index 0000000..a25d05c --- /dev/null +++ b/vendor/latex-review/src/core/view.ts @@ -0,0 +1,195 @@ +import fs from "node:fs"; +import path from "node:path"; +import { randomBytes } from "node:crypto"; +import { Builder, BuildError } from "./build.ts"; +import { cleanPath, within } from "./git.ts"; +import { run } from "./process.ts"; +import { mupdf } from "../render/pdf.ts"; +import type { App } from "../server.ts"; +import type { BuildInfo } from "../types.ts"; +// A task's agent looks at its typeset pages: the task worktree as it is now, +// or the accepted text, compiled like a review round (only the chapters the +// edits and the asked-for places need, when the paper allows partial builds, +// so a later publication of the same text reuses the build) and drawn as PNG +// files the agent opens with its own image viewer. Places are source lines +// or printed page numbers. +export interface ViewRequest { + locations?: string[]; + pages?: string[]; + accepted?: boolean; +} +export interface ViewedPage { + /** The page's position in the PDF that was built, from 1. */ + index: number; + /** The page number printed on it. */ + label: string; + /** What asked for this page. */ + shows: string[]; + /** The drawn page. */ + file: string; +} +export interface ViewResult { + pages: ViewedPage[]; + /** Requests no page answered, with the reason. */ + missing: string[]; + targets: string[]; + error?: string; + details?: string[]; +} +// Pages drawn per request, and how far from an asked-for line SyncTeX may +// look for one that printed something (blank lines and comments print nothing). +const PAGES = 4, + NEARBY = 10, + SCALE = 1.5, + // Drawn pages are kept this long, for an agent that opens them later in its turn. + KEEP = 6 * 3600 * 1000; +// A new folder for one view's pages, after removing the task's old ones. +function folder(app: App, name: string) { + const views = path.join(app.project.state, "views", name); + for (const entry of fs.existsSync(views) ? fs.readdirSync(views) : []) { + const old = path.join(views, entry); + if (Date.now() - fs.statSync(old).mtimeMs > KEEP) + fs.rmSync(old, { recursive: true, force: true }); + } + const created = path.join(views, `${Date.now()}-${randomBytes(3).toString("hex")}`); + fs.mkdirSync(created, { recursive: true }); + return created; +} +interface Location { + asked: string; + file: string; + line: number; +} +function location(app: App, worktree: string, value: string): Location { + const match = /^(.*?):(\d+)$/.exec(value.trim()); + if (!match) throw new Error(`Give a location as file:line, such as main.tex:12: ${value}`); + let file = match[1]; + if (path.isAbsolute(file)) file = path.relative(worktree, file); + file = cleanPath(file); + if (!within(file, [app.project.source_dir])) + throw new Error(`${file} is outside the paper's source folder ${app.project.source_dir}.`); + return { asked: value.trim(), file, line: Number(match[2]) }; +} +// The pages SyncTeX gives for a source line, or for the nearest line that +// printed something. +function pagesOf(pdf: string, file: string, line: number) { + for (let distance = 0; distance <= NEARBY; distance++) + for (const candidate of distance ? [line - distance, line + distance] : [line]) { + if (candidate < 1) continue; + const output = run( + "synctex", + ["view", "-i", `${candidate}:0:${file}`, "-o", pdf], + { check: false, timeout: 20000 }, + ).stdout.toString(); + const pages = [ + ...new Set( + [...output.matchAll(/^Page:(\d+)$/gm)].map((m) => Number(m[1])), + ), + ]; + if (pages.length) return pages; + } + return []; +} +export async function view( + app: App, + name: string, + request: ViewRequest, +): Promise { + const task = app.tasks.get(name), + project = app.project; + if (!fs.existsSync(task.worktree)) + throw new Error(`The task worktree ${task.worktree} no longer exists.`); + const locations = (request.locations ?? []).map((l) => + location(app, task.worktree, l), + ), + labels = (request.pages ?? []).map((p) => p.trim()).filter(Boolean); + if (!locations.length && !labels.length) + throw new Error("Ask for at least one source location or page number."); + const after = app.tasks.snapshot(task), + before = task.accepted, + builder = new Builder(project, app.repo), + targets = builder.plan(before, after, [ + ...new Set([ + ...app.repo.changedFiles(before, after), + ...locations.map((l) => l.file), + ]), + ]); + let build: BuildInfo; + try { + const seed = targets[0] !== "full" ? await builder.build(task.base) : null; + build = await builder.build(request.accepted ? before : after, targets, seed); + } catch (error) { + if (!(error instanceof BuildError)) throw error; + return { + pages: [], + missing: [], + targets, + error: request.accepted + ? "The accepted text could not be compiled." + : "The paper in the task worktree could not be compiled.", + details: error.details, + }; + } + const pdf = build.pdf, + document = mupdf.Document.openDocument(fs.readFileSync(pdf), "application/pdf"); + try { + const wanted = new Map(), + missing: string[] = [], + ask = (index: number, asked: string) => + wanted.set(index, [...(wanted.get(index) ?? []), asked]); + for (const l of locations) { + // The build compiles an export of the source folder. + const file = path.join( + build.root, + path.posix.relative(project.source_dir || ".", l.file), + ), + found = fs.existsSync(file) ? pagesOf(pdf, file, l.line) : []; + if (found.length) found.forEach((page) => ask(page, l.asked)); + else missing.push(`${l.asked}: SyncTeX places no typeset text at or near this line.`); + } + if (labels.length) { + const printed = Array.from({ length: document.countPages() }, (_, i) => { + const page = document.loadPage(i), + label = page.getLabel() || String(i + 1); + page.destroy(); + return label; + }); + for (const label of labels) { + const index = printed.findIndex( + (p) => p.toLowerCase() === label.toLowerCase(), + ); + if (index >= 0) ask(index + 1, label); + else + missing.push( + targets[0] === "full" + ? `Page ${label}: no page has this printed number.` + : `Page ${label}: not in the chapters compiled for this view (${targets.join(", ")}). Ask for a source line on that page instead.`, + ); + } + } + const indices = [...wanted.keys()].sort((a, b) => a - b); + if (indices.length > PAGES) + missing.push( + `Only the first ${PAGES} pages are shown; ask again for pages ${indices.slice(PAGES).join(", ")} of this build.`, + ); + const out = indices.length ? folder(app, name) : ""; + const pages = indices.slice(0, PAGES).map((index) => { + const page = document.loadPage(index - 1), + pixmap = page.toPixmap( + [SCALE, 0, 0, SCALE, 0, 0], + mupdf.ColorSpace.DeviceRGB, + false, + true, + ), + label = page.getLabel() || String(index), + file = path.join(out, `page-${/^[\w-]{1,16}$/u.test(label) ? label : index}-${index}.png`); + fs.writeFileSync(file, pixmap.asPNG()); + pixmap.destroy(); + page.destroy(); + return { index, label, shows: wanted.get(index)!, file }; + }); + return { pages, missing, targets }; + } finally { + document.destroy(); + } +} diff --git a/vendor/latex-review/src/server.ts b/vendor/latex-review/src/server.ts index 1ea3444..bb92cee 100644 --- a/vendor/latex-review/src/server.ts +++ b/vendor/latex-review/src/server.ts @@ -12,6 +12,7 @@ import { Tasks } from "./core/tasks.ts"; import { Review } from "./core/review.ts"; import { Deliveries } from "./core/delivery.ts"; import { more, PublishError, Publisher } from "./core/publish.ts"; +import { view, type ViewRequest } from "./core/view.ts"; import { acquireLock, now, readJson, writeJson } from "./core/files.ts"; import { stopAll } from "./core/process.ts"; import type { ActionKind } from "./types.ts"; @@ -272,6 +273,12 @@ export function createServer(app: App) { return { job: job.id, status: job.status }; }, ); + // The task's typeset pages as images, for its agent to look at. + server.post<{ Params: { name: string }; Body: ViewRequest }>( + "/api/tasks/:name/view", + { schema: { body: { ...objectSchema, additionalProperties: false, properties: { locations: { type: "array", maxItems: 8, items: { type: "string", maxLength: 1000 } }, pages: { type: "array", maxItems: 8, items: { type: "string", maxLength: 40 } }, accepted: { type: "boolean" } } } } }, + (request) => view(app, request.params.name, request.body), + ); server.post<{ Params: { name: string }; Body: { mode: "workspace" | "branch"; message?: string } }>( "/api/tasks/:name/deliver", { schema: { body: { ...objectSchema, required: ["mode"], properties: { mode: { enum: ["workspace", "branch"] }, message: { type: "string", maxLength: 5000 } } } } }, diff --git a/vendor/latex-review/tests/ts/view.test.ts b/vendor/latex-review/tests/ts/view.test.ts new file mode 100644 index 0000000..05584cf --- /dev/null +++ b/vendor/latex-review/tests/ts/view.test.ts @@ -0,0 +1,55 @@ +import fs from "node:fs"; +import path from "node:path"; +import { it, expect } from "vitest"; +import { which } from "../../src/core/process.ts"; +import { view } from "../../src/core/view.ts"; +import { Fixture, METHOD } from "./helpers.ts"; +const tex = Boolean(which("latexmk") && which("pdflatex") && which("synctex")); +const png = (page: { file: string }) => + fs.readFileSync(page.file).subarray(1, 4).toString(); +it.skipIf(!tex)( + "draws the pages where source lines and printed page numbers are typeset", + async () => { + const f = new Fixture(), + app = f.app(); + try { + const task = app.tasks.create("look"); + f.edit(task, METHOD, "Method sentence 3.", "Method sentence three, revised."); + const shown = await view(app, task.name, { + locations: [`${METHOD}:6`, path.join(task.worktree, METHOD) + ":7"], + pages: ["1", "99"], + }); + expect(shown.error).toBeUndefined(); + expect(shown.pages).toHaveLength(1); + expect(shown.pages[0]).toMatchObject({ index: 1, label: "1" }); + expect(shown.pages[0].shows).toEqual([`${METHOD}:6`, path.join(task.worktree, METHOD) + ":7", "1"]); + expect(png(shown.pages[0])).toBe("PNG"); + expect(path.basename(shown.pages[0].file)).toBe("page-1-1.png"); + expect(shown.pages[0].file.startsWith(path.join(app.project.state, "views", task.name))).toBe(true); + expect(shown.missing).toEqual(["Page 99: no page has this printed number."]); + // The accepted text can be seen too. + const accepted = await view(app, task.name, { pages: ["1"], accepted: true }); + expect(accepted.pages.map((p) => p.label)).toEqual(["1"]); + await expect(view(app, task.name, { locations: ["../outside.tex:1"] })).rejects.toThrow( + /repository-relative path/, + ); + await expect(view(app, task.name, {})).rejects.toThrow(/at least one/); + } finally { + await f.close(); + } + }, +); +it.skipIf(!tex)("reports a worktree that does not compile", async () => { + const f = new Fixture(), + app = f.app(); + try { + const task = app.tasks.create("broken"); + fs.appendFileSync(path.join(task.worktree, METHOD), "\\undefinedcommand\n"); + const shown = await view(app, task.name, { locations: [`${METHOD}:3`] }); + expect(shown.pages).toEqual([]); + expect(shown.error).toBe("The paper in the task worktree could not be compiled."); + expect(shown.details!.join("\n")).toMatch(/Undefined control sequence/); + } finally { + await f.close(); + } +}); From cca504a88d1f923aae077dc99a90e4c1f01c3b0d Mon Sep 17 00:00:00 2001 From: Cui Chen Date: Sun, 1 Nov 2026 05:01:05 +0700 Subject: [PATCH 2/3] Use source locations when PDF page labels are unavailable --- docs/guide.md | 2 +- src/server/workspace.ts | 6 +-- tests/workspace.test.ts | 4 +- vendor/latex-review/src/core/view.ts | 23 +++++++---- vendor/latex-review/tests/ts/view.test.ts | 50 ++++++++++++++++++++--- 5 files changed, 66 insertions(+), 19 deletions(-) diff --git a/docs/guide.md b/docs/guide.md index 5d61632..9b71451 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -49,7 +49,7 @@ Each task runs on one agent and model, chosen from the model menu below the prom CLI agents, including Claude when Claude Code is installed, use their own login and configuration on this host. The SDK-based agents (Cursor, GLM, and Claude without Claude Code) take their key from Papilot's environment and receive it only for their own service. Papilot never stores a credential. Every agent edits only the task's worktree and leaves compiling and publishing the review to Papilot. -- **Codex** runs through the Codex app-server with its workspace-write sandbox. Commands outside the sandbox and Codex's questions come to the conversation for approval. Codex can also look at the typeset paper: Papilot gives each new Codex task a `view_pdf` tool that compiles the task worktree as it is and draws the pages where given source lines, or given printed page numbers, appear, or the same places in the accepted text, as image files that Codex opens with its own image viewer. Codex decides when to use it, for example when line breaks, spacing, figures or formulas matter; it compiles only the chapters it needs when the paper allows partial builds, and the review after the turn reuses that build. The conversation shows each look as **Viewed the PDF**. This uses the app-server's experimental API; a Codex without it runs tasks without the tool. +- **Codex** runs through the Codex app-server with its workspace-write sandbox. Commands outside the sandbox and Codex's questions come to the conversation for approval. Codex can also look at the typeset paper: Papilot gives each new Codex task a `view_pdf` tool that compiles the task worktree as it is and draws the pages where given source lines, or given printed page numbers, appear, or the same places in the accepted text, as image files that Codex opens with its own image viewer. Codex decides when to use it, for example when line breaks, spacing, figures or formulas matter; it compiles only the chapters it needs when the paper allows partial builds, and the review after the turn reuses that build. When the PDF has no page-label metadata, Codex uses source locations instead of guessing printed numbers from physical page positions. The conversation shows each look as **Viewed the PDF**. This uses the app-server's experimental API; a Codex without it runs tasks without the tool. - **Claude** runs on [Claude Code](https://code.claude.com) when it is installed: each turn is one `claude -p` process with your own Claude Code login, either your Claude plan, whose usage counts toward the plan's limits, or an API key. Sign in once with `claude auth login`. Papilot only reads whether Claude Code is signed in; the credential stays with Claude Code. Edits in the worktree are accepted and commands run in Claude Code's sandbox. `-p` cannot ask for approval, so anything that would need it, such as a write outside the worktree, is declined and noted in the conversation, and Claude asks its questions in its reply. A later turn resumes the same session. The model menu lists the models your Claude Code offers, such as Opus 5.5 and Sonnet 5.5, with their reasoning efforts and its default first; Papilot reads them from Claude Code without sending a request. Set `PAPILOT_CLAUDE_BIN` if the service cannot find `claude`. Without Claude Code, Claude runs through the Claude Agent SDK, which an installed Papilot downloads into `agents` in the data folder the first time it starts with an API key. Through the SDK, anything that needs approval, including Claude's questions, comes to the conversation. The SDK needs an API key: Anthropic does not accept a Claude subscription login through the Agent SDK and refuses such requests with 403. Run Papilot with `ANTHROPIC_API_KEY` set (or with Claude Code configured for Amazon Bedrock, Google Vertex AI or Microsoft Foundry); usage is billed to that key or provider. diff --git a/src/server/workspace.ts b/src/server/workspace.ts index 9117ceb..180fb87 100644 --- a/src/server/workspace.ts +++ b/src/server/workspace.ts @@ -68,7 +68,7 @@ export const VIEW_PDF_TOOL = { type: 'object', additionalProperties: false, properties: { locations: { type: 'array', items: { type: 'string' }, maxItems: 4, description: 'Source lines as file:line, relative to the worktree.' }, - pages: { type: 'array', items: { type: 'string' }, maxItems: 4, description: 'Page numbers as printed on the pages, such as "12" or "iv".' }, + pages: { type: 'array', items: { type: 'string' }, maxItems: 4, description: 'Printed page numbers, such as "12" or "iv", when the PDF provides page labels. Use locations if numbering metadata is unavailable.' }, accepted: { type: 'boolean', description: 'Show the accepted text instead of your worktree.' }, }, }, @@ -574,13 +574,13 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo const strings = (value: unknown) => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string').slice(0, 4) : []; try { const client = await this.connectReview(this.store.project(task.projectId).path); - const shown = await client.post<{ pages: { index: number; label: string; shows: string[]; file: string }[]; missing: string[]; targets: string[]; error?: string; details?: string[] }>( + const shown = await client.post<{ pages: { index: number; label: string | null; shows: string[]; file: string }[]; missing: string[]; targets: string[]; error?: string; details?: string[] }>( `${taskRoute(task.name)}/view`, { locations: strings(args?.locations), pages: strings(args?.pages), ...(args?.accepted === true ? { accepted: true } : {}) }, 30 * 60000); if (shown.error) { reply(false, [shown.error, ...(shown.details || [])].join('\n')); return; } const compiled = shown.targets[0] === 'full' ? 'the whole paper' : `only ${shown.targets.join(', ')}, so page positions differ from the full PDF while printed page numbers match`; reply(true, [ `${args?.accepted === true ? 'The accepted text' : 'Your worktree'} was compiled (${compiled}).`, - ...shown.pages.map(page => `Page ${page.label} shows ${page.shows.join(', ')}: ${page.file}`), + ...shown.pages.map(page => `${page.label ? `Page ${page.label}` : `PDF page ${page.index} (printed page number unavailable)`} shows ${page.shows.join(', ')}: ${page.file}`), ...shown.missing, ...(shown.pages.length ? ['Open the page files you need with your image viewer (view_image).'] : []), ].join('\n')); diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts index 2458b16..65f81bb 100644 --- a/tests/workspace.test.ts +++ b/tests/workspace.test.ts @@ -1255,7 +1255,7 @@ test('a build the agent broke goes back to it once, and its instructions name wh test('Codex threads get the view_pdf tool, which Papilot answers with typeset pages', async () => { const views: unknown[] = []; const f = fixture(async (_method, route, body) => { - if (route.endsWith('/view')) { views.push(body); return { pages: [{ index: 3, label: '1', shows: ['main.tex:12'], file: '/paper/.git/latex-review/views/p-task/1/page-1-3.png' }], missing: ['Page 99: no page has this printed number.'], targets: ['chapters/intro'] }; } + if (route.endsWith('/view')) { views.push(body); return { pages: [{ index: 3, label: '1', shows: ['main.tex:12'], file: '/paper/.git/latex-review/views/p-task/1/page-1-3.png' }, { index: 4, label: null, shows: ['main.tex:20'], file: '/paper/.git/latex-review/views/p-task/1/page-4-4.png' }], missing: ['Page 99: no page has this printed number.'], targets: ['chapters/intro'] }; } if (route.includes('/feedback')) return feedback; return summary; }); @@ -1277,7 +1277,7 @@ test('Codex threads get the view_pdf tool, which Papilot answers with typeset pa assert.equal(reply.id, 41); assert.equal(reply.result.success, true); // Only text comes back: the page files, which the agent opens with its image viewer. - assert.deepEqual(reply.result.contentItems, [{ type: 'inputText', text: 'Your worktree was compiled (only chapters/intro, so page positions differ from the full PDF while printed page numbers match).\nPage 1 shows main.tex:12: /paper/.git/latex-review/views/p-task/1/page-1-3.png\nPage 99: no page has this printed number.\nOpen the page files you need with your image viewer (view_image).' }]); + assert.deepEqual(reply.result.contentItems, [{ type: 'inputText', text: 'Your worktree was compiled (only chapters/intro, so page positions differ from the full PDF while printed page numbers match).\nPage 1 shows main.tex:12: /paper/.git/latex-review/views/p-task/1/page-1-3.png\nPDF page 4 (printed page number unavailable) shows main.tex:20: /paper/.git/latex-review/views/p-task/1/page-4-4.png\nPage 99: no page has this printed number.\nOpen the page files you need with your image viewer (view_image).' }]); assert.equal(f.workspace.requests.size, 0); assert.equal(f.store.task(f.task.id).status, 'running'); call(42, 'other_tool', {}); diff --git a/vendor/latex-review/src/core/view.ts b/vendor/latex-review/src/core/view.ts index a25d05c..37690e2 100644 --- a/vendor/latex-review/src/core/view.ts +++ b/vendor/latex-review/src/core/view.ts @@ -21,8 +21,8 @@ export interface ViewRequest { export interface ViewedPage { /** The page's position in the PDF that was built, from 1. */ index: number; - /** The page number printed on it. */ - label: string; + /** The PDF's page label, or null when no numbering metadata is available. */ + label: string | null; /** What asked for this page. */ shows: string[]; /** The drawn page. */ @@ -133,6 +133,13 @@ export async function view( const pdf = build.pdf, document = mupdf.Document.openDocument(fs.readFileSync(pdf), "application/pdf"); try { + // MuPDF synthesizes 1, 2, ... when /PageLabels is absent. Those are + // physical positions, not the printed numbers (front matter may be i, ii). + const trailer = document.asPDF()?.getTrailer(), + pageLabels = trailer?.get("Root", "PageLabels"), + hasLabels = pageLabels?.isDictionary() ?? false; + pageLabels?.destroy(); + trailer?.destroy(); const wanted = new Map(), missing: string[] = [], ask = (index: number, asked: string) => @@ -147,10 +154,12 @@ export async function view( if (found.length) found.forEach((page) => ask(page, l.asked)); else missing.push(`${l.asked}: SyncTeX places no typeset text at or near this line.`); } - if (labels.length) { + if (labels.length && !hasLabels) { + missing.push("This PDF has no page-label metadata, so printed page numbers cannot be located reliably. Ask for source locations (file:line) instead."); + } else if (labels.length) { const printed = Array.from({ length: document.countPages() }, (_, i) => { const page = document.loadPage(i), - label = page.getLabel() || String(i + 1); + label = page.getLabel(); page.destroy(); return label; }); @@ -170,7 +179,7 @@ export async function view( const indices = [...wanted.keys()].sort((a, b) => a - b); if (indices.length > PAGES) missing.push( - `Only the first ${PAGES} pages are shown; ask again for pages ${indices.slice(PAGES).join(", ")} of this build.`, + `Only the first ${PAGES} pages are shown; ask for more specific source locations to see the remaining pages (physical positions ${indices.slice(PAGES).join(", ")} in this build).`, ); const out = indices.length ? folder(app, name) : ""; const pages = indices.slice(0, PAGES).map((index) => { @@ -181,8 +190,8 @@ export async function view( false, true, ), - label = page.getLabel() || String(index), - file = path.join(out, `page-${/^[\w-]{1,16}$/u.test(label) ? label : index}-${index}.png`); + label = hasLabels ? page.getLabel() || null : null, + file = path.join(out, `page-${label && /^[\w-]{1,16}$/u.test(label) ? label : index}-${index}.png`); fs.writeFileSync(file, pixmap.asPNG()); pixmap.destroy(); page.destroy(); diff --git a/vendor/latex-review/tests/ts/view.test.ts b/vendor/latex-review/tests/ts/view.test.ts index 05584cf..e6c53d3 100644 --- a/vendor/latex-review/tests/ts/view.test.ts +++ b/vendor/latex-review/tests/ts/view.test.ts @@ -13,23 +13,26 @@ it.skipIf(!tex)( const f = new Fixture(), app = f.app(); try { + // Hyperref may omit /PageLabels for ordinary 1, 2, ... numbering. + // Roman numbering makes this fixture exercise real label metadata. + f.write("paper/main.tex", f.read("paper/main.tex").replace("\\begin{document}", "\\begin{document}\n\\pagenumbering{roman}")); const task = app.tasks.create("look"); f.edit(task, METHOD, "Method sentence 3.", "Method sentence three, revised."); const shown = await view(app, task.name, { locations: [`${METHOD}:6`, path.join(task.worktree, METHOD) + ":7"], - pages: ["1", "99"], + pages: ["i", "99"], }); expect(shown.error).toBeUndefined(); expect(shown.pages).toHaveLength(1); - expect(shown.pages[0]).toMatchObject({ index: 1, label: "1" }); - expect(shown.pages[0].shows).toEqual([`${METHOD}:6`, path.join(task.worktree, METHOD) + ":7", "1"]); + expect(shown.pages[0]).toMatchObject({ index: 1, label: "i" }); + expect(shown.pages[0].shows).toEqual([`${METHOD}:6`, path.join(task.worktree, METHOD) + ":7", "i"]); expect(png(shown.pages[0])).toBe("PNG"); - expect(path.basename(shown.pages[0].file)).toBe("page-1-1.png"); + expect(path.basename(shown.pages[0].file)).toBe("page-i-1.png"); expect(shown.pages[0].file.startsWith(path.join(app.project.state, "views", task.name))).toBe(true); expect(shown.missing).toEqual(["Page 99: no page has this printed number."]); // The accepted text can be seen too. - const accepted = await view(app, task.name, { pages: ["1"], accepted: true }); - expect(accepted.pages.map((p) => p.label)).toEqual(["1"]); + const accepted = await view(app, task.name, { pages: ["i"], accepted: true }); + expect(accepted.pages.map((p) => p.label)).toEqual(["i"]); await expect(view(app, task.name, { locations: ["../outside.tex:1"] })).rejects.toThrow( /repository-relative path/, ); @@ -53,3 +56,38 @@ it.skipIf(!tex)("reports a worktree that does not compile", async () => { await f.close(); } }); + +it.skipIf(!tex)("does not mistake physical positions for printed page numbers", async () => { + const f = new Fixture(); + try { + const source = String.raw`\documentclass{article} +\begin{document} +\pagenumbering{roman} +Front matter. +\newpage +\pagenumbering{arabic} +Main content. +\end{document} +`; + f.write("paper/main.tex", source); + const app = f.app(), task = app.tasks.create("numbering"); + const missing = await view(app, task.name, { pages: ["i", "1"] }); + expect(missing.pages).toEqual([]); + expect(missing.missing.join(" ")).toMatch(/no page-label metadata.*source locations/); + // Source locations still reach the right page, without claiming that + // the second physical page has the printed number 2. + const located = await view(app, task.name, { locations: ["paper/main.tex:7"] }); + expect(located.pages).toHaveLength(1); + expect(located.pages[0]).toMatchObject({ index: 2, label: null }); + expect(png(located.pages[0])).toBe("PNG"); + // With numbering metadata, roman and reset arabic numbers work as before. + f.write("paper/main.tex", source.replace("\\begin{document}", "\\usepackage{hyperref}\n\\begin{document}"), task.worktree); + const labeled = await view(app, task.name, { pages: ["i", "1"] }); + expect(labeled.pages.map(({ index, label }) => ({ index, label }))).toEqual([ + { index: 1, label: "i" }, { index: 2, label: "1" }, + ]); + expect(labeled.missing).toEqual([]); + } finally { + await f.close(); + } +}); From dc1004a798b52cf12a05509fcfaadee689fb3555 Mon Sep 17 00:00:00 2001 From: Cui Chen Date: Sun, 1 Nov 2026 05:23:55 +0700 Subject: [PATCH 3/3] Discard stale PDF tool replies after agent disconnects --- src/server/workspace.ts | 21 +++++++++++++++-- tests/workspace.test.ts | 51 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/server/workspace.ts b/src/server/workspace.ts index 180fb87..5a82394 100644 --- a/src/server/workspace.ts +++ b/src/server/workspace.ts @@ -95,6 +95,8 @@ export class Workspace extends EventEmitter { agents: Record; requests = new Map(); private active = new Map(); + // Async tools must never answer a request from an earlier connection. + private connectionEpochs = new Map(); private loaded = new Set(); private preparing = new Set(); private preparingNotes = new Set(); @@ -143,6 +145,7 @@ export class Workspace extends EventEmitter { } private agentOf(task: Task): AgentId { return task.agent && AGENTS.includes(task.agent) ? task.agent : 'codex'; } private disconnected(id: AgentId, e: unknown) { + this.connectionEpochs.set(id, (this.connectionEpochs.get(id) || 0) + 1); this.runtimes[id].connected = false; this.runtimes[id].error = errorText(e); for (const [key, request] of this.requests) if (request.agent === id) this.requests.delete(key); for (const [taskId, run] of this.active) { @@ -558,7 +561,10 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo return; } // Papilot answers its own tools; the author is not asked. - if (message.method === 'item/tool/call') { void this.toolCall(task, message, runtime); return; } + if (message.method === 'item/tool/call') { + void this.toolCall(task, message, runtime).catch(error => console.error(`${AGENT_NAMES[agent]} tool:`, errorText(error))); + return; + } const id = randomUUID(); this.requests.set(id, { id, taskId: task.id, rpcId: message.id, agent, method: message.method, params: message.params, createdAt: Date.now() }); this.store.updateTask(task.id, { status: 'waiting_input' }); @@ -569,11 +575,22 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo // Codex's code mode as base64 text. private async toolCall(task: Task, message: any, runtime: AgentRuntime) { const { tool, arguments: args } = message.params || {}; - const reply = (success: boolean, text: string) => runtime.respond(message.id, { success, contentItems: [{ type: 'inputText', text }] }); + const agent = this.agentOf(task), run = this.active.get(task.id), epoch = this.connectionEpochs.get(agent); + const current = () => !this.closing && runtime.ready && this.agents[agent] === runtime + && this.connectionEpochs.get(agent) === epoch && run && this.active.get(task.id)?.id === run.id + && this.store.task(task.id).turnId === task.turnId; + const reply = (success: boolean, text: string) => { + if (!current()) return; + // A failed transport cannot receive an error reply either. Do not let + // it escape this detached async call or retry it against a new child. + try { runtime.respond(message.id, { success, contentItems: [{ type: 'inputText', text }] }); } + catch (error) { console.error(`${AGENT_NAMES[agent]} tool reply:`, errorText(error)); } + }; if (tool !== VIEW_PDF) { reply(false, `Papilot has no tool named ${tool}.`); return; } const strings = (value: unknown) => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string').slice(0, 4) : []; try { const client = await this.connectReview(this.store.project(task.projectId).path); + if (!current()) return; const shown = await client.post<{ pages: { index: number; label: string | null; shows: string[]; file: string }[]; missing: string[]; targets: string[]; error?: string; details?: string[] }>( `${taskRoute(task.name)}/view`, { locations: strings(args?.locations), pages: strings(args?.pages), ...(args?.accepted === true ? { accepted: true } : {}) }, 30 * 60000); if (shown.error) { reply(false, [shown.error, ...(shown.details || [])].join('\n')); return; } diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts index 65f81bb..33aff1d 100644 --- a/tests/workspace.test.ts +++ b/tests/workspace.test.ts @@ -1290,6 +1290,57 @@ test('Codex threads get the view_pdf tool, which Papilot answers with typeset pa } finally { f.close(); } }); +test('PDF tool replies are discarded after disconnect, reconnect, turn completion or shutdown', async t => { + for (const scenario of ['disconnect', 'reconnect', 'completed', 'closed']) await t.test(scenario, async () => { + const gate = deferred(); + const f = fixture(async (_method, route) => route.endsWith('/view') ? gate.promise : summary); + try { + f.codex.ready = true; + await f.workspace.enqueue(f.task.id, 'Inspect the PDF.', 'request', false); + await tick(); await tick(); + const run = (f.workspace as any).active.get(f.task.id); + const turnId = f.store.task(f.task.id).turnId; + (f.workspace as any).serverRequest({ id: 91, method: 'item/tool/call', params: { threadId: 'thread', turnId, tool: 'view_pdf', arguments: { pages: ['1'] } } }); + await tick(); + assert.ok(f.calls.some(call => call.route.endsWith('/view'))); + let replies = 0; + f.codex.respond = () => { replies++; throw new Error('Codex disconnected'); }; + if (scenario === 'closed') f.workspace.close(); + else if (scenario === 'completed') (f.workspace as any).active.delete(f.task.id); + else { + f.codex.ready = false; + f.codex.emit('disconnected', new Error('Agent exited')); + assert.equal(f.store.task(f.task.id).status, 'interrupted'); + if (scenario === 'reconnect') { + // Even recovery of the same run/turn cannot revive an old RPC id. + f.codex.ready = true; + (f.workspace as any).active.set(f.task.id, run); + f.store.updateTask(f.task.id, { status: 'running', turnId }); + } + } + gate.resolve({ pages: [], missing: [], targets: ['full'] }); + await tick(); await tick(); + assert.equal(replies, 0); + } finally { gate.resolve({ pages: [], missing: [], targets: ['full'] }); f.close(); } + }); +}); + +test('a failed PDF tool response is caught without trying to reply a second time', async t => { + const f = fixture(async () => ({ pages: [], missing: [], targets: ['full'] })); + t.mock.method(console, 'error', () => {}); + try { + f.codex.ready = true; + await f.workspace.enqueue(f.task.id, 'Inspect the PDF.', 'request', false); + await tick(); await tick(); + let attempts = 0; + f.codex.respond = () => { attempts++; throw new Error('Broken pipe'); }; + (f.workspace as any).serverRequest({ id: 92, method: 'item/tool/call', params: { threadId: 'thread', turnId: f.store.task(f.task.id).turnId, tool: 'view_pdf', arguments: { pages: ['1'] } } }); + await tick(); await tick(); + assert.equal(attempts, 1); + assert.equal(f.store.task(f.task.id).status, 'running'); + } finally { f.close(); } +}); + test('a Codex without the experimental tool API starts threads without view_pdf', async () => { const f = fixture(); try {