From df4f3fd0c1a69a156bf4c30f54f03255b7cf7859 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 26 Oct 2026 16:10:51 +0800 Subject: [PATCH] fix(forge-session): bind numeric PR references with explicit repositories --- .../forge-panel/reference.expected.md | 12 + apps/web/tests/forge-panel.e2e.ts | 52 +++- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/module-graph.md | 12 +- docs/module-graph.zh.md | 12 +- docs/subsystems/forge.md | 7 +- docs/subsystems/forge.zh.md | 7 +- .../forge-session/README.i18n.yaml | 8 +- packages/experimental/forge-session/README.md | 6 +- .../experimental/forge-session/README.zh.md | 6 +- .../experimental/forge-session/package.json | 27 +- .../forge-session/src/commands.ts | 10 +- .../experimental/forge-session/src/index.ts | 6 + .../forge-session/src/projection.ts | 6 +- .../forge-session/src/reference-lookup.ts | 82 ++++++ .../forge-session/src/references.ts | 124 +++++++++ .../experimental/forge-session/src/types.ts | 9 +- .../forge-session/tests/commands.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 255 ++++++++++++++---- .../forge-session/tests/projection.spec.ts | 18 ++ .../forge-session/tests/references.spec.ts | 69 +++++ .../forge-session/tests/service.spec.ts | 26 +- .../experimental/forge-session/tsconfig.json | 12 + pnpm-lock.yaml | 15 ++ 27 files changed, 694 insertions(+), 97 deletions(-) create mode 100644 apps/web/tests/expected/forge-panel/reference.expected.md create mode 100644 packages/experimental/forge-session/src/reference-lookup.ts create mode 100644 packages/experimental/forge-session/src/references.ts create mode 100644 packages/experimental/forge-session/tests/references.spec.ts diff --git a/apps/web/tests/expected/forge-panel/reference.expected.md b/apps/web/tests/expected/forge-panel/reference.expected.md new file mode 100644 index 0000000000..cc5f2e87f6 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/reference.expected.md @@ -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 diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index 7c463c3493..b516301c92 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -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 { +async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up' | 'reference' = 'normal'): Promise { 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&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 { - 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) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4ab361972a..c6662572a5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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`. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 03ef373d81..8577fec82d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -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`. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d7c7c9dc23..decfecaa16 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index cd50577bde..bd7893cffd 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -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) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 43ed591fa2..89181853d1 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index db40fd2e6d..4aa5922598 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -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) | diff --git a/docs/subsystems/forge.md b/docs/subsystems/forge.md index 58a452ce7b..18da66e63e 100644 --- a/docs/subsystems/forge.md +++ b/docs/subsystems/forge.md @@ -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 } diff --git a/docs/subsystems/forge.zh.md b/docs/subsystems/forge.zh.md index e9f7c80f13..b091dd8217 100644 --- a/docs/subsystems/forge.zh.md +++ b/docs/subsystems/forge.zh.md @@ -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 } diff --git a/packages/experimental/forge-session/README.i18n.yaml b/packages/experimental/forge-session/README.i18n.yaml index ecd9702732..0f0d3dcda7 100644 --- a/packages/experimental/forge-session/README.i18n.yaml +++ b/packages/experimental/forge-session/README.i18n.yaml @@ -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 diff --git a/packages/experimental/forge-session/README.md b/packages/experimental/forge-session/README.md index cf85990781..96ddcfb0bc 100644 --- a/packages/experimental/forge-session/README.md +++ b/packages/experimental/forge-session/README.md @@ -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/` URL in its text output. A command containing `gh pr view`, `gh pr edit`, or `gh pr checkout` binds, as `referenced`, a `/pull/` 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 ` 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/` URL in its text output. A command containing `gh pr view`, `gh pr edit`, or `gh pr checkout` binds, as `referenced`, a `/pull/` 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 ` 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 --repo --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 -- **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/` 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. diff --git a/packages/experimental/forge-session/README.zh.md b/packages/experimental/forge-session/README.zh.md index 95e8c27101..e8c245ab6b 100644 --- a/packages/experimental/forge-session/README.zh.md +++ b/packages/experimental/forge-session/README.zh.md @@ -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/` URL。包含 `gh pr view`、`gh pr edit` 或 `gh pr checkout` 的命令以 `referenced` 绑定:作为该子命令第一个参数给出的 `/pull/` URL,以及输出为它所操作的变更请求本身打印的 URL,即 gh 的 `url:` 字段、开始描述正文的 `--` 行之前单独成行的 URL,以及 JSON 的 `url` 成员。描述正文或其他字段里的链接不会绑定;同一命令里还包含 `gh pr create` 时,其 URL 以 `created` 绑定。包含 `git push` 的命令(可带前置的 `-C`、`-c` 或 Git 长选项)会记录 `To ` 行下列出的每个已创建、已更新、已强制推送或已是最新的分支;被拒绝、已删除、标签以及非分支的引用会被跳过。对远端由提供方服务的每个被推送分支,服务在 `discoveryTimeoutMs` 内调用 `ctx.forge.findByHead`,并为返回的每个尚未绑定(包括已隐藏)的变更请求追加一条 `discover` 记录。每次推送都会重新查找;失败记录为警告日志。仅在协议、大小写、查询、片段或末尾斜杠上不同的 URL 指向同一个绑定。 +`bash` 或 `pwsh` 调用只从其第一个结果中提取证据,且仅当工具执行本身成功时才提取;命令的退出状态不影响结果。包含 `gh pr create` 或 `gh pr new` 的命令会绑定其文本输出中的每个 `/pull/` URL。包含 `gh pr view`、`gh pr edit` 或 `gh pr checkout` 的命令以 `referenced` 绑定:命令选中的 `/pull/` URL,或带有明确仓库和主机的数字编号,以及输出为它所操作的变更请求本身打印的 URL,即 gh 的 `url:` 字段、开始描述正文的 `--` 行之前单独成行的 URL,以及 JSON 的 `url` 成员。描述正文或其他字段里的链接不会绑定;同一命令里还包含 `gh pr create` 时,其 URL 以 `created` 绑定。包含 `git push` 的命令(可带前置的 `-C`、`-c` 或 Git 长选项)会记录 `To ` 行下列出的每个已创建、已更新、已强制推送或已是最新的分支;被拒绝、已删除、标签以及非分支的引用会被跳过。对远端由提供方服务的每个被推送分支,服务在 `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 --repo --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" -- **按文本识别命令**——只有包含 `gh pr create`、`gh pr new`、`gh pr view`、`gh pr edit`、`gh pr checkout` 或 `git push` 的 `bash` 与 `pwsh` 命令计入,也只有 `/pull/` 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 不会汇总它们。 diff --git a/packages/experimental/forge-session/package.json b/packages/experimental/forge-session/package.json index 6b6952c92e..1283ed7145 100644 --- a/packages/experimental/forge-session/package.json +++ b/packages/experimental/forge-session/package.json @@ -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 + } } } diff --git a/packages/experimental/forge-session/src/commands.ts b/packages/experimental/forge-session/src/commands.ts index 039d6366b0..a40be37952 100644 --- a/packages/experimental/forge-session/src/commands.ts +++ b/packages/experimental/forge-session/src/commands.ts @@ -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 } } diff --git a/packages/experimental/forge-session/src/index.ts b/packages/experimental/forge-session/src/index.ts index 4a75b7edcd..fcc725bcfd 100644 --- a/packages/experimental/forge-session/src/index.ts +++ b/packages/experimental/forge-session/src/index.ts @@ -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 { diff --git a/packages/experimental/forge-session/src/projection.ts b/packages/experimental/forge-session/src/projection.ts index 94af4d2c82..193d63e5dd 100644 --- a/packages/experimental/forge-session/src/projection.ts +++ b/packages/experimental/forge-session/src/projection.ts @@ -22,7 +22,7 @@ const EMPTY: ForgeChangeRequestsState = Object.freeze({ }) const bindingRecordSchema: z.ZodType = 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 = 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': diff --git a/packages/experimental/forge-session/src/reference-lookup.ts b/packages/experimental/forge-session/src/reference-lookup.ts new file mode 100644 index 0000000000..7cce3eb6dc --- /dev/null +++ b/packages/experimental/forge-session/src/reference-lookup.ts @@ -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>() + 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, result: Readonly): Promise { + 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) + } + } + }) +} diff --git a/packages/experimental/forge-session/src/references.ts b/packages/experimental/forge-session/src/references.ts new file mode 100644 index 0000000000..c499aa4850 --- /dev/null +++ b/packages/experimental/forge-session/src/references.ts @@ -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, + } +} diff --git a/packages/experimental/forge-session/src/types.ts b/packages/experimental/forge-session/src/types.ts index 1ffc012ea6..b87587de68 100644 --- a/packages/experimental/forge-session/src/types.ts +++ b/packages/experimental/forge-session/src/types.ts @@ -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 } diff --git a/packages/experimental/forge-session/tests/commands.spec.ts b/packages/experimental/forge-session/tests/commands.spec.ts index f7068ea835..ea4b2abd90 100644 --- a/packages/experimental/forge-session/tests/commands.spec.ts +++ b/packages/experimental/forge-session/tests/commands.spec.ts @@ -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) diff --git a/packages/experimental/forge-session/tests/loader-composition.spec.ts b/packages/experimental/forge-session/tests/loader-composition.spec.ts index 5ae15fa0d8..0a2288f4f6 100644 --- a/packages/experimental/forge-session/tests/loader-composition.spec.ts +++ b/packages/experimental/forge-session/tests/loader-composition.spec.ts @@ -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,61 +146,193 @@ afterEach(async () => { const suite = process.platform === 'win32' ? describe.skip : describe +async function load() { + root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-forge-session-loader-'))) + const bin = join(root, 'bin') + await mkdir(bin) + await writeFile(join(root, 'fake-gh.mjs'), FAKE_GH) + await writeFile(join(bin, 'gh'), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(join(root, 'fake-gh.mjs'))} "$@"\n`) + await writeFile(join(bin, 'git'), FAKE_GIT) + await chmod(join(bin, 'gh'), 0o755) + await chmod(join(bin, 'git'), 0o755) + + const modules = new Map([ + ['@deepseek-ai/dsh-llm', LlmRuntime], + ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], + ['@deepseek-ai/dsh-system-prompt', SystemPrompt], + ['@deepseek-ai/dsh-fs-local', LocalFileSystem], + ['@deepseek-ai/dsh-working-directory', WorkingDirectoryService], + ['@deepseek-ai/dsh-tools', ToolRuntime], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], + ['@deepseek-ai/dsh-agent', AgentRegistry], + ['@deepseek-ai/dsh-agent-loop', AgentLoop], + ['@deepseek-ai/dsh-subprocess-local', LocalSubprocessRuntime], + ['@deepseek-ai/dsh-shell-env', ShellEnv], + ['@deepseek-ai/dsh-bash-local', LocalBashExecutor], + ['@deepseek-ai/dsh-tool-bash', ToolBash], + ['@deepseek-ai/dsh-experimental-forge', ForgeService], + ['@deepseek-ai/dsh-experimental-forge-github', ForgeGitHub], + ['@deepseek-ai/dsh-experimental-forge-session', ForgeSessionService], + ]) + const configs: Record = { + '@deepseek-ai/dsh-agent-loop': ['agents: []'], + '@deepseek-ai/dsh-shell-env': [`dshHome: ${JSON.stringify(join(root, 'dsh-home'))}`], + '@deepseek-ai/dsh-bash-local': ['timeoutMs: 20000'], + '@deepseek-ai/dsh-experimental-forge': ['activeIntervalMs: 600000', 'idleIntervalMs: 600000'], + '@deepseek-ai/dsh-experimental-forge-github': [`ghCommand: ${JSON.stringify(join(bin, 'gh'))}`], + '@deepseek-ai/dsh-experimental-forge-session': ['discoveryTimeoutMs: 20000'], + } + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [...modules.keys()].flatMap(name => [ + `- name: 'cordis:${name}'`, + ...name in configs ? [' config:', ...(configs[name] ?? []).map(line => ` ${line}`)] : [], + ]).join('\n') + '\n') + + const ctx = context = new Context() + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + for (const [specifier, plugin] of modules) ctx.loader.builtins[specifier] = plugin + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) + await ctx.loader.await() + for (const entry of ctx.loader.entries()) await entry.fiber?.await() + + 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() + const recorded = Promise.withResolvers() + 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() + 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() + const pending = Promise.withResolvers() + 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 () => { - root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-forge-session-loader-'))) - const bin = join(root, 'bin') - await mkdir(bin) - await writeFile(join(root, 'fake-gh.mjs'), FAKE_GH) - await writeFile(join(bin, 'gh'), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(join(root, 'fake-gh.mjs'))} "$@"\n`) - await writeFile(join(bin, 'git'), FAKE_GIT) - await chmod(join(bin, 'gh'), 0o755) - await chmod(join(bin, 'git'), 0o755) - - const modules = new Map([ - ['@deepseek-ai/dsh-llm', LlmRuntime], - ['@deepseek-ai/dsh-session', SessionStore], - ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], - ['@deepseek-ai/dsh-system-prompt', SystemPrompt], - ['@deepseek-ai/dsh-fs-local', LocalFileSystem], - ['@deepseek-ai/dsh-working-directory', WorkingDirectoryService], - ['@deepseek-ai/dsh-tools', ToolRuntime], - ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], - ['@deepseek-ai/dsh-agent', AgentRegistry], - ['@deepseek-ai/dsh-agent-loop', AgentLoop], - ['@deepseek-ai/dsh-subprocess-local', LocalSubprocessRuntime], - ['@deepseek-ai/dsh-shell-env', ShellEnv], - ['@deepseek-ai/dsh-bash-local', LocalBashExecutor], - ['@deepseek-ai/dsh-tool-bash', ToolBash], - ['@deepseek-ai/dsh-experimental-forge', ForgeService], - ['@deepseek-ai/dsh-experimental-forge-github', ForgeGitHub], - ['@deepseek-ai/dsh-experimental-forge-session', ForgeSessionService], + 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`, ]) - const configs: Record = { - '@deepseek-ai/dsh-agent-loop': ['agents: []'], - '@deepseek-ai/dsh-shell-env': [`dshHome: ${JSON.stringify(join(root, 'dsh-home'))}`], - '@deepseek-ai/dsh-bash-local': ['timeoutMs: 20000'], - '@deepseek-ai/dsh-experimental-forge': ['activeIntervalMs: 600000', 'idleIntervalMs: 600000'], - '@deepseek-ai/dsh-experimental-forge-github': [`ghCommand: ${JSON.stringify(join(bin, 'gh'))}`], - '@deepseek-ai/dsh-experimental-forge-session': ['discoveryTimeoutMs: 20000'], - } - const configPath = join(root, 'cordis.yml') - await writeFile(configPath, [...modules.keys()].flatMap(name => [ - `- name: 'cordis:${name}'`, - ...name in configs ? [' config:', ...(configs[name] ?? []).map(line => ` ${line}`)] : [], - ]).join('\n') + '\n') - - const ctx = context = new Context() - ctx.baseUrl = pathToFileURL(root).href + '/' - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - for (const [specifier, plugin] of modules) ctx.loader.builtins[specifier] = plugin - await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) - 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`]) ctx.llm.registerAdapter(['fixture'], model) const agent = await ctx.agentLoop.create(SessionId('forge-session-loader'), { provider: 'fixture', model: 'fixture' }, { cwd: root }) const idle: PromiseWithResolvers = 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() ctx.forgeSession.watch(agent.session, listener) diff --git a/packages/experimental/forge-session/tests/projection.spec.ts b/packages/experimental/forge-session/tests/projection.spec.ts index 3534dbe5fc..19188c081d 100644 --- a/packages/experimental/forge-session/tests/projection.spec.ts +++ b/packages/experimental/forge-session/tests/projection.spec.ts @@ -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 }]) + }) }) diff --git a/packages/experimental/forge-session/tests/references.spec.ts b/packages/experimental/forge-session/tests/references.spec.ts new file mode 100644 index 0000000000..a108c09bcd --- /dev/null +++ b/packages/experimental/forge-session/tests/references.spec.ts @@ -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 }]) + }) +}) diff --git a/packages/experimental/forge-session/tests/service.spec.ts b/packages/experimental/forge-session/tests/service.spec.ts index 9e208183fe..beea067983 100644 --- a/packages/experimental/forge-session/tests/service.spec.ts +++ b/packages/experimental/forge-session/tests/service.spec.ts @@ -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)) diff --git a/packages/experimental/forge-session/tsconfig.json b/packages/experimental/forge-session/tsconfig.json index 4ff0bd48d4..a939ef56e9 100644 --- a/packages/experimental/forge-session/tsconfig.json +++ b/packages/experimental/forge-session/tsconfig.json @@ -31,6 +31,18 @@ }, { "path": "../../util/values" + }, + { + "path": "../../shell/shell" + }, + { + "path": "../../shell/shell-env" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../sandbox/sandbox-policy" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfb1c0ce7e..74b60fc20d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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'} + shell-quote@1.12.0: + resolution: {integrity: sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==} + engines: {node: '>= 0.4'} + sherpa-onnx-darwin-arm64@1.13.8: resolution: {integrity: sha512-FPNgJMgnWVl/KhRTIhG3KL3A4Om63Rn4YKXc9/uHY7SzLcvqLJLc/h7UBWJwduXvv7K18t5NpxHR6XgXn4sjWw==} cpu: [arm64] @@ -32641,6 +32654,8 @@ snapshots: shebang-regex@3.0.0: {} + shell-quote@1.12.0: {} + sherpa-onnx-darwin-arm64@1.13.8: optional: true