@vintasoftware/pr-review-canvas 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +192 -0
  3. package/bin/pr-review.mjs +5 -0
  4. package/docs/reference.md +340 -0
  5. package/package.json +74 -0
  6. package/pr-review.config.example.yml +68 -0
  7. package/prompts/chat-seed.md +64 -0
  8. package/prompts/generation-format.md +255 -0
  9. package/prompts/generation-strict.md +34 -0
  10. package/prompts/generation-surfacing.md +67 -0
  11. package/prompts/layers-default.md +13 -0
  12. package/prompts/quality-standards.md +32 -0
  13. package/skills/pr-review-canvas/SKILL.md +177 -0
  14. package/src/acpx/acpx.ts +530 -0
  15. package/src/acpx/agents.ts +85 -0
  16. package/src/acpx/events.ts +216 -0
  17. package/src/acpx/ndjson.ts +69 -0
  18. package/src/acpx/preflight.ts +44 -0
  19. package/src/canvas/export.ts +95 -0
  20. package/src/canvas/import.ts +138 -0
  21. package/src/canvas/name.ts +55 -0
  22. package/src/canvas/zip.ts +123 -0
  23. package/src/chat/chat-manager.ts +389 -0
  24. package/src/chat/context.ts +160 -0
  25. package/src/chat/seed.ts +71 -0
  26. package/src/chat/threads.ts +114 -0
  27. package/src/cli.ts +199 -0
  28. package/src/commands.ts +424 -0
  29. package/src/config.ts +142 -0
  30. package/src/contract/api.ts +190 -0
  31. package/src/contract/canvas-manifest.ts +29 -0
  32. package/src/contract/chat.ts +76 -0
  33. package/src/contract/comments.ts +96 -0
  34. package/src/contract/discovery.ts +20 -0
  35. package/src/contract/generation-context.ts +77 -0
  36. package/src/contract/keys.ts +14 -0
  37. package/src/contract/links.ts +5 -0
  38. package/src/contract/mermaid-fences.ts +4 -0
  39. package/src/contract/review-artifact.ts +324 -0
  40. package/src/contract/settings.ts +144 -0
  41. package/src/contract/state.ts +46 -0
  42. package/src/contract/validation.ts +43 -0
  43. package/src/git/diff-collector.ts +151 -0
  44. package/src/git/git.ts +115 -0
  45. package/src/git/lang.ts +1 -0
  46. package/src/git/materialize.ts +79 -0
  47. package/src/git/patch-lines.ts +60 -0
  48. package/src/github/attachments.ts +288 -0
  49. package/src/github/capabilities.ts +112 -0
  50. package/src/github/comments.ts +132 -0
  51. package/src/github/gh.ts +196 -0
  52. package/src/github/post-comment.ts +104 -0
  53. package/src/github/post-review.ts +44 -0
  54. package/src/github/pr.ts +133 -0
  55. package/src/github/review-body.ts +72 -0
  56. package/src/github/threads.ts +63 -0
  57. package/src/paths.ts +10 -0
  58. package/src/project-config.ts +219 -0
  59. package/src/prompt-files.ts +26 -0
  60. package/src/review/diagram-nodes.ts +227 -0
  61. package/src/review/doctor.ts +139 -0
  62. package/src/review/glob.ts +33 -0
  63. package/src/review/install-skill.ts +107 -0
  64. package/src/review/normalize.ts +209 -0
  65. package/src/review/prepare.ts +165 -0
  66. package/src/review/prompt.ts +233 -0
  67. package/src/review/publish.ts +209 -0
  68. package/src/review/skill-command.ts +4 -0
  69. package/src/review/test-paths.ts +32 -0
  70. package/src/review/text-length.ts +15 -0
  71. package/src/review/trim-caps.ts +114 -0
  72. package/src/review/validate-folds.ts +110 -0
  73. package/src/review/validate.ts +520 -0
  74. package/src/server/app.ts +46 -0
  75. package/src/server/bundle.ts +266 -0
  76. package/src/server/capped-body.ts +62 -0
  77. package/src/server/context.ts +174 -0
  78. package/src/server/env.ts +7 -0
  79. package/src/server/errors.ts +65 -0
  80. package/src/server/html.ts +140 -0
  81. package/src/server/node-server.ts +42 -0
  82. package/src/server/routes/api.ts +256 -0
  83. package/src/server/routes/chat-routes.ts +221 -0
  84. package/src/server/routes/pages.ts +64 -0
  85. package/src/server/routes/review-routes.ts +245 -0
  86. package/src/server/routes/static.ts +114 -0
  87. package/src/server/security.ts +104 -0
  88. package/src/server/sse.ts +67 -0
  89. package/src/store/atomic-json.ts +68 -0
  90. package/src/store/canvas-store.ts +120 -0
  91. package/src/store/data-dir.ts +29 -0
  92. package/src/store/derived-store.ts +93 -0
  93. package/src/store/pr-store.ts +69 -0
  94. package/src/store/settings-store.ts +152 -0
  95. package/src/store/state-store.ts +121 -0
  96. package/static/js/anchors.js +141 -0
  97. package/static/js/api.js +542 -0
  98. package/static/js/app.js +418 -0
  99. package/static/js/ask.js +35 -0
  100. package/static/js/chat-context.js +137 -0
  101. package/static/js/chat-scroll.js +114 -0
  102. package/static/js/chat.js +843 -0
  103. package/static/js/code-folds.js +200 -0
  104. package/static/js/commands.js +110 -0
  105. package/static/js/comment-link.js +37 -0
  106. package/static/js/composer.js +241 -0
  107. package/static/js/contract-types.d.ts +59 -0
  108. package/static/js/deep-link.js +160 -0
  109. package/static/js/diagram.js +582 -0
  110. package/static/js/diff-decorations.js +204 -0
  111. package/static/js/diff-renderer.js +860 -0
  112. package/static/js/dom.js +145 -0
  113. package/static/js/download.js +52 -0
  114. package/static/js/empty-state.js +161 -0
  115. package/static/js/errors.js +135 -0
  116. package/static/js/fences.js +90 -0
  117. package/static/js/header.js +134 -0
  118. package/static/js/hunks.js +62 -0
  119. package/static/js/import-zone.js +95 -0
  120. package/static/js/interactions.js +952 -0
  121. package/static/js/keyboard.js +131 -0
  122. package/static/js/keys.js +97 -0
  123. package/static/js/lang.js +54 -0
  124. package/static/js/layers.js +596 -0
  125. package/static/js/links.js +150 -0
  126. package/static/js/markdown.js +232 -0
  127. package/static/js/mermaid-fences.js +55 -0
  128. package/static/js/nav.js +91 -0
  129. package/static/js/overview.js +85 -0
  130. package/static/js/points.js +247 -0
  131. package/static/js/progress.js +49 -0
  132. package/static/js/proposed-comment.js +133 -0
  133. package/static/js/quick-questions.js +216 -0
  134. package/static/js/regenerate.js +69 -0
  135. package/static/js/review-session.js +257 -0
  136. package/static/js/scroll-spy.js +66 -0
  137. package/static/js/selection.js +193 -0
  138. package/static/js/settings.js +206 -0
  139. package/static/js/signoff.js +171 -0
  140. package/static/js/skin.js +56 -0
  141. package/static/js/store.js +35 -0
  142. package/static/js/theme.js +56 -0
  143. package/static/js/threads.js +78 -0
  144. package/static/js/vendor.d.ts +15 -0
  145. package/static/styles/base.css +223 -0
  146. package/static/styles/chat-tools.css +130 -0
  147. package/static/styles/chat.css +140 -0
  148. package/static/styles/commands.css +156 -0
  149. package/static/styles/diff.css +258 -0
  150. package/static/styles/header.css +114 -0
  151. package/static/styles/layout.css +123 -0
  152. package/static/styles/panels.css +152 -0
  153. package/static/styles/responsive.css +80 -0
  154. package/static/styles/review-actions.css +124 -0
  155. package/static/styles/review.css +473 -0
  156. package/static/styles/skin-github.css +356 -0
  157. package/static/styles.css +14 -0
@@ -0,0 +1,123 @@
1
+ // The shared canvas format: a zip holding manifest.json and review.json at the root, and nothing
2
+ // else. Everything that reads a zip goes through readCanvasZip, so a file from a teammate, from a
3
+ // pull request attachment, and from the CLI is checked the same way.
4
+ import { unzipSync, zipSync } from 'fflate'
5
+ import { type CanvasManifest, CanvasManifestSchema } from '../contract/canvas-manifest.js'
6
+ import { type ReviewArtifact, ReviewArtifactSchema } from '../contract/review-artifact.js'
7
+
8
+ export const MANIFEST_ENTRY = 'manifest.json'
9
+ export const REVIEW_ENTRY = 'review.json'
10
+ export const CANVAS_ZIP_MAX_BYTES = 20 * 1024 * 1024
11
+ /** `PK\x03\x04`, the first bytes of every zip. */
12
+ export const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04] as const
13
+
14
+ export interface CanvasZipContents {
15
+ manifest: CanvasManifest
16
+ artifact: ReviewArtifact
17
+ }
18
+
19
+ /** A zip that cannot be read as a canvas. `issues` names the problems, never the file's content. */
20
+ export class CanvasZipError extends Error {
21
+ readonly code: 'CANVAS_INVALID' | 'CANVAS_TOO_LARGE'
22
+ readonly issues: string[]
23
+
24
+ constructor(code: 'CANVAS_INVALID' | 'CANVAS_TOO_LARGE', message: string, issues: string[] = []) {
25
+ super(message)
26
+ this.name = 'CanvasZipError'
27
+ this.code = code
28
+ this.issues = issues
29
+ }
30
+ }
31
+
32
+ export function hasZipMagic(bytes: Uint8Array): boolean {
33
+ return ZIP_MAGIC.every((byte, i) => bytes[i] === byte)
34
+ }
35
+
36
+ export function buildCanvasZip(manifest: CanvasManifest, artifact: ReviewArtifact): Uint8Array<ArrayBuffer> {
37
+ const encoder = new TextEncoder()
38
+ // Copied into a plain buffer, so the bytes can be handed straight to a Blob or a Response.
39
+ return Uint8Array.from(
40
+ zipSync(
41
+ {
42
+ [MANIFEST_ENTRY]: encoder.encode(`${JSON.stringify(manifest, null, 2)}\n`),
43
+ [REVIEW_ENTRY]: encoder.encode(`${JSON.stringify(artifact, null, 2)}\n`),
44
+ },
45
+ { level: 6, mtime: new Date(manifest.generatedAt) }
46
+ )
47
+ )
48
+ }
49
+
50
+ function decode(entry: Uint8Array | undefined, name: string, issues: string[]): unknown {
51
+ if (entry === undefined) {
52
+ issues.push(`${name} is missing from the zip`)
53
+ return null
54
+ }
55
+ try {
56
+ return JSON.parse(new TextDecoder().decode(entry)) as unknown
57
+ } catch {
58
+ issues.push(`${name} is not valid JSON`)
59
+ return null
60
+ }
61
+ }
62
+
63
+ /**
64
+ * The two entries of the zip, both schema-checked. Names with a directory part are ignored rather
65
+ * than written anywhere, so a zip carrying `../../etc/passwd` reads as a zip missing its entries.
66
+ */
67
+ export function readCanvasZip(bytes: Uint8Array): CanvasZipContents {
68
+ if (bytes.length > CANVAS_ZIP_MAX_BYTES) {
69
+ throw new CanvasZipError('CANVAS_TOO_LARGE', `the canvas zip is larger than ${CANVAS_ZIP_MAX_BYTES} bytes`)
70
+ }
71
+ if (!hasZipMagic(bytes)) {
72
+ throw new CanvasZipError('CANVAS_INVALID', 'this file is not a zip', [
73
+ 'the file does not start with the zip signature',
74
+ ])
75
+ }
76
+ let entries: Record<string, Uint8Array>
77
+ // Only the first copy of each of the two root entries is inflated, and the two together may
78
+ // not claim more than the cap, so a small archive cannot inflate into gigabytes.
79
+ const taken = new Set<string>()
80
+ let claimed = 0
81
+ try {
82
+ entries = unzipSync(bytes, {
83
+ filter: file => {
84
+ if (file.name !== MANIFEST_ENTRY && file.name !== REVIEW_ENTRY) {
85
+ return false
86
+ }
87
+ if (taken.has(file.name) || claimed + file.originalSize > CANVAS_ZIP_MAX_BYTES) {
88
+ return false
89
+ }
90
+ taken.add(file.name)
91
+ claimed += file.originalSize
92
+ return true
93
+ },
94
+ })
95
+ } catch (err) {
96
+ throw new CanvasZipError('CANVAS_INVALID', 'the zip could not be read', [
97
+ err instanceof Error ? err.message : String(err),
98
+ ])
99
+ }
100
+ const issues: string[] = []
101
+ const rawManifest = decode(entries[MANIFEST_ENTRY], MANIFEST_ENTRY, issues)
102
+ const rawArtifact = decode(entries[REVIEW_ENTRY], REVIEW_ENTRY, issues)
103
+ if (issues.length > 0) {
104
+ throw new CanvasZipError('CANVAS_INVALID', 'the zip is not a review canvas', issues)
105
+ }
106
+ const manifest = CanvasManifestSchema.safeParse(rawManifest)
107
+ const artifact = ReviewArtifactSchema.safeParse(rawArtifact)
108
+ if (!manifest.success) {
109
+ issues.push(...manifest.error.issues.map(i => `${MANIFEST_ENTRY}: ${i.path.join('.')} ${i.message}`))
110
+ }
111
+ if (!artifact.success) {
112
+ issues.push(...artifact.error.issues.map(i => `${REVIEW_ENTRY}: ${i.path.join('.')} ${i.message}`))
113
+ }
114
+ if (!(manifest.success && artifact.success)) {
115
+ throw new CanvasZipError('CANVAS_INVALID', 'the zip is not a review canvas', issues)
116
+ }
117
+ if (artifact.data.pr.headSha !== manifest.data.headSha) {
118
+ throw new CanvasZipError('CANVAS_INVALID', 'the zip is not a review canvas', [
119
+ `${REVIEW_ENTRY} is for ${artifact.data.pr.headSha.slice(0, 7)} while ${MANIFEST_ENTRY} says ${manifest.data.headSha.slice(0, 7)}`,
120
+ ])
121
+ }
122
+ return { manifest: manifest.data, artifact: artifact.data }
123
+ }
@@ -0,0 +1,389 @@
1
+ // One turn at a time per pull request: build the prompt, run the agent, stream what it says,
2
+ // and keep the transcript. The lock is what makes `CHAT_BUSY` a real answer rather than two
3
+ // agents writing into one thread.
4
+ import type { AgentRunner } from '../acpx/acpx.js'
5
+ import type { ChatContext, ChatEvent, ChatThreadsResponse, ChatTurn } from '../contract/chat.js'
6
+ import type { FileEntry, Repo, ReviewArtifact } from '../contract/review-artifact.js'
7
+ import type { Settings, SettingsOverrides } from '../contract/settings.js'
8
+ import type { ChatThread } from '../contract/state.js'
9
+ import type { SettingsStore } from '../store/settings-store.js'
10
+ import type { StateStore } from '../store/state-store.js'
11
+ import { type ContextSources, renderChatContext } from './context.js'
12
+ import { renderSeed, type SeedPaths } from './seed.js'
13
+ import { NEW_THREAD_TITLE, nextThreadIndex, type TranscriptStore, threadName, threadTitle } from './threads.js'
14
+
15
+ export class ChatBusyError extends Error {
16
+ constructor() {
17
+ super('a chat turn is already running for this pull request')
18
+ this.name = 'ChatBusyError'
19
+ }
20
+ }
21
+
22
+ export interface ChatManagerDeps {
23
+ runner: AgentRunner
24
+ settings: SettingsStore
25
+ state: StateStore
26
+ transcripts: TranscriptStore
27
+ repo: Repo
28
+ repoRoot: string
29
+ /** `serve --agent/--model`, which win over the settings file. */
30
+ overrides: SettingsOverrides
31
+ loadSeedTemplate: () => Promise<string>
32
+ now: () => Date
33
+ }
34
+
35
+ /** Everything about the pull request one turn needs, resolved by the route. */
36
+ export interface ChatTarget {
37
+ prNumber: number
38
+ headSha: string
39
+ artifact: ReviewArtifact
40
+ files: FileEntry[]
41
+ patches: Record<string, string>
42
+ /** `<canvas>/derived`, which holds head/, base/ and patches/. */
43
+ derivedDir: string
44
+ readLines: ContextSources['readLines']
45
+ }
46
+
47
+ export interface ChatSendInput {
48
+ message: string
49
+ context: ChatContext
50
+ thread?: string | undefined
51
+ }
52
+
53
+ export interface ChatManager {
54
+ effectiveSettings(): Promise<Settings>
55
+ threads(prNumber: number): Promise<ChatThreadsResponse>
56
+ createThread(prNumber: number): Promise<ChatThread>
57
+ selectThread(prNumber: number, name: string): Promise<ChatThread | null>
58
+ send(target: ChatTarget, input: ChatSendInput): AsyncIterable<ChatEvent>
59
+ /** True when a turn was running and has been asked to stop. */
60
+ cancel(prNumber: number): Promise<boolean>
61
+ busy(prNumber: number): boolean
62
+ }
63
+
64
+ /** The slot a turn holds while it runs. The handle is filled in once the agent has started. */
65
+ interface RunningTurn {
66
+ run: { cancel: () => Promise<void> } | null
67
+ /** True once a stop arrived, which can be before the agent has started. */
68
+ stopped: boolean
69
+ }
70
+
71
+ export function createChatManager(deps: ChatManagerDeps): ChatManager {
72
+ const running = new Map<number, RunningTurn>()
73
+
74
+ const effectiveSettings = async (): Promise<Settings> => {
75
+ const saved = await deps.settings.read()
76
+ return {
77
+ ...saved,
78
+ ...(deps.overrides.agent === undefined ? {} : { agent: deps.overrides.agent }),
79
+ ...(deps.overrides.model === undefined ? {} : { model: deps.overrides.model }),
80
+ }
81
+ }
82
+
83
+ const newThread = async (prNumber: number, agent: string): Promise<ChatThread> => {
84
+ const at = deps.now().toISOString()
85
+ let created: ChatThread | null = null
86
+ await deps.state.update(prNumber, state => {
87
+ const thread: ChatThread = {
88
+ name: threadName(deps.repo, prNumber, agent, nextThreadIndex(state.chat.threads)),
89
+ agent,
90
+ rev: 0,
91
+ title: NEW_THREAD_TITLE,
92
+ createdAt: at,
93
+ seededHeadSha: '',
94
+ }
95
+ created = thread
96
+ return { ...state, chat: { threads: [...state.chat.threads, thread], activeThread: thread.name } }
97
+ })
98
+ if (created === null) {
99
+ throw new Error('the thread was not created')
100
+ }
101
+ return created
102
+ }
103
+
104
+ /** The thread a message goes to: the one asked for, the active one, or a fresh one. */
105
+ const resolveThread = async (prNumber: number, agent: string, wanted?: string): Promise<ChatThread> => {
106
+ const state = await deps.state.read(prNumber)
107
+ const byName = wanted === undefined ? undefined : state.chat.threads.find(t => t.name === wanted)
108
+ const active =
109
+ byName ??
110
+ (state.chat.activeThread === undefined
111
+ ? undefined
112
+ : state.chat.threads.find(t => t.name === state.chat.activeThread))
113
+ // Changing the agent starts a new thread: the old session belongs to the old agent.
114
+ if (active === undefined || active.agent !== agent) {
115
+ return newThread(prNumber, agent)
116
+ }
117
+ if (state.chat.activeThread !== active.name) {
118
+ await deps.state.update(prNumber, current => ({
119
+ ...current,
120
+ chat: { ...current.chat, activeThread: active.name },
121
+ }))
122
+ }
123
+ return active
124
+ }
125
+
126
+ /**
127
+ * Writes the answer down. A turn the agent never took leaves the thread as it was, so the next
128
+ * try creates the session again and sends the seed again.
129
+ */
130
+ const saveTurn = async (
131
+ prNumber: number,
132
+ thread: ChatThread,
133
+ userText: string,
134
+ assistant: ChatTurn,
135
+ seededHeadSha: string,
136
+ reached: boolean
137
+ ): Promise<void> => {
138
+ await deps.transcripts.append(prNumber, thread.name, assistant)
139
+ if (!reached) {
140
+ return
141
+ }
142
+ await deps.state.update(prNumber, state => ({
143
+ ...state,
144
+ chat: {
145
+ ...state.chat,
146
+ threads: state.chat.threads.map(t =>
147
+ t.name === thread.name
148
+ ? {
149
+ ...t,
150
+ rev: t.rev + 1,
151
+ seededHeadSha,
152
+ // The first turn of a thread gives it its title.
153
+ title: t.rev === 0 ? threadTitle(userText) : t.title,
154
+ }
155
+ : t
156
+ ),
157
+ },
158
+ }))
159
+ }
160
+
161
+ /**
162
+ * Clears the mark that says this thread has been seeded. The agent lost the acpx session behind
163
+ * it, so the next turn creates the session again and sends the seed with it. The revision keeps
164
+ * counting the turns the thread has taken, which is what its title is keyed on.
165
+ */
166
+ const forgetSession = async (prNumber: number, name: string): Promise<void> => {
167
+ await deps.state.update(prNumber, state => ({
168
+ ...state,
169
+ chat: {
170
+ ...state.chat,
171
+ threads: state.chat.threads.map(t => (t.name === name ? { ...t, seededHeadSha: '' } : t)),
172
+ },
173
+ }))
174
+ }
175
+
176
+ async function* send(target: ChatTarget, input: ChatSendInput): AsyncIterable<ChatEvent> {
177
+ if (running.has(target.prNumber)) {
178
+ throw new ChatBusyError()
179
+ }
180
+ // The slot is taken before the first await, so a second request that arrives while this one
181
+ // is still reading the settings sees a busy chat rather than starting a second agent.
182
+ const slot: RunningTurn = { run: null, stopped: false }
183
+ running.set(target.prNumber, slot)
184
+ try {
185
+ yield* runTurn(target, input, slot)
186
+ } finally {
187
+ running.delete(target.prNumber)
188
+ }
189
+ }
190
+
191
+ async function* runTurn(target: ChatTarget, input: ChatSendInput, slot: RunningTurn): AsyncIterable<ChatEvent> {
192
+ const settings = await effectiveSettings()
193
+ const thread = await resolveThread(target.prNumber, settings.agent, input.thread)
194
+ const seeded = thread.seededHeadSha !== target.headSha
195
+ const at = deps.now().toISOString()
196
+ const contextBlock = await renderChatContext(input.context, {
197
+ artifact: target.artifact,
198
+ files: target.files,
199
+ patches: target.patches,
200
+ readLines: target.readLines,
201
+ })
202
+ const seed = seeded
203
+ ? `${renderSeed(await deps.loadSeedTemplate(), target.artifact, seedPaths(deps, target))}\n\n`
204
+ : ''
205
+ const prompt = `${seed}${contextBlock}\n\n## Question\n\n${input.message}\n`
206
+
207
+ // A thread that has never been seeded has no acpx session behind it either.
208
+ if (thread.seededHeadSha === '') {
209
+ await deps.runner.ensureSession({
210
+ agent: settings.agent,
211
+ session: thread.name,
212
+ cwd: deps.repoRoot,
213
+ timeoutSec: settings.chatTimeoutSec,
214
+ })
215
+ }
216
+
217
+ await deps.transcripts.append(target.prNumber, thread.name, {
218
+ role: 'user',
219
+ text: input.message,
220
+ at,
221
+ context: input.context,
222
+ })
223
+
224
+ // A stop that arrived while the turn was setting up means no agent is started at all. The
225
+ // transcript is written before the events, so a reader who leaves now still finds it there.
226
+ if (slot.stopped) {
227
+ await saveTurn(
228
+ target.prNumber,
229
+ thread,
230
+ input.message,
231
+ { role: 'assistant', text: '', at: deps.now().toISOString(), incomplete: 'cancelled' },
232
+ target.headSha,
233
+ // The seed never went anywhere, so the thread stays where it was.
234
+ false
235
+ )
236
+ yield { event: 'turn', thread: thread.name, agent: settings.agent, seeded }
237
+ yield { event: 'cancelled' }
238
+ return
239
+ }
240
+
241
+ const run = deps.runner.run({
242
+ agent: settings.agent,
243
+ session: thread.name,
244
+ prompt,
245
+ cwd: deps.repoRoot,
246
+ timeoutSec: settings.chatTimeoutSec,
247
+ model: settings.model ?? undefined,
248
+ maxTurns: settings.maxTurns ?? undefined,
249
+ // The runner scrubs the line before it gets here; this only writes it down.
250
+ onRawLine: line => {
251
+ void deps.transcripts.appendEvent(target.prNumber, thread.name, line).catch(() => undefined)
252
+ },
253
+ })
254
+ slot.run = run
255
+ // A stop between the spawn above and the loop below reaches the agent through its handle.
256
+ if (slot.stopped) {
257
+ void run.cancel().catch(() => undefined)
258
+ }
259
+
260
+ let answer = ''
261
+ let incomplete: string | undefined
262
+ let ended = false
263
+ try {
264
+ yield { event: 'turn', thread: thread.name, agent: settings.agent, seeded }
265
+ for await (const event of run.events) {
266
+ switch (event.type) {
267
+ case 'chunk':
268
+ answer += event.text
269
+ yield { event: 'chunk', text: event.text }
270
+ break
271
+ case 'thought':
272
+ yield { event: 'thought', text: event.text }
273
+ break
274
+ case 'tool':
275
+ yield { event: 'tool', id: event.id, title: event.title, status: event.status }
276
+ break
277
+ case 'done':
278
+ if (event.stopReason === 'cancelled') {
279
+ incomplete = 'cancelled'
280
+ yield { event: 'cancelled' }
281
+ } else if (event.stopReason === 'end_turn') {
282
+ yield { event: 'done', stopReason: event.stopReason }
283
+ } else {
284
+ incomplete = event.stopReason
285
+ yield {
286
+ event: 'error',
287
+ code: 'AGENT_INCOMPLETE',
288
+ message: `the agent stopped early (${event.stopReason})`,
289
+ }
290
+ }
291
+ break
292
+ case 'error':
293
+ incomplete = event.code
294
+ yield { event: 'error', code: event.code, message: event.message, ...hintFor(event.code) }
295
+ break
296
+ default:
297
+ break
298
+ }
299
+ }
300
+ ended = true
301
+ } finally {
302
+ // A reader that went away leaves the agent running, so the turn is stopped with it, and
303
+ // what it had said by then is a partial answer rather than the whole one.
304
+ if (!ended) {
305
+ incomplete ??= 'cancelled'
306
+ await run.cancel().catch(() => undefined)
307
+ }
308
+ await saveTurn(
309
+ target.prNumber,
310
+ thread,
311
+ input.message,
312
+ {
313
+ role: 'assistant',
314
+ text: answer,
315
+ at: deps.now().toISOString(),
316
+ ...(incomplete === undefined ? {} : { incomplete }),
317
+ },
318
+ target.headSha,
319
+ // An error before the agent said anything means the turn never reached it.
320
+ answer !== '' || incomplete === undefined || incomplete === 'cancelled'
321
+ )
322
+ if (incomplete === 'AGENT_NO_SESSION') {
323
+ await forgetSession(target.prNumber, thread.name)
324
+ }
325
+ }
326
+ }
327
+
328
+ return {
329
+ effectiveSettings,
330
+ async threads(prNumber) {
331
+ const [state, settings] = await Promise.all([deps.state.read(prNumber), effectiveSettings()])
332
+ return {
333
+ threads: state.chat.threads.map(t => ({
334
+ name: t.name,
335
+ agent: t.agent,
336
+ title: t.title,
337
+ createdAt: t.createdAt,
338
+ })),
339
+ activeThread: state.chat.activeThread ?? null,
340
+ agent: settings.agent,
341
+ }
342
+ },
343
+ async createThread(prNumber) {
344
+ const settings = await effectiveSettings()
345
+ return newThread(prNumber, settings.agent)
346
+ },
347
+ async selectThread(prNumber, name) {
348
+ const state = await deps.state.read(prNumber)
349
+ const thread = state.chat.threads.find(t => t.name === name)
350
+ if (thread === undefined) {
351
+ return null
352
+ }
353
+ await deps.state.update(prNumber, current => ({ ...current, chat: { ...current.chat, activeThread: name } }))
354
+ return thread
355
+ },
356
+ send,
357
+ async cancel(prNumber) {
358
+ const slot = running.get(prNumber)
359
+ if (slot === undefined) {
360
+ return false
361
+ }
362
+ // A turn whose agent has not started yet is stopped by the flag: `runTurn` reads it the
363
+ // moment it has a handle, so the stop is never lost to the setup it arrived during.
364
+ slot.stopped = true
365
+ await slot.run?.cancel()
366
+ return true
367
+ },
368
+ busy: prNumber => running.has(prNumber),
369
+ }
370
+ }
371
+
372
+ function seedPaths(deps: ChatManagerDeps, target: ChatTarget): SeedPaths {
373
+ return {
374
+ headDir: `${target.derivedDir}/head`,
375
+ baseDir: `${target.derivedDir}/base`,
376
+ patchDir: `${target.derivedDir}/patches`,
377
+ repoRoot: deps.repoRoot,
378
+ }
379
+ }
380
+
381
+ function hintFor(code: string): { hint?: string } {
382
+ if (code === 'AGENT_AUTH_REQUIRED') {
383
+ return { hint: 'log the agent in from a terminal, then try again' }
384
+ }
385
+ if (code === 'AGENT_MISSING') {
386
+ return { hint: 'install acpx and the agent CLI, then reload' }
387
+ }
388
+ return {}
389
+ }
@@ -0,0 +1,160 @@
1
+ // The block that goes above the reader's message: what they pointed at, in the agent's terms.
2
+ // Every value is checked against the canvas first, so a made-up path never reaches a file read.
3
+ import type { ChatContext } from '../contract/chat.js'
4
+ import { chatContextLabel } from '../contract/chat.js'
5
+ import type { FileEntry, Hunk, Layer, ReviewArtifact } from '../contract/review-artifact.js'
6
+
7
+ /** A file's patch goes into the message up to this many lines; past it the agent reads the file. */
8
+ export const INLINE_PATCH_MAX_LINES = 400
9
+ /** A line range the reader can select and the agent can read in one block. */
10
+ export const LINE_RANGE_MAX = 400
11
+
12
+ export class ChatContextError extends Error {
13
+ constructor(message: string) {
14
+ super(message)
15
+ this.name = 'ChatContextError'
16
+ }
17
+ }
18
+
19
+ export interface ContextSources {
20
+ artifact: ReviewArtifact
21
+ files: readonly FileEntry[]
22
+ patches: Record<string, string>
23
+ /** Lines of a materialized file, or null when this head has none. */
24
+ readLines: (side: 'head' | 'base', path: string, from: number, to: number) => Promise<string[] | null>
25
+ }
26
+
27
+ function layerById(artifact: ReviewArtifact, id: string): Layer {
28
+ const layer = artifact.layers.find(l => l.id === id)
29
+ if (layer === undefined) {
30
+ throw new ChatContextError(`this canvas has no layer ${id}`)
31
+ }
32
+ return layer
33
+ }
34
+
35
+ function fileByPath(sources: ContextSources, path: string): FileEntry {
36
+ const entry = sources.files.find(f => f.path === path)
37
+ if (entry === undefined) {
38
+ throw new ChatContextError(`${path} is not a file of this pull request`)
39
+ }
40
+ return entry
41
+ }
42
+
43
+ /** The hunk a line sits in, on the side the reader selected. */
44
+ export function enclosingHunk(entry: FileEntry, side: 'new' | 'old', line: number): Hunk | undefined {
45
+ return entry.hunks.find(h => {
46
+ const start = side === 'new' ? h.newStart : h.oldStart
47
+ // A hunk that adds lines shows none on the old side, and the other way round.
48
+ const count = side === 'new' ? h.newLines : h.oldLines
49
+ return count > 0 && line >= start && line < start + count
50
+ })
51
+ }
52
+
53
+ function fence(body: string, lang = ''): string {
54
+ return `\`\`\`${lang}\n${body}\n\`\`\``
55
+ }
56
+
57
+ function patchOf(sources: ContextSources, entry: FileEntry): string | null {
58
+ const patch = sources.patches[entry.key]
59
+ if (patch === undefined || patch === '') {
60
+ return null
61
+ }
62
+ return patch.split('\n').length > INLINE_PATCH_MAX_LINES ? null : patch
63
+ }
64
+
65
+ /**
66
+ * The context block for one message. The label is the same text the chip on screen shows, so the
67
+ * reader and the agent are talking about the same thing.
68
+ */
69
+ export async function renderChatContext(context: ChatContext, sources: ContextSources): Promise<string> {
70
+ const label = chatContextLabel(
71
+ context,
72
+ id => sources.artifact.layers.find(l => l.id === id)?.title,
73
+ fp => sources.artifact.points.find(p => p.fingerprint === fp)?.title
74
+ )
75
+ switch (context.kind) {
76
+ case 'pr':
77
+ return '## Context: the whole pull request\n\nThe reader is asking about the pull request as a whole.'
78
+ case 'layer': {
79
+ const layer = layerById(sources.artifact, context.layerId)
80
+ const files = layer.files.map(f => `- \`${f.path}\` (${f.hunks.join(', ')})`).join('\n')
81
+ return [`## Context: ${label}`, '', layer.rationale, '', files === '' ? '_no files_' : files].join('\n')
82
+ }
83
+ case 'file': {
84
+ const entry = fileByPath(sources, context.path)
85
+ const patch = patchOf(sources, entry)
86
+ const body =
87
+ patch === null
88
+ ? `The patch is too long to inline. Read the file at the materialized head path for \`${entry.path}\`.`
89
+ : fence(patch, 'diff')
90
+ return [
91
+ `## Context: \`${entry.path}\``,
92
+ '',
93
+ `${entry.status}, +${entry.additions} −${entry.deletions}`,
94
+ '',
95
+ body,
96
+ ].join('\n')
97
+ }
98
+ case 'lines':
99
+ return renderLines(context, sources, label)
100
+ case 'point':
101
+ return renderPoint(context, sources)
102
+ }
103
+ }
104
+
105
+ /**
106
+ * An attention point: what it says, plus the lines it sits on. The agent needs the text to know
107
+ * what the reader is reacting to; the lines alone leave it guessing. One context, one heading.
108
+ */
109
+ async function renderPoint(context: Extract<ChatContext, { kind: 'point' }>, sources: ContextSources): Promise<string> {
110
+ const point = sources.artifact.points.find(p => p.fingerprint === context.fingerprint)
111
+ if (point === undefined) {
112
+ throw new ChatContextError('this canvas has no such attention point')
113
+ }
114
+ const at: Extract<ChatContext, { kind: 'lines' }> = {
115
+ kind: 'lines',
116
+ path: point.path,
117
+ side: point.side === 'old' ? 'old' : 'new',
118
+ start: point.line,
119
+ end: point.endLine ?? point.line,
120
+ }
121
+ return [
122
+ `## Context: attention point — ${point.title}`,
123
+ '',
124
+ `${point.kind} · ${point.level}`,
125
+ '',
126
+ point.body,
127
+ '',
128
+ `It sits on ${chatContextLabel(at)}:`,
129
+ '',
130
+ await linesBody(at, sources),
131
+ ].join('\n')
132
+ }
133
+
134
+ async function renderLines(
135
+ context: Extract<ChatContext, { kind: 'lines' }>,
136
+ sources: ContextSources,
137
+ label: string
138
+ ): Promise<string> {
139
+ return `## Context: ${label}\n\n${await linesBody(context, sources)}`
140
+ }
141
+
142
+ /** The quoted lines and the hunk around them, with no heading of their own. */
143
+ async function linesBody(context: Extract<ChatContext, { kind: 'lines' }>, sources: ContextSources): Promise<string> {
144
+ if (context.end < context.start) {
145
+ throw new ChatContextError('the last line of a selection comes after the first')
146
+ }
147
+ if (context.end - context.start + 1 > LINE_RANGE_MAX) {
148
+ throw new ChatContextError(`select at most ${LINE_RANGE_MAX} lines`)
149
+ }
150
+ const entry = fileByPath(sources, context.path)
151
+ const side = context.side === 'new' ? 'head' : 'base'
152
+ const lines = await sources.readLines(side, entry.path, context.start, context.end)
153
+ const hunk = enclosingHunk(entry, context.side, context.start)
154
+ const numbered =
155
+ lines === null || lines.length === 0
156
+ ? '_these lines are not available locally_'
157
+ : fence(lines.map((text, i) => `${context.start + i}: ${text}`).join('\n'))
158
+ const hunkBlock = hunk === undefined ? '' : `\n\nThe hunk around them is \`${hunk.id}\` (\`${hunk.header}\`).`
159
+ return `${numbered}${hunkBlock}`
160
+ }