@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.
- package/LICENSE +202 -0
- package/README.md +192 -0
- package/bin/pr-review.mjs +5 -0
- package/docs/reference.md +340 -0
- package/package.json +74 -0
- package/pr-review.config.example.yml +68 -0
- package/prompts/chat-seed.md +64 -0
- package/prompts/generation-format.md +255 -0
- package/prompts/generation-strict.md +34 -0
- package/prompts/generation-surfacing.md +67 -0
- package/prompts/layers-default.md +13 -0
- package/prompts/quality-standards.md +32 -0
- package/skills/pr-review-canvas/SKILL.md +177 -0
- package/src/acpx/acpx.ts +530 -0
- package/src/acpx/agents.ts +85 -0
- package/src/acpx/events.ts +216 -0
- package/src/acpx/ndjson.ts +69 -0
- package/src/acpx/preflight.ts +44 -0
- package/src/canvas/export.ts +95 -0
- package/src/canvas/import.ts +138 -0
- package/src/canvas/name.ts +55 -0
- package/src/canvas/zip.ts +123 -0
- package/src/chat/chat-manager.ts +389 -0
- package/src/chat/context.ts +160 -0
- package/src/chat/seed.ts +71 -0
- package/src/chat/threads.ts +114 -0
- package/src/cli.ts +199 -0
- package/src/commands.ts +424 -0
- package/src/config.ts +142 -0
- package/src/contract/api.ts +190 -0
- package/src/contract/canvas-manifest.ts +29 -0
- package/src/contract/chat.ts +76 -0
- package/src/contract/comments.ts +96 -0
- package/src/contract/discovery.ts +20 -0
- package/src/contract/generation-context.ts +77 -0
- package/src/contract/keys.ts +14 -0
- package/src/contract/links.ts +5 -0
- package/src/contract/mermaid-fences.ts +4 -0
- package/src/contract/review-artifact.ts +324 -0
- package/src/contract/settings.ts +144 -0
- package/src/contract/state.ts +46 -0
- package/src/contract/validation.ts +43 -0
- package/src/git/diff-collector.ts +151 -0
- package/src/git/git.ts +115 -0
- package/src/git/lang.ts +1 -0
- package/src/git/materialize.ts +79 -0
- package/src/git/patch-lines.ts +60 -0
- package/src/github/attachments.ts +288 -0
- package/src/github/capabilities.ts +112 -0
- package/src/github/comments.ts +132 -0
- package/src/github/gh.ts +196 -0
- package/src/github/post-comment.ts +104 -0
- package/src/github/post-review.ts +44 -0
- package/src/github/pr.ts +133 -0
- package/src/github/review-body.ts +72 -0
- package/src/github/threads.ts +63 -0
- package/src/paths.ts +10 -0
- package/src/project-config.ts +219 -0
- package/src/prompt-files.ts +26 -0
- package/src/review/diagram-nodes.ts +227 -0
- package/src/review/doctor.ts +139 -0
- package/src/review/glob.ts +33 -0
- package/src/review/install-skill.ts +107 -0
- package/src/review/normalize.ts +209 -0
- package/src/review/prepare.ts +165 -0
- package/src/review/prompt.ts +233 -0
- package/src/review/publish.ts +209 -0
- package/src/review/skill-command.ts +4 -0
- package/src/review/test-paths.ts +32 -0
- package/src/review/text-length.ts +15 -0
- package/src/review/trim-caps.ts +114 -0
- package/src/review/validate-folds.ts +110 -0
- package/src/review/validate.ts +520 -0
- package/src/server/app.ts +46 -0
- package/src/server/bundle.ts +266 -0
- package/src/server/capped-body.ts +62 -0
- package/src/server/context.ts +174 -0
- package/src/server/env.ts +7 -0
- package/src/server/errors.ts +65 -0
- package/src/server/html.ts +140 -0
- package/src/server/node-server.ts +42 -0
- package/src/server/routes/api.ts +256 -0
- package/src/server/routes/chat-routes.ts +221 -0
- package/src/server/routes/pages.ts +64 -0
- package/src/server/routes/review-routes.ts +245 -0
- package/src/server/routes/static.ts +114 -0
- package/src/server/security.ts +104 -0
- package/src/server/sse.ts +67 -0
- package/src/store/atomic-json.ts +68 -0
- package/src/store/canvas-store.ts +120 -0
- package/src/store/data-dir.ts +29 -0
- package/src/store/derived-store.ts +93 -0
- package/src/store/pr-store.ts +69 -0
- package/src/store/settings-store.ts +152 -0
- package/src/store/state-store.ts +121 -0
- package/static/js/anchors.js +141 -0
- package/static/js/api.js +542 -0
- package/static/js/app.js +418 -0
- package/static/js/ask.js +35 -0
- package/static/js/chat-context.js +137 -0
- package/static/js/chat-scroll.js +114 -0
- package/static/js/chat.js +843 -0
- package/static/js/code-folds.js +200 -0
- package/static/js/commands.js +110 -0
- package/static/js/comment-link.js +37 -0
- package/static/js/composer.js +241 -0
- package/static/js/contract-types.d.ts +59 -0
- package/static/js/deep-link.js +160 -0
- package/static/js/diagram.js +582 -0
- package/static/js/diff-decorations.js +204 -0
- package/static/js/diff-renderer.js +860 -0
- package/static/js/dom.js +145 -0
- package/static/js/download.js +52 -0
- package/static/js/empty-state.js +161 -0
- package/static/js/errors.js +135 -0
- package/static/js/fences.js +90 -0
- package/static/js/header.js +134 -0
- package/static/js/hunks.js +62 -0
- package/static/js/import-zone.js +95 -0
- package/static/js/interactions.js +952 -0
- package/static/js/keyboard.js +131 -0
- package/static/js/keys.js +97 -0
- package/static/js/lang.js +54 -0
- package/static/js/layers.js +596 -0
- package/static/js/links.js +150 -0
- package/static/js/markdown.js +232 -0
- package/static/js/mermaid-fences.js +55 -0
- package/static/js/nav.js +91 -0
- package/static/js/overview.js +85 -0
- package/static/js/points.js +247 -0
- package/static/js/progress.js +49 -0
- package/static/js/proposed-comment.js +133 -0
- package/static/js/quick-questions.js +216 -0
- package/static/js/regenerate.js +69 -0
- package/static/js/review-session.js +257 -0
- package/static/js/scroll-spy.js +66 -0
- package/static/js/selection.js +193 -0
- package/static/js/settings.js +206 -0
- package/static/js/signoff.js +171 -0
- package/static/js/skin.js +56 -0
- package/static/js/store.js +35 -0
- package/static/js/theme.js +56 -0
- package/static/js/threads.js +78 -0
- package/static/js/vendor.d.ts +15 -0
- package/static/styles/base.css +223 -0
- package/static/styles/chat-tools.css +130 -0
- package/static/styles/chat.css +140 -0
- package/static/styles/commands.css +156 -0
- package/static/styles/diff.css +258 -0
- package/static/styles/header.css +114 -0
- package/static/styles/layout.css +123 -0
- package/static/styles/panels.css +152 -0
- package/static/styles/responsive.css +80 -0
- package/static/styles/review-actions.css +124 -0
- package/static/styles/review.css +473 -0
- package/static/styles/skin-github.css +356 -0
- 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
|
+
}
|