fix(forge-session): bind numeric PR references with explicit repositories

This commit is contained in:
Yichen Jiang committed 2026-10-26 16:10:51 +08:00
1 parent 28f9c98108
commit df4f3fd0c1
27 files changed
+646 -49

No files matched your search

@@ -0,0 +1,12 @@
- listitem:
- img "变更请求状态:需要处理"
- link "Add the pull request panel":
- /url: https://github.com/octo/demo/pull/7
- text: "#7 feat → main"
- button "在浏览器中打开"
- button "复制变更请求链接"
- 'button "从本会话移除 #7"'
- 'button "收起 #7" [expanded]'
- button "检查 1 项失败 · 1 项通过"
- text: 评审 等待评审 · 1 条未解决讨论 合并 无冲突
- 'button "查看 #7 的变更:新增 128 行,删除 14 行"': 变更 +128 −14
+47 -5
View File
@@ -51,6 +51,13 @@ appendFileSync(join(here, 'calls.jsonl'), JSON.stringify(args) + '\\n')
const statePath = join(here, 'state.json')
const state = existsSync(statePath) ? JSON.parse(readFileSync(statePath, 'utf8')) : { ready: false, mergedAt: null }
const save = () => { writeFileSync(statePath, JSON.stringify(state)) }
if (args[0] === 'pr' && args[1] === 'view' && args[2] === '7') {
const fields = args[args.indexOf('--json') + 1]
process.stdout.write(JSON.stringify(fields === 'url'
? { url: 'https://github.com/octo/demo/pull/7' }
: { number: 7, title: 'Add the pull request panel' }))
process.exit(0)
}
if (args[0] === 'pr' && args[1] === 'ready' && args[2] === '9') {
state.ready = true
save()
@@ -191,10 +198,10 @@ interface PanelLaunch {
/**
* Launch the bundle against a GitHub checkout of `main` and `release` and open the New Session screen of that workspace.
* @param failureShot - name of the screenshot saved when the test fails.
* @param followUp - whether the replay answers a second model call with {@link FOLLOW_UP_REPLY}.
* @param scenario - normal conversation, a restoration follow-up, or a numbered PR lookup.
* @returns the scaffold, page, and checkout.
*/
async function launchPanel(failureShot: string, followUp = false): Promise<PanelLaunch> {
async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up' | 'reference' = 'normal'): Promise<PanelLaunch> {
const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
const root = await mkdtemp(join(tmpdir(), 'dsh-forge-panel-'))
onTestFinished(async () => {
@@ -215,12 +222,24 @@ async function launchPanel(failureShot: string, followUp = false): Promise<Panel
await writeFile(overlay, `- id: forge-github\n config: ${JSON.stringify({ ghCommand: gh })}\n`)
const override = join(root, 'replay.override.json')
// The borrowed scenario records one model call, so index 1 appends the reply to the follow-up.
if (followUp) await writeFile(override, JSON.stringify({ patches: [{ at: 1, entry: { kind: 'chunks', chunks: textReply(FOLLOW_UP_REPLY) } }] }))
if (scenario === 'follow-up') await writeFile(override, JSON.stringify({ patches: [{ at: 1, entry: { kind: 'chunks', chunks: textReply(FOLLOW_UP_REPLY) } }] }))
if (scenario === 'reference') {
const command = `PATH=${JSON.stringify(`${root}:${process.env.PATH ?? ''}`)} gh pr view 7 --repo octo/demo --json number,title 2>&1 | head -200`
const call = { type: 'tool-call', id: 'view-pr', name: 'bash', arguments: JSON.stringify({ command, description: 'View PR metadata' }) }
await writeFile(override, JSON.stringify({ patches: [
{ at: 0, entry: { kind: 'chunks', chunks: [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'block-end', index: 0, block: call },
{ type: 'finish', reason: { kind: 'tool-calls' } },
] } },
{ at: 1, entry: { kind: 'chunks', chunks: textReply('Viewed PR 7.') } },
] }))
}
const scaffold = await launchWebScaffold({
profile: { packages: [{ dir: BUNDLE, enabled: true }] },
extraOverlayPath: overlay,
replayFixture: FIXTURE,
...followUp ? { replayOverride: override } : {},
...scenario !== 'normal' ? { replayOverride: override } : {},
// The worktree notice, worktree records, and bindings are asserted separately from the borrowed conversation.
compareReplaySession: false,
})
@@ -267,7 +286,7 @@ function textReply(text: string): unknown[] {
* @returns the scaffold, page, and checkout.
*/
async function requestReleaseWorktree(failureShot: string, followUp = false): Promise<PanelLaunch> {
const launched = await launchPanel(failureShot, followUp)
const launched = await launchPanel(failureShot, followUp ? 'follow-up' : 'normal')
const { page } = launched
const option = page.locator('[data-forge-worktree]')
await option.waitFor({ timeout: 20_000 })
@@ -299,6 +318,29 @@ function worktreeRecords(events: readonly SessionEvent[]): unknown[] {
})
}
it.skipIf(MODE === 'record')('automatically shows a numbered PR when gh JSON omits its URL', async () => {
const { scaffold, page, root, tripwire } = await launchPanel('web-e2e-forge-numbered-reference', 'reference')
const { settled } = await sendPrompt(page, scaffold)
const sessionId = await settled
const header = page.locator('[data-forge-header]')
await expect.poll(() => header.getAttribute('aria-label'), { timeout: 20_000 }).toBe('本会话的变更请求需要处理')
await header.click()
const change = page.locator(`[data-forge-change="${PULL}"]`)
await change.getByRole('link', { name: 'Add the pull request panel', exact: true }).waitFor({ timeout: 20_000 })
await change.getByText('等待评审 · 1 条未解决讨论', { exact: true }).waitFor()
await compareOrRefreshGolden(join(EXPECTED, 'reference.expected.md'),
await captureStableAria(page, `[data-forge-change="${PULL}"]`, scaffold.workspaceCwd), MODE)
const events = await readPersistedEvents(scaffold, sessionId)
expect(events.flatMap((event) => {
const record = pluginRecordOf(event)
return record?.type === 'plugin:forge-session/binding' ? [record.data] : []
})).toEqual([{ action: 'reference', url: PULL }])
const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[])
expect(calls).toContainEqual(['pr', 'view', '7', '--repo', 'octo/demo', '--json', 'url'])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it.skipIf(MODE === 'record')('starts a Session in a worktree before its first message, follows its change request, and restores the removed worktree', async () => {
const { scaffold, page, root, project, tripwire } = await requestReleaseWorktree('web-e2e-forge-panel', true)
const { settled } = await sendPrompt(page, scaffold)
+1 -1
View File
@@ -1155,7 +1155,7 @@ export interface Config {
## `@deepseek-ai/dsh-experimental-forge-session`
- `inject`: `forge` · `sessionProjections`
- `source`: [`packages/experimental/forge-session/src/types.ts:88`](../packages/experimental/forge-session/src/types.ts)
- `source`: [`packages/experimental/forge-session/src/types.ts:91`](../packages/experimental/forge-session/src/types.ts)
```ts config-catalog
/** Deployment settings of `forgeSession`. */
+1 -1
View File
@@ -1157,7 +1157,7 @@ export interface Config {
## `@deepseek-ai/dsh-experimental-forge-session`
- `inject`: `forge` · `sessionProjections`
- `source`: [`packages/experimental/forge-session/src/types.ts:88`](../packages/experimental/forge-session/src/types.ts)
- `source`: [`packages/experimental/forge-session/src/types.ts:91`](../packages/experimental/forge-session/src/types.ts)
```ts config-catalog
/** Deployment settings of `forgeSession`. */
+1 -1
View File
@@ -83,7 +83,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
+1 -1
View File
@@ -85,7 +85,7 @@
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
+8 -4
View File
@@ -569,9 +569,6 @@ flowchart TD
pkg_session_log_export --> pkg_session_persistence
pkg_config_editor --> pkg_app_boot
pkg_config_editor --> pkg_hmr
pkg_experimental_forge_session --> pkg_experimental_forge
pkg_experimental_forge_session --> pkg_session
pkg_experimental_forge_session --> pkg_session_projection
pkg_experimental_speech_to_text --> pkg_settings
pkg_experimental_translator --> pkg_credentials
pkg_experimental_translator --> pkg_launch_environment
@@ -1130,6 +1127,13 @@ flowchart TD
pkg_experimental_auto_review --> pkg_session
pkg_experimental_auto_review --> pkg_tools
pkg_experimental_auto_review --> pkg_working_directory
pkg_experimental_forge_session --> pkg_experimental_forge
pkg_experimental_forge_session --> pkg_sandbox_policy
pkg_experimental_forge_session --> pkg_session
pkg_experimental_forge_session --> pkg_session_projection
pkg_experimental_forge_session --> pkg_shell
pkg_experimental_forge_session --> pkg_shell_env
pkg_experimental_forge_session --> pkg_tools
pkg_webhook --> pkg_agent
pkg_webhook --> pkg_agent_default_model
pkg_webhook --> pkg_agent_preset_registry
@@ -1690,7 +1694,6 @@ flowchart TD
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
| [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) |
| [`experimental-forge-session`](../packages/experimental/forge-session) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) |
| [`experimental-translator`](../packages/experimental/translator) | `experimental` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) |
| [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) |
@@ -1812,6 +1815,7 @@ flowchart TD
| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) |
| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env) |
| [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
| [`experimental-forge-session`](../packages/experimental/forge-session) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`tools`](../packages/core/tools) |
| [`webhook`](../packages/experimental/webhook) | `experimental` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) |
| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
+8 -4
View File
@@ -571,9 +571,6 @@ flowchart TD
pkg_session_log_export --> pkg_session_persistence
pkg_config_editor --> pkg_app_boot
pkg_config_editor --> pkg_hmr
pkg_experimental_forge_session --> pkg_experimental_forge
pkg_experimental_forge_session --> pkg_session
pkg_experimental_forge_session --> pkg_session_projection
pkg_experimental_speech_to_text --> pkg_settings
pkg_experimental_translator --> pkg_credentials
pkg_experimental_translator --> pkg_launch_environment
@@ -1132,6 +1129,13 @@ flowchart TD
pkg_experimental_auto_review --> pkg_session
pkg_experimental_auto_review --> pkg_tools
pkg_experimental_auto_review --> pkg_working_directory
pkg_experimental_forge_session --> pkg_experimental_forge
pkg_experimental_forge_session --> pkg_sandbox_policy
pkg_experimental_forge_session --> pkg_session
pkg_experimental_forge_session --> pkg_session_projection
pkg_experimental_forge_session --> pkg_shell
pkg_experimental_forge_session --> pkg_shell_env
pkg_experimental_forge_session --> pkg_tools
pkg_webhook --> pkg_agent
pkg_webhook --> pkg_agent_default_model
pkg_webhook --> pkg_agent_preset_registry
@@ -1692,7 +1696,6 @@ flowchart TD
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
| [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) |
| [`experimental-forge-session`](../packages/experimental/forge-session) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) |
| [`experimental-translator`](../packages/experimental/translator) | `experimental` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) |
| [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) |
@@ -1814,6 +1817,7 @@ flowchart TD
| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) |
| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env) |
| [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
| [`experimental-forge-session`](../packages/experimental/forge-session) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`tools`](../packages/core/tools) |
| [`webhook`](../packages/experimental/webhook) | `experimental` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) |
| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
+5 -2
View File
@@ -216,8 +216,11 @@ type BindingOrigin = 'created' | 'referenced' | 'discovered' | 'tracked'
```ts type-equiv
/** Payload of the `plugin:forge-session/binding` record. */
interface BindingRecord {
/** `track` binds or restores, `dismiss` hides, `discover` binds a change request found for a branch the Session pushed or works on. */
readonly action: 'track' | 'dismiss' | 'discover'
/**
* `track` binds or restores, `dismiss` hides, `discover` binds a branch lookup,
* and `reference` binds a CLI selector lookup without restoring dismissed entries.
*/
readonly action: 'track' | 'dismiss' | 'discover' | 'reference'
/** Web URL of the change request. */
readonly url: string
}
+5 -2
View File
@@ -216,8 +216,11 @@ type BindingOrigin = 'created' | 'referenced' | 'discovered' | 'tracked'
```ts type-equiv
/** Payload of the `plugin:forge-session/binding` record. */
interface BindingRecord {
/** `track` binds or restores, `dismiss` hides, `discover` binds a change request found for a branch the Session pushed or works on. */
readonly action: 'track' | 'dismiss' | 'discover'
/**
* `track` binds or restores, `dismiss` hides, `discover` binds a branch lookup,
* and `reference` binds a CLI selector lookup without restoring dismissed entries.
*/
readonly action: 'track' | 'dismiss' | 'discover' | 'reference'
/** Web URL of the change request. */
readonly url: string
}
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 2f8a621b991260a1
/deepseek-ai-dsh-experimental-forge-session/use-this-package:
en: 8f9cd15f34cd4836
zh: ccd89a61c42f7bb0
en: 62679d4aaeae5e2c
zh: 2f88937670fa64d0
/deepseek-ai-dsh-experimental-forge-session/understand-the-implementation:
en: ccc7ad69f5837528
zh: 369c08bf4f7e6e4d
@@ -30,8 +30,8 @@
en: 1bdaeb7d102c23d8
zh: 37a3f0bd58f68363
/deepseek-ai-dsh-experimental-forge-session/known-limitations-and-deferred-work:
en: 3136750ace3c3bf9
zh: d76a83eaf4de6fee
en: 6b3be538bd434258
zh: bb790733a0fbb114
/deepseek-ai-dsh-experimental-forge-session/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
@@ -36,7 +36,9 @@ Mount the service with the forge registry, a provider, and the Session projectio
`ctx.forgeSession.changeRequests(session)` returns the visible bindings in first-recorded order. Each entry carries the URL, its `origin` (`created`, `referenced`, `discovered`, or `tracked`), the provider reference or `null` when no provider serves the URL, and the latest polling result or `null`. `watch(session, listener)` is an effect on the calling fiber: while a Session has a watcher, every visible binding that a provider serves is polled, and listeners receive the complete list after each later binding change or polling result. `track(session, url)` binds a URL under the provider's canonical form, or restores a dismissed binding, and throws when no provider recognizes the URL. `dismiss(session, url)` hides a visible binding; other URLs leave the log unchanged. `discoverBranch(session, remote, branch)` runs the lookup that follows a push for a branch on the remote at `remote` and resolves once it has recorded its bindings or failed; a remote that no provider serves changes nothing.
A `bash` or `pwsh` call binds evidence only from its first result, and only when the tool execution itself succeeded; the command's exit status does not matter. A command containing `gh pr create` or `gh pr new` binds every `/pull/<number>` URL in its text output. A command containing `gh pr view`, `gh pr edit`, or `gh pr checkout` binds, as `referenced`, a `/pull/<number>` URL given as the subcommand's first argument and the URL its output prints for its own change request: gh's `url:` field or a URL alone on a line before the `--` line that starts a description, and JSON `url` members. Links in a description or in other fields do not bind, and a command that also contains `gh pr create` binds its URLs as `created`. A command containing `git push`, optionally with leading `-C`, `-c`, or long Git options, records each branch listed under a `To <remote>` line as created, updated, forced, or already up to date; rejected, deleted, tag, and non-branch refs are skipped. For each pushed branch whose remote a provider serves, the service runs `ctx.forge.findByHead` within `discoveryTimeoutMs` and appends a `discover` record for every returned change request that is not bound, including dismissed ones. Every push repeats the lookup; failures are logged as warnings. URLs that differ only in scheme, letter case, query, fragment, or trailing slashes name the same binding.
A `bash` or `pwsh` call binds evidence only from its first result, and only when the tool execution itself succeeded; the command's exit status does not matter. A command containing `gh pr create` or `gh pr new` binds every `/pull/<number>` URL in its text output. A command containing `gh pr view`, `gh pr edit`, or `gh pr checkout` binds, as `referenced`, a `/pull/<number>` URL selected by the command, or a numeric selector with an explicit repository and host, plus the URL its output prints for its own change request: gh's `url:` field or a URL alone on a line before the `--` line that starts a description, and JSON `url` members. Links in a description or in other fields do not bind, and a command that also contains `gh pr create` binds its URLs as `created`. A command containing `git push`, optionally with leading `-C`, `-c`, or long Git options, records each branch listed under a `To <remote>` line as created, updated, forced, or already up to date; rejected, deleted, tag, and non-branch refs are skipped. For each pushed branch whose remote a provider serves, the service runs `ctx.forge.findByHead` within `discoveryTimeoutMs` and appends a `discover` record for every returned change request that is not bound, including dismissed ones. Every push repeats the lookup; failures are logged as warnings. URLs that differ only in scheme, letter case, query, fragment, or trailing slashes name the same binding.
Numeric `gh pr view`, `edit`, and `checkout` selectors accept `--repo OWNER/REPO`, `--repo=OWNER/REPO`, and `-R OWNER/REPO` in either argument order. A repository containing its host, or a literal `GH_HOST` assignment, supplies a URL directly. For a single command, optionally piped through `head`, `tail`, or `cat`, a completed foreground Bash call without a host runs a read-only `gh pr view <number> --repo <repository> --json url` through the same shell service, cwd, literal environment assignments, managed environment, and standing sandbox policy. The CLI resolves its configured host. A successful lookup appends a `reference` binding record; failures leave bindings unchanged and log a warning. This live observer requires `tools`, `shell`, and `shellEnv`; projection-only compositions still recover recorded URLs. Queries share `discoveryTimeoutMs`, stop on caller cancellation, and are aborted and awaited on service disposal.
Binding actions are `plugin:forge-session/binding` records listed in the [experimental persistence catalog](../../../docs/experimental-persistence-catalog.md); fork and resume carry them with the shell events, so a fork starts with its parent's bindings. `discoveryTimeoutMs` is listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge-session).
@@ -84,7 +86,7 @@ None. Binding records are ignorable plugin records outside the model-visible con
<a id="known-limitations-and-deferred-work"></a>
- **Textual command recognition** — only `bash` and `pwsh` commands containing `gh pr create`, `gh pr new`, `gh pr view`, `gh pr edit`, `gh pr checkout`, or `git push` count, and only `/pull/<number>` URLs bind. A view, edit, or checkout that names its change request by number or branch binds only when the output prints its URL, which `gh pr checkout` never does. Scripts, aliases, `gh api` calls, other CLIs such as `glab`, and `git push --porcelain` output are not recognized; `track` binds such change requests.
- **Limited shell recognition** — Numeric selectors require a literal repository. Expansion, substitutions, multiline scripts, unknown options, aliases, and other CLIs are excluded. Unqualified repositories in compound commands or PowerShell require an output URL or `track`; the live resolver cannot recover their original environment. Replay restores confirmed reference records and explicit-host selectors without running `gh`; old unqualified calls without a recorded URL need another invocation or `track`. Branch selectors and `git push --porcelain` remain unsupported.
- **Discovery timing** — a change request opened elsewhere for a branch appears after the next push of that branch, after a caller such as the forge panel reports the branch again, or after a `track`.
- **No model tool** — the model cannot read or change bindings yet.
- **Per-Session bindings** — subagent Sessions keep their own bindings; a parent Session does not aggregate them.
@@ -36,7 +36,9 @@ kind: "package-reference"
`ctx.forgeSession.changeRequests(session)` 按首次记录的顺序返回可见的绑定。每一项包含 URL、其 `origin`(`created`、`referenced`、`discovered` 或 `tracked`)、提供方引用(没有提供方服务该 URL 时为 `null`)以及最近一次轮询结果(尚无结果时为 `null`)。`watch(session, listener)` 是调用方 fiber 上的 effect:Session 有监听方期间,提供方服务的每个可见绑定都会被轮询;此后每次绑定变化或轮询结果到达时,监听方都会收到完整列表。`track(session, url)` 以提供方的规范形式绑定 URL,或恢复已隐藏的绑定;没有提供方识别该 URL 时抛错。`dismiss(session, url)` 隐藏一个可见绑定;其他 URL 不会改变日志。`discoverBranch(session, remote, branch)` 为位于 `remote` 远端的分支执行推送之后的那次查找,记录完绑定或失败后 resolve;没有提供方服务的远端不会带来任何变化。
`bash` 或 `pwsh` 调用只从其第一个结果中提取证据,且仅当工具执行本身成功时才提取;命令的退出状态不影响结果。包含 `gh pr create` 或 `gh pr new` 的命令会绑定其文本输出中的每个 `/pull/<number>` URL。包含 `gh pr view`、`gh pr edit` 或 `gh pr checkout` 的命令以 `referenced` 绑定:作为该子命令第一个参数给出的 `/pull/<number>` URL,以及输出为它所操作的变更请求本身打印的 URL,即 gh 的 `url:` 字段、开始描述正文的 `--` 行之前单独成行的 URL,以及 JSON 的 `url` 成员。描述正文或其他字段里的链接不会绑定;同一命令里还包含 `gh pr create` 时,其 URL 以 `created` 绑定。包含 `git push` 的命令(可带前置的 `-C`、`-c` 或 Git 长选项)会记录 `To <remote>` 行下列出的每个已创建、已更新、已强制推送或已是最新的分支;被拒绝、已删除、标签以及非分支的引用会被跳过。对远端由提供方服务的每个被推送分支,服务在 `discoveryTimeoutMs` 内调用 `ctx.forge.findByHead`,并为返回的每个尚未绑定(包括已隐藏)的变更请求追加一条 `discover` 记录。每次推送都会重新查找;失败记录为警告日志。仅在协议、大小写、查询、片段或末尾斜杠上不同的 URL 指向同一个绑定。
`bash` 或 `pwsh` 调用只从其第一个结果中提取证据,且仅当工具执行本身成功时才提取;命令的退出状态不影响结果。包含 `gh pr create` 或 `gh pr new` 的命令会绑定其文本输出中的每个 `/pull/<number>` URL。包含 `gh pr view`、`gh pr edit` 或 `gh pr checkout` 的命令以 `referenced` 绑定:命令选中的 `/pull/<number>` URL,或带有明确仓库和主机的数字编号,以及输出为它所操作的变更请求本身打印的 URL,即 gh 的 `url:` 字段、开始描述正文的 `--` 行之前单独成行的 URL,以及 JSON 的 `url` 成员。描述正文或其他字段里的链接不会绑定;同一命令里还包含 `gh pr create` 时,其 URL 以 `created` 绑定。包含 `git push` 的命令(可带前置的 `-C`、`-c` 或 Git 长选项)会记录 `To <remote>` 行下列出的每个已创建、已更新、已强制推送或已是最新的分支;被拒绝、已删除、标签以及非分支的引用会被跳过。对远端由提供方服务的每个被推送分支,服务在 `discoveryTimeoutMs` 内调用 `ctx.forge.findByHead`,并为返回的每个尚未绑定(包括已隐藏)的变更请求追加一条 `discover` 记录。每次推送都会重新查找;失败记录为警告日志。仅在协议、大小写、查询、片段或末尾斜杠上不同的 URL 指向同一个绑定。
`gh pr view`、`edit` 和 `checkout` 的数字编号支持 `--repo OWNER/REPO`、`--repo=OWNER/REPO` 和 `-R OWNER/REPO`,参数可位于编号前后。仓库参数中的主机或字面量 `GH_HOST` 赋值可直接确定 URL;对于单条命令(可通过管道传给 `head`、`tail` 或 `cat`),未写明主机且已完成的前台 Bash 调用会通过相同的 shell 服务、工作目录、字面量环境赋值、托管环境和常设沙箱策略,执行只读的 `gh pr view <number> --repo <repository> --json url` 查询,由 CLI 解析其配置的主机。查询成功后追加 `reference` 绑定记录;失败时保留现有绑定并记录警告。此实时观察器需要 `tools`、`shell` 和 `shellEnv`,仅含投影的组合仍可恢复已记录的 URL。查询共用 `discoveryTimeoutMs`,调用方取消会停止查询,销毁服务会中止并等待查询结束。
绑定操作是 `plugin:forge-session/binding` 插件记录,列于[实验性持久化目录](../../../docs/experimental-persistence-catalog.zh.md);fork 与恢复会把它们与 shell 事件一同带上,因此 fork 出的 Session 从父 Session 的绑定开始。`discoveryTimeoutMs` 见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge-session)。
@@ -84,7 +86,7 @@ kind: "package-reference"
<a id="known-limitations-and-deferred-work"></a>
- **按文本识别命令**——只有包含 `gh pr create`、`gh pr new`、`gh pr view`、`gh pr edit`、`gh pr checkout` 或 `git push` 的 `bash` 与 `pwsh` 命令计入,也只有 `/pull/<number>` URL 会被绑定。以编号或分支指定变更请求的 view、edit 或 checkout,只有输出打印了它的 URL 时才会绑定,而 `gh pr checkout` 从不打印。脚本、别名、`gh api` 调用、`glab` 等其他 CLI 以及 `git push --porcelain` 输出都无法识别;这类变更请求需用 `track` 绑定。
- **有限的 shell 识别**——数字编号需要字面量仓库参数。不识别变量展开、替换、多行脚本、未知选项、别名和其他 CLI。复合命令或 PowerShell 中未写明主机的仓库需要输出 URL 或使用 `track`,实时解析器无法恢复它们原本的环境。重放会恢复已确认的引用记录和主机明确的编号参数,不会执行 `gh`;没有记录 URL 的旧调用若未写明主机,需要再次调用或使用 `track`。分支选择器和 `git push --porcelain` 仍不受支持。
- **发现时机**——在别处为某个分支打开的变更请求,要等到该分支的下一次推送、forge 面板等调用方再次报告该分支,或 `track` 之后才会出现。
- **没有模型工具**——模型目前还不能读取或修改绑定。
- **按 Session 绑定**——subagent 的 Session 保有自己的绑定;父 Session 不会汇总它们。
@@ -32,16 +32,21 @@
"directory": "packages/experimental/forge-session"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:~",
"@deepseek-ai/dsh-timeout": "workspace:*",
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"shell-quote": "^1.12.0",
"zod": "^4.4.3"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-experimental-forge": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*"
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-shell": "workspace:*",
"@deepseek-ai/dsh-shell-env": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/dsh-sandbox-policy": "workspace:*"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:~",
@@ -62,6 +67,22 @@
"@deepseek-ai/dsh-tool-bash": "workspace:*",
"@deepseek-ai/dsh-tool-executor-native": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/dsh-working-directory": "workspace:*"
"@deepseek-ai/dsh-working-directory": "workspace:*",
"@deepseek-ai/dsh-shell": "workspace:*",
"@deepseek-ai/dsh-sandbox-policy": "workspace:*"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-shell": {
"optional": true
},
"@deepseek-ai/dsh-shell-env": {
"optional": true
},
"@deepseek-ai/dsh-tools": {
"optional": true
},
"@deepseek-ai/dsh-sandbox-policy": {
"optional": true
}
}
}
@@ -1,6 +1,7 @@
/** Recognition of shell commands and output that bind change requests to a Session. */
import type { CommandEvidence } from './types.ts'
import { ghReferences } from './references.ts'
/** One branch a `git push` output reported as updated. */
export interface PushedRef {
@@ -12,7 +13,6 @@ export interface PushedRef {
const CREATE_COMMAND = /\bgh\s+pr\s+(?:create|new)\b/
const REFERENCE_COMMAND = /\bgh\s+pr\s+(?:view|edit|checkout)\b/
const NAMED_CHANGE_REQUEST = /\bgh\s+pr\s+(?:view|edit|checkout)\s+['"]?(https?:\/\/[^\s"'<>]+?\/pull\/\d+)(?![\w-])/g
const OWN_URL_LINE = /^(?:url:\s+)?(https?:\/\/[^\s"'<>]+?\/pull\/\d+)\/?$/
const OWN_URL_MEMBER = /"url"\s*:\s*"(https?:\/\/[^\s"'<>]+?\/pull\/\d+)\/?"/g
const PUSH_COMMAND = /\bgit(?:\s+(?:-[Cc]\s+\S+|--[\w-]+(?:=\S+)?))*\s+push\b/
@@ -42,7 +42,8 @@ export function commandOf(argumentsJson: string): string | undefined {
* Classify the binding evidence a command and its output can carry.
* `gh pr create` (or its alias `gh pr new`) prints the change request URL;
* `gh pr view`, `gh pr edit`, and `gh pr checkout` act on one change request,
* which their first argument can name and their output can print; `git push`,
* which a URL or a number with an explicit repository and host can name;
* their output can also print its URL. `git push`,
* with or without leading `-C`, `-c`, and long options, prints the updated
* refs. When a command does both, the URLs a result prints bind as created.
* The match is textual, so quoted mentions also classify; the result parsers
@@ -52,10 +53,11 @@ export function commandOf(argumentsJson: string): string | undefined {
*/
export function commandEvidence(command: string): CommandEvidence | undefined {
const create = CREATE_COMMAND.test(command)
const reference = REFERENCE_COMMAND.test(command)
const references = ghReferences(command)
const reference = REFERENCE_COMMAND.test(command) || references.length > 0
const pushes = PUSH_COMMAND.test(command)
if (!create && !reference && !pushes) return undefined
const named = reference ? [...new Set(Array.from(command.matchAll(NAMED_CHANGE_REQUEST), ([, url = '']) => url))] : []
const named = [...new Set(references.flatMap(reference => reference.url === null ? [] : [reference.url]))]
return { urls: create ? 'created' : reference ? 'referenced' : null, pushes, named }
}
@@ -10,6 +10,7 @@ import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
import { deadline, MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { sameChangeRequestUrl } from './commands.ts'
import { BINDING_RECORD, forgeChangeRequestsProjection } from './projection.ts'
import { observeReferences } from './reference-lookup.ts'
import type { BindingState, Config, ForgeChangeRequestsState, SessionChangeRequest, SessionChangeRequestsListener } from './types.ts'
export type * from './types.ts'
@@ -155,6 +156,11 @@ export class ForgeSessionService extends Service {
this.home.on('session/event', (session: Session, event: SessionEvent) => {
this.react(session, event)
})
observeReferences(this.home, this.discoveryTimeoutMs, (session, url) => {
if (!this.state(session).bindings.some(binding => sameChangeRequestUrl(binding.url, url))) {
appendPluginRecord(session, BINDING_RECORD, { action: 'reference', url })
}
})
}
private state(session: Session): ForgeChangeRequestsState {
@@ -22,7 +22,7 @@ const EMPTY: ForgeChangeRequestsState = Object.freeze({
})
const bindingRecordSchema: z.ZodType<BindingRecord> = z.object({
action: z.enum(['track', 'dismiss', 'discover']),
action: z.enum(['track', 'dismiss', 'discover', 'reference']),
url: z.string().regex(/^https?:\/\/\S+$/),
})
@@ -58,7 +58,7 @@ const stateSchema: z.ZodType<ForgeChangeRequestsState> = z.object({
*/
export const forgeChangeRequestsProjection = {
key: 'forgeChangeRequests',
stateVersion: 2,
stateVersion: 3,
stateSchema,
init: () => EMPTY,
apply,
@@ -110,6 +110,8 @@ function applyRecord(state: ForgeChangeRequestsState, event: SessionEvent): Forg
const index = state.bindings.findIndex(binding => sameChangeRequestUrl(binding.url, url))
const existing = state.bindings[index]
switch (action) {
case 'reference':
return existing === undefined ? { ...state, bindings: bind(state.bindings, [url], 'referenced') } : state
case 'discover':
return existing === undefined ? { ...state, bindings: bind(state.bindings, [url], 'discovered') } : state
case 'track':
@@ -0,0 +1,82 @@
/** Resolve unqualified CLI references in the shell execution world and persist only confirmed URLs. */
import type { Context } from '@deepseek-ai/cordis'
import type { Session } from '@deepseek-ai/dsh-session'
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-shell'
import type {} from '@deepseek-ai/dsh-shell-env'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import { z } from 'zod'
import { ownChangeRequestUrls } from './commands.ts'
import { ghReferences } from './references.ts'
const completedShell = z.object({
kind: z.literal('foreground'),
cwd: z.string(),
exitCode: z.literal(0),
signal: z.null(),
timedOut: z.literal(false),
aborted: z.literal(false),
})
const shellArguments = z.object({ command: z.string() })
const resolvedReference = z.object({ url: z.string().regex(/^https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/[1-9]\d*\/?$/) })
/**
* Observe completed foreground Bash calls while tools and shell services exist.
* Queries use the same shell service, cwd, managed environment, and standing
* sandbox policy. Failed lookups leave bindings unchanged and log a warning.
* Cancellation leaves bindings unchanged; disposal aborts and awaits all queries.
* Replay performs no lookup.
* @param ctx - owning plugin context.
* @param timeoutMs - shell deadline for one read-only lookup.
* @param record - persists a confirmed URL without restoring dismissed bindings.
*/
export function observeReferences(ctx: Context, timeoutMs: number, record: (session: Session, url: string) => void): void {
ctx.inject(['tools', 'shell', 'shellEnv'], (scope) => {
const lifetime = new AbortController()
const tasks = new Set<Promise<void>>()
scope.effect(() => async () => {
lifetime.abort()
await Promise.allSettled(tasks)
}, 'forge-session: stop reference lookups')
scope.on('tools/result', (exec, result) => {
const task = resolve(exec, result).catch((error: unknown) => {
if (lifetime.signal.aborted || exec.signal.aborted) return
scope.logger.warn(`forge-session: PR reference lookup failed; use a full PR URL to bind it: ${error instanceof Error ? error.message : String(error)}`)
})
tasks.add(task)
void task.then(() => tasks.delete(task))
})
async function resolve(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): Promise<void> {
if (exec.name !== 'bash' || exec.agent === undefined || exec.parent !== undefined || result.isError) return
const argumentsResult = shellArguments.safeParse(exec.arguments)
const shell = completedShell.safeParse(result.value)
if (!argumentsResult.success || !shell.success) return
const printed = result.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n')
if (ownChangeRequestUrls(printed).length > 0) return
const lookups = ghReferences(argumentsResult.data.command).flatMap(reference => reference.lookup === null ? [] : [reference.lookup])
const agent = exec.agent
const signal = AbortSignal.any([lifetime.signal, exec.signal])
for (const command of lookups) {
signal.throwIfAborted()
const spec = scope.shell.resolve({
command,
workdir: shell.data.cwd,
timeoutMs,
signal,
dshEnv: scope.shellEnv.collect(exec),
sandboxPolicy: scope.get('sandboxPolicy')?.resolve({ session: agent.session }),
})
const process = await scope.shell.execute(spec)
const output = await process.result()
signal.throwIfAborted()
if (output.exitCode !== 0 || output.signal !== null || output.timedOut || output.aborted || output.stdout.truncated) {
throw new Error('gh did not return a complete successful reference lookup')
}
const parsed = resolvedReference.parse(JSON.parse(output.stdout.text))
record(agent.session, parsed.url)
}
}
})
}
@@ -0,0 +1,124 @@
/** Literal GitHub CLI references, with shell quoting and command separators preserved. */
import parse from 'shell-quote/parse.js'
import quote from 'shell-quote/quote.js'
/** One PR explicitly selected by a supported GitHub CLI invocation. */
export interface GhReference {
/** Canonical URL when the command supplies its host, otherwise null. */
readonly url: string | null
/** Read-only command for an unqualified repository in the first simple command, otherwise null. */
readonly lookup: string | null
}
const VALUE_OPTIONS = new Set([
'--json', '--jq', '-q', '--template', '-t', '--title', '--body', '-b', '--body-file', '-F', '--base', '-B',
'--add-assignee', '--add-label', '--add-project', '--add-reviewer', '--milestone', '-m', '--remove-assignee',
'--remove-label', '--remove-project', '--remove-reviewer', '--branch',
])
const BOOLEAN_OPTIONS = new Set(['--comments', '-c', '--web', '-w', '--detach', '-d', '--force', '-f', '--recurse-submodules', '--remove-milestone'])
const SEPARATORS = new Set(['&&', '||', ';', '|', '|&', '&'])
const REPOSITORY = /^(?:([a-z\d.-]+(?::\d+)?)\/)?([a-z\d_-]+)\/([a-z\d_.-]+)$/i
/**
* Read explicit URL selectors or numeric selectors with `--repo`/`-R`.
* Shell expansion, multiline scripts, substitutions, and unsupported options
* are excluded. Unqualified repositories can be resolved only for a single
* simple command, optionally piped to an output filter; compound commands
* may change the CLI environment. PowerShell callers may use only `url`.
* @param command - logged shell command text.
* @returns references in command order; no process or environment is read.
*/
export function ghReferences(command: string): GhReference[] {
if (/[\r\n`]|\$\(/.test(command) || !/^(?:[^'"\\]|\\.|'(?:[^']*)'|"(?:[^"\\]|\\.)*")*$/.test(command)) return []
let tokens: parse.ParseEntry[]
try {
tokens = parse(command, () => { throw new Error('dynamic shell word') })
} catch (error) {
void error // An unresolved or malformed shell word cannot identify a repository.
return []
}
const references: GhReference[] = []
const compound = tokens.some((token, index) => {
const next = tokens[index + 1]
return typeof token !== 'string' && 'op' in token
&& (['&&', '||', ';', '&'].includes(token.op)
|| (['|', '|&'].includes(token.op) && (typeof next !== 'string' || !['head', 'tail', 'cat'].includes(next))))
})
let words: string[] = []
let first = true
let unsupported = false
const flush = () => {
const reference = unsupported ? undefined : referenceOf(words, first && !compound)
if (reference !== undefined) references.push(reference)
words = []
first = false
unsupported = false
}
for (let index = 0; index < tokens.length; index++) {
const token = tokens[index]
if (typeof token === 'string') {
words.push(token)
} else if (token !== undefined && 'op' in token && SEPARATORS.has(token.op)) {
flush()
} else if (token !== undefined && 'op' in token && token.op === '>&' && words.at(-1) === '2' && tokens[index + 1] === '1') {
words.pop()
index++
} else {
unsupported = true
}
}
flush()
return references
}
function referenceOf(words: readonly string[], allowLookup: boolean): GhReference | undefined {
const environment: string[] = []
let executable = words[0] === 'env' ? 1 : 0
for (const word of words.slice(executable)) {
if (!/^[A-Za-z_][A-Za-z\d_]*=/.test(word)) break
environment.push(word)
executable++
}
if (words[executable] !== 'gh') return undefined
let repository: string | undefined
let take: 'repo' | 'value' | null = null
const positional: string[] = []
for (const arg of words.slice(executable + 1)) {
if (take !== null) {
if (take === 'repo') repository = arg
take = null
continue
}
if (arg === '--repo' || arg === '-R') {
take = 'repo'
} else if (arg.startsWith('--repo=')) {
repository = arg.slice('--repo='.length)
} else if (arg.startsWith('-R') && arg.length > 2) {
repository = arg.slice(2)
} else {
const option = arg.replace(/=.*/, '')
if (VALUE_OPTIONS.has(option)) {
if (!arg.includes('=')) take = 'value'
} else if (!BOOLEAN_OPTIONS.has(option)) {
if (arg.startsWith('-')) return undefined
positional.push(arg)
}
}
}
if (take !== null) return undefined
const [resource, operation, selector] = positional
if (resource !== 'pr' || !['view', 'edit', 'checkout'].includes(operation ?? '') || selector === undefined || positional.length !== 3) return undefined
const url = /^(https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/[1-9]\d*)(?:[/?#].*)?$/.exec(selector)?.[1]
if (url !== undefined) return { url, lookup: null }
if (!/^[1-9]\d*$/.test(selector) || !Number.isSafeInteger(Number(selector)) || repository === undefined) return undefined
const match = REPOSITORY.exec(repository.replace(/^https?:\/\//, '').replace(/\.git$/, ''))
if (match === null || match[3] === '.' || match[3] === '..') return undefined
const [, repoHost, owner, name] = match
const host = repoHost ?? environment.findLast(entry => entry.startsWith('GH_HOST='))?.slice('GH_HOST='.length)
if (host !== undefined && /^[a-z\d.-]+(?::\d+)?$/i.test(host)) return { url: `https://${host}/${owner}/${name}/pull/${selector}`, lookup: null }
return {
url: null,
lookup: allowLookup ? quote(['env', ...environment, 'gh', 'pr', 'view', selector, '--repo', repository, '--json', 'url']) : null,
}
}
@@ -18,14 +18,17 @@ export interface CommandEvidence {
readonly urls: 'created' | 'referenced' | null
/** Whether the result can report pushed refs. */
readonly pushes: boolean
/** Change request URLs the command passes as the first argument of `gh pr view`, `gh pr edit`, or `gh pr checkout`. */
/** Change request URLs explicitly selected by URL or by number, repository, and host. */
readonly named: readonly string[]
}
/** Payload of the `plugin:forge-session/binding` record. */
export interface BindingRecord {
/** `track` binds or restores, `dismiss` hides, `discover` binds a change request found for a branch the Session pushed or works on. */
readonly action: 'track' | 'dismiss' | 'discover'
/**
* `track` binds or restores, `dismiss` hides, `discover` binds a branch lookup,
* and `reference` binds a CLI selector lookup without restoring dismissed entries.
*/
readonly action: 'track' | 'dismiss' | 'discover' | 'reference'
/** Web URL of the change request. */
readonly url: string
}
@@ -35,7 +35,7 @@ describe('commandEvidence', () => {
[`gh pr view '${url}' --json title`, { urls: 'referenced', pushes: false, named: [url] }],
[`gh pr edit ${url}/files --add-label bug`, { urls: 'referenced', pushes: false, named: [url] }],
[`gh pr checkout ${url} && git push`, { urls: 'referenced', pushes: true, named: [url] }],
[`gh pr view --json url ${url}`, { urls: 'referenced', pushes: false, named: [] }],
[`gh pr view --json url ${url}`, { urls: 'referenced', pushes: false, named: [url] }],
[`gh pr create --fill && gh pr view ${url}`, { ...created, named: [url] }],
])('classifies %j', (command, evidence) => {
expect(commandEvidence(command)).toEqual(evidence)
@@ -27,6 +27,8 @@ import ToolRuntime from '@deepseek-ai/dsh-tools'
import WorkingDirectoryService from '@deepseek-ai/dsh-working-directory'
import ForgeSessionService from '../src/index.ts'
import type { SessionChangeRequest, SessionChangeRequestsListener } from '../src/index.ts'
import type { ShellExecution } from '@deepseek-ai/dsh-shell'
import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
/**
* GitHub CLI fixture: `pr create` prints PR 8; `pr view` prints PR 9, whose description links PR 6;
@@ -43,6 +45,17 @@ if (args[0] === 'pr' && args[1] === 'create') {
process.exit(0)
}
if (args[0] === 'pr' && args[1] === 'view') {
if (args.includes('--json')) {
const fields = args[args.indexOf('--json') + 1]
if (fields === 'url' && process.env.FORGE_TEST_MODE === 'fail') process.exit(1)
if (fields === 'url' && process.env.FORGE_TEST_MODE === 'invalid') { process.stdout.write('{}'); process.exit(0) }
if (fields === 'url' && process.env.FORGE_TEST_MODE === 'wait') { setInterval(() => {}, 1000); }
else process.stdout.write(JSON.stringify(fields === 'url'
? { url: 'https://' + (process.env.FORGE_TEST_HOST || 'github.com') + '/octo/demo/pull/9' }
: { number: 9, title: 'Change 9', body: 'Stacked on https://github.com/octo/demo/pull/6' }))
if (process.env.FORGE_TEST_MODE !== 'wait') process.exit(0)
else await new Promise(() => {})
}
process.stdout.write('title:\tChange 9\nstate:\tOPEN\nurl:\thttps://github.com/octo/demo/pull/9\n--\nStacked on https://github.com/octo/demo/pull/6\n')
process.exit(0)
}
@@ -133,8 +146,7 @@ afterEach(async () => {
const suite = process.platform === 'win32' ? describe.skip : describe
suite('forge-session through a real cordis.yml Loader composition', () => {
it('binds the pull requests that bash pushes discover, creates, and views, and polls them through the GitHub provider', async () => {
async function load() {
root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-forge-session-loader-')))
const bin = join(root, 'bin')
await mkdir(bin)
@@ -186,8 +198,141 @@ suite('forge-session through a real cordis.yml Loader composition', () => {
await ctx.loader.await()
for (const entry of ctx.loader.entries()) await entry.fiber?.await()
const path = `PATH=${JSON.stringify(`${bin}:`)}"$PATH"`
const model = new ScriptedModel([`${path} git push -u origin feat`, `${path} gh pr create --fill --head other`, `${path} gh pr view 9`])
const path = `PATH=${JSON.stringify(`${bin}:${process.env.PATH ?? ''}`)}`
return { ctx, path, root }
}
async function observerFixture() {
const { ctx, path, root } = await load()
const handle = await ctx.agents.create({ sessionId: SessionId('reference-observer') })
const controller = new AbortController()
const exec: ToolExecution = {
name: 'bash', agent: handle.agent,
callId: ToolCallId('reference'), rootCallId: ToolCallId('reference'), token: Symbol('reference') as ToolExecutionToken,
arguments: { command: `${path} gh pr view 9 -R octo/demo --json number` }, signal: controller.signal,
}
const result: ToolExecutionResult = {
isError: false, content: [{ type: 'text', text: '{"number":9}' }],
value: { kind: 'foreground', cwd: root, exitCode: 0, signal: null, timedOut: false, aborted: false },
}
const entry = [...ctx.loader.entries()].find(candidate => candidate.options.name === 'cordis:@deepseek-ai/dsh-experimental-forge-session')!
const records = () => handle.agent.session.snapshotEvents().flatMap((event) => {
const record = pluginRecordOf(event)
return record?.type === 'plugin:forge-session/binding' ? [record.data] : []
})
const query = Promise.withResolvers<ShellExecution>()
const recorded = Promise.withResolvers<undefined>()
ctx.on('session/event', (_session, event) => {
const record = pluginRecordOf(event)
if (record?.type === 'plugin:forge-session/binding') recorded.resolve(undefined)
})
const execute = ctx.shell.execute.bind(ctx.shell)
const executeSpy = vi.spyOn(ctx.shell, 'execute').mockImplementation(async (spec) => {
const execution = await execute(spec)
query.resolve(execution)
return execution
})
return { ctx, path, exec, result, controller, entry, records, query, executeSpy, recorded }
}
suite('forge-session through a real cordis.yml Loader composition', () => {
it('uses the CLI environment to resolve an Enterprise host and retains a dismissal after another lookup', async () => {
const f = await observerFixture()
const url = 'https://fake.test/octo/demo/pull/9'
const emit = () => { f.ctx.emit('tools/result', {
...f.exec, arguments: { command: `${f.path} FORGE_TEST_HOST=fake.test gh pr view 9 -R octo/demo --json number` },
}, f.result) }
emit()
await f.recorded.promise
expect(f.records()).toEqual([{ action: 'reference', url }])
f.ctx.forgeSession.dismiss(f.exec.agent!.session, url)
emit()
const spy = f.executeSpy
expect(spy).toHaveBeenCalledTimes(2)
const returned = spy.mock.results[1]
if (returned?.type !== 'return') throw new Error('missing second lookup')
const second = await returned.value
await second.result()
await f.entry.fiber?.dispose()
expect(f.records()).toEqual([{ action: 'reference', url }, { action: 'dismiss', url }])
})
it.each(['fail', 'invalid'])('leaves bindings unchanged and logs a %s lookup', async (mode) => {
const f = await observerFixture()
const reported = Promise.withResolvers<undefined>()
const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation((message) => {
if (String(message).includes('PR reference lookup failed')) reported.resolve(undefined)
})
f.ctx.emit('tools/result', { ...f.exec, arguments: { command: `${f.path} FORGE_TEST_MODE=${mode} gh pr view 9 -R octo/demo --json number` } }, f.result)
await reported.promise
expect(warn).toHaveBeenCalledWith(expect.stringContaining('PR reference lookup failed'))
expect(f.records()).toEqual([])
})
it.each(['caller', 'service'] as const)('waits for a running lookup to stop on %s cancellation', async (owner) => {
const f = await observerFixture()
const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {})
f.ctx.emit('tools/result', { ...f.exec, arguments: { command: `${f.path} FORGE_TEST_MODE=wait gh pr view 9 -R octo/demo --json number` } }, f.result)
const query = await f.query.promise
if (owner === 'caller') f.controller.abort()
await f.entry.fiber?.dispose()
expect(query.status).not.toBe('running')
expect((await query.result()).aborted).toBe(true)
expect(f.records()).toEqual([])
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('PR reference lookup failed'))
})
it('skips calls without a completed foreground result, explicit repository, or live caller', async () => {
const f = await observerFixture()
const emit = (exec: ToolExecution, result: ToolExecutionResult = f.result) => { f.ctx.emit('tools/result', exec, result) }
emit({ ...f.exec, name: 'pwsh' })
const { agent: _agent, ...withoutAgent } = f.exec
emit(withoutAgent)
emit({ ...f.exec, parent: f.exec.token })
emit(f.exec, { isError: true, error: { message: 'failed' }, content: [] })
emit({ ...f.exec, arguments: {} })
emit(f.exec, { isError: false, value: { kind: 'background' }, content: [] })
emit({ ...f.exec, arguments: { command: 'gh pr view 9' } }, { ...f.result, content: [{ type: 'reasoning', text: 'thinking' }] })
emit({ ...f.exec, arguments: { command: 'gh pr view 9 -R github.com/octo/demo' } })
f.controller.abort()
emit(f.exec)
await f.entry.fiber?.dispose()
expect(f.executeSpy).not.toHaveBeenCalled()
expect(f.records()).toEqual([])
})
it('contains a shell preparation failure without changing the completed tool outcome', async () => {
const f = await observerFixture()
const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {})
f.executeSpy.mockRejectedValue('unavailable')
f.ctx.emit('tools/result', f.exec, f.result)
await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('unavailable')) })
expect(f.records()).toEqual([])
expect(f.result.isError).toBe(false)
})
it('joins a cancelled preparation failure without reporting it as a lookup failure', async () => {
const f = await observerFixture()
const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {})
const started = Promise.withResolvers<undefined>()
const pending = Promise.withResolvers<ShellExecution>()
f.executeSpy.mockImplementation(() => { started.resolve(undefined); return pending.promise })
f.ctx.emit('tools/result', f.exec, f.result)
await started.promise
f.controller.abort()
pending.reject(new Error('cancelled during preparation'))
await f.entry.fiber?.dispose()
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('PR reference lookup failed'))
expect(f.records()).toEqual([])
})
it('binds the pull requests that bash pushes discover, creates, and views, and polls them through the GitHub provider', async () => {
const { ctx, path, root } = await load()
const model = new ScriptedModel([
`${path} git push -u origin feat`,
`${path} gh pr create --fill --head other`,
`${path} gh pr view 9 --repo octo/demo --json number,title,body 2>&1 | head -200`,
])
ctx.llm.registerAdapter(['fixture'], model)
const agent = await ctx.agentLoop.create(SessionId('forge-session-loader'), { provider: 'fixture', model: 'fixture' }, { cwd: root })
const idle: PromiseWithResolvers<void> = Promise.withResolvers()
@@ -204,9 +349,10 @@ suite('forge-session through a real cordis.yml Loader composition', () => {
const record = pluginRecordOf(event)
return record === undefined ? [] : [record.data]
})
await vi.waitFor(() => { expect(records()).toEqual([
await vi.waitFor(() => { expect(records()).toEqual(expect.arrayContaining([
{ action: 'discover', url: 'https://github.com/octo/demo/pull/7' },
]) })
{ action: 'reference', url: 'https://github.com/octo/demo/pull/9' },
])) })
const byUrl = (entries: readonly SessionChangeRequest[]) => [...entries].sort((left, right) => left.url.localeCompare(right.url))
expect(byUrl(ctx.forgeSession.changeRequests(agent.session))).toEqual([
{ url: 'https://github.com/octo/demo/pull/7', origin: 'discovered', ref: github(7), update: null },
@@ -215,6 +361,7 @@ suite('forge-session through a real cordis.yml Loader composition', () => {
])
const lookups = (await readFile(join(root, 'gh.log'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[])
expect(lookups.find(args => args.includes('branch=feat'))).toEqual(expect.arrayContaining(['--hostname', 'github.com', 'owner=octo', 'name=demo']))
expect(lookups).toContainEqual(['pr', 'view', '9', '--repo', 'octo/demo', '--json', 'url'])
const listener = vi.fn<SessionChangeRequestsListener>()
ctx.forgeSession.watch(agent.session, listener)
@@ -153,6 +153,16 @@ describe('forgeChangeRequests projection', () => {
])
})
it('binds a numbered PR in an explicit host repository when JSON omits the URL', () => {
const session = Session.create(SessionId('numbered-reference'))
shellTurn(session, [{
callId: 'view',
command: 'gh pr view 5960 --repo fake.test/octo/demo --json number,title,body 2>&1 | head -200',
output: JSON.stringify({ number: 5960, title: 'Change', body: 'See #5961' }),
}])
expect(fold(session).bindings).toEqual([{ url: pullUrl(5960), origin: 'referenced', dismissed: false }])
})
it('folds only the first result of a call and drops calls without results at turn end', () => {
const session = Session.create(SessionId('pending'))
shellTurn(session, [{ callId: 'create', command: 'gh pr create --fill', output: created(1) }])
@@ -224,4 +234,12 @@ describe('forgeChangeRequests projection', () => {
expect(() => stateSchema.parse({ ...state, pushes: [{ remote: 'r', branch: 'b', seq: -1 }] })).toThrow()
expect(() => stateSchema.parse({ ...state, extra: true })).toThrow()
})
it('replays confirmed references without restoring hidden bindings or changing their first origin', () => {
const session = Session.create(SessionId('confirmed-reference'))
appendPluginRecord(session, 'plugin:forge-session/binding', { action: 'reference', url: pullUrl(9) })
appendPluginRecord(session, 'plugin:forge-session/binding', { action: 'dismiss', url: pullUrl(9) })
appendPluginRecord(session, 'plugin:forge-session/binding', { action: 'reference', url: pullUrl(9) })
expect(fold(session).bindings).toEqual([{ url: pullUrl(9), origin: 'referenced', dismissed: true }])
})
})
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { ghReferences } from '../src/references.ts'
describe('explicit CLI references', () => {
const url = 'https://ghe.example/octo/demo/pull/5960'
it.each([
'gh pr view 5960 --repo ghe.example/octo/demo --json number,title,body 2>&1 | head -200',
'gh -R ghe.example/octo/demo pr view --json number 5960',
'gh pr edit --repo="ghe.example/octo/demo" --title "New title" 5960',
"gh pr checkout -Rghe.example/octo/demo '5960'",
'GH_HOST=ghe.example gh pr view 5960 -R octo/demo',
'env GH_HOST=ghe.example gh pr view 5960 -R octo/demo',
'GH_HOST=other.example gh pr view 5960 -R ghe.example/octo/demo',
'gh pr view 5960 -R https://ghe.example/octo/demo.git',
`gh pr view --json title '${url}/files'`,
])('recognizes %s', (command) => {
expect(ghReferences(command)).toEqual([{ url, lookup: null }])
})
it('resolves a repo without a host through the same gh configuration and literal environment', () => {
expect(ghReferences('gh pr view 5960 --repo deepseek-harness/deepseek-harness --json number,title,state,author,baseRefName,headRefName,additions,deletions,changedFiles,labels,body 2>&1 | head -200'))
.toEqual([{ url: null, lookup: 'env gh pr view 5960 --repo deepseek-harness/deepseek-harness --json url' }])
expect(ghReferences('GH_CONFIG_DIR="/tmp/gh config" gh pr edit 12 -R octo/demo --title "A title"'))
.toEqual([{ url: null, lookup: "env 'GH_CONFIG_DIR=/tmp/gh config' gh pr view 12 --repo octo/demo --json url" }])
})
it('keeps repository and selector in the same command, and does not resolve a compound shell environment', () => {
expect(ghReferences('gh pr view 5960 --json number && echo --repo ghe.example/octo/demo')).toEqual([])
expect(ghReferences('echo --repo ghe.example/octo/demo | gh pr view 5960')).toEqual([])
expect(ghReferences('cd other && gh pr view 5960 -R octo/demo')).toEqual([{ url: null, lookup: null }])
expect(ghReferences('gh pr view 5960 -R octo/demo | custom-command')).toEqual([{ url: null, lookup: null }])
expect(ghReferences('gh pr view 5960 -R octo/demo | (cat)')).toEqual([{ url: null, lookup: null }])
expect(ghReferences('gh pr view 5960 -R ghe.example/octo/demo && gh pr checkout 12 -R github.com/other/repo'))
.toEqual([{ url, lookup: null }, { url: 'https://github.com/other/repo/pull/12', lookup: null }])
})
it.each([
'echo "gh pr view 5960 --repo ghe.example/octo/demo"',
'gh pr view $PR --repo ghe.example/octo/demo',
'gh pr view 5960 --repo "$REPO"',
'gh pr view $(cat number) --repo ghe.example/octo/demo',
'gh pr view `cat number` --repo ghe.example/octo/demo',
'gh pr view 5960 --repo "ghe.example/octo/demo',
'gh pr view 5960 -R ghe.example/*/demo',
'gh pr view 5960 -R ghe.example/octo/demo > output.txt',
'gh pr view 5960 -R ghe.example/octo/demo # comment',
'gh pr view 5960\n--repo ghe.example/octo/demo',
'gh pr view topic -R ghe.example/octo/demo',
'gh pr edit --title 5960 --repo ghe.example/octo/demo',
'gh pr view 5960 --unknown 42 --repo ghe.example/octo/demo',
'gh pr view 5960 --repo',
'gh pr view 5960 --repo=',
'gh pr view 5960 -R bad/repo/extra/path',
'gh pr view 5960 -R ghe.example/octo/..',
'gh pr view 99999999999999999999 -R ghe.example/octo/demo',
'gh pr view 1 2 -R ghe.example/octo/demo',
'gh pr view --json',
'gh pr view --help -R ghe.example/octo/demo',
'gh pr edit --title --repo ghe.example/octo/demo 5960',
])('leaves unsupported or ambiguous input unbound: %s', (command) => {
expect(ghReferences(command)).toEqual([])
})
it('consumes boolean flags without treating them as selectors', () => {
expect(ghReferences('gh pr view --comments 5960 -R ghe.example/octo/demo')).toEqual([{ url, lookup: null }])
expect(ghReferences('gh pr edit 5960 --title=Fixed -R ghe.example/octo/demo')).toEqual([{ url, lookup: null }])
})
})
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Mock } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import ForgeService from '@deepseek-ai/dsh-experimental-forge'
import SessionStore, { pluginRecordOf } from '@deepseek-ai/dsh-session'
import SessionStore, { appendPluginRecord, pluginRecordOf, SessionLogOffset } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import ForgeSessionService from '../src/index.ts'
@@ -286,6 +286,30 @@ describe('forgeSession watch', () => {
})
describe('forgeSession lifecycle', () => {
it('recovers references from an old checkpoint and preserves them across checkpoint suffix replay', async () => {
const { ctx, session } = await open()
create(session, 'create', pullUrl(1))
const rows = ctx.sessionProjections.checkpoint(session)
const offset = session.snapshotEvents().length
shellTurn(session, [{ callId: 'number', command: 'gh pr view 9 -R fake.test/octo/demo --json number', output: '{"number":9}' }])
appendPluginRecord(session, 'plugin:forge-session/binding', { action: 'reference', url: pullUrl(8) })
appendPluginRecord(session, 'plugin:forge-session/binding', { action: 'dismiss', url: pullUrl(9) })
const events = session.snapshotEvents()
const expected = ctx.sessionProjections.checkpoint(session)
const restore = (checkpoints: typeof rows, from: number) => ctx.sessionProjections.restore(
checkpoints, events.slice(from), SessionLogOffset(from), session.header, session.inheritedEventCount,
).checkpoint
expect(restore({}, 0)).toEqual(expected)
expect(restore(rows, offset)).toEqual(expected)
const row = rows.forgeChangeRequests!
expect(restore({ forgeChangeRequests: { ...row, ver: 2 } }, 0)).toEqual(expected)
expect(expected.forgeChangeRequests?.val).toMatchObject({ bindings: [
{ url: pullUrl(1), origin: 'created', dismissed: false },
{ url: pullUrl(9), origin: 'referenced', dismissed: true },
{ url: pullUrl(8), origin: 'referenced', dismissed: false },
] })
})
it('unregisters its projection and stops discovery when the service fiber is disposed', async () => {
const { ctx, provider, session, service } = await open()
create(session, 'create', pullUrl(1))
@@ -31,6 +31,18 @@
},
{
"path": "../../util/values"
},
{
"path": "../../shell/shell"
},
{
"path": "../../shell/shell-env"
},
{
"path": "../../core/tools"
},
{
"path": "../../sandbox/sandbox-policy"
}
]
}
+15
View File
@@ -9823,6 +9823,9 @@ importers:
'@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
shell-quote:
specifier: ^1.12.0
version: 1.12.0
zod:
specifier: ^4.4.3
version: 4.4.3
@@ -9857,12 +9860,18 @@ importers:
'@deepseek-ai/dsh-llm':
specifier: workspace:*
version: link:../../llm/llm
'@deepseek-ai/dsh-sandbox-policy':
specifier: workspace:*
version: link:../../sandbox/sandbox-policy
'@deepseek-ai/dsh-session':
specifier: workspace:*
version: link:../../core/session
'@deepseek-ai/dsh-session-projection':
specifier: workspace:*
version: link:../../session/session-projection
'@deepseek-ai/dsh-shell':
specifier: workspace:*
version: link:../../shell/shell
'@deepseek-ai/dsh-shell-env':
specifier: workspace:*
version: link:../../shell/shell-env
@@ -24675,6 +24684,10 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-FPNgJMgnWVl/KhRTIhG3KL3A4Om63Rn4YKXc9/uHY7SzLcvqLJLc/h7UBWJwduXvv7K18t5NpxHR6XgXn4sjWw==}
cpu: [arm64]
@@ -32641,6 +32654,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
optional: true