Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions .qwen/e2e-tests/2026-08-27-webshell-session-issue-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# E2E Test Plan: Session Issue Binding Derived From Bound PRs

## Scope

The daemon's PR refresh sweep snapshots the issues each bound PR closes
(GitHub `closingIssuesReferences`, with state) into the existing `.pr.json`
sidecar; the Web Shell tooltip lists them and sidebar search matches their
numbers. No new write path, sidecar, or env switch.

## Baseline dry-run

```bash
qwen --version
```

With the released CLI, a session bound to a PR whose body says `Fixes #N`
shows only the PR in the session tooltip; searching `N` in the sidebar does
not find the session; the sidecar entry carries no `issues` field.

## Group A: core contract

```bash
cd packages/core
npx vitest run src/services/session-pr-service.test.ts src/utils/github-pr-issues.test.ts
```

Expected: an `issues` list round-trips; a non-http(s) issue url, an unknown
issue state, or more than 10 issues voids the sidecar; a same-PR re-bind
keeps the snapshot while a foreign-repo re-bind drops it;
`updateSessionPrStates` writes `issues` with or without a `state` and skips
the write when nothing changed. The GraphQL wrapper aliases one
`pullRequest(number:)` per PR, maps OPEN / CLOSED+COMPLETED /
CLOSED+NOT_PLANNED / CLOSED+DUPLICATE to open / completed / not_planned /
not_planned, keeps resolved aliases when gh exits non-zero over a NOT_FOUND
number, chunks at 100, and maps a missing binary to `cli_unavailable`.

## Group B: daemon sweep and listing

```bash
cd packages/cli
npx vitest run src/serve/server/session-pr-refresh.test.ts
npx vitest run src/serve/server.test.ts -t sidecar
cd ../acp-bridge && npx vitest run src/bridge.test.ts -t "SessionPrs|re-bind"
Comment thread
wenshao marked this conversation as resolved.
Outdated
cd ../sdk-typescript && npx vitest run test/unit/sessionPr.test.ts
```

Expected: an open binding gets its state and issues in one write with
`createdAt` untouched; merged bindings without a snapshot get one by-number
lookup and no list query, and neither query once snapshotted; a foreign-repo
binding never receives this repository's issues; a failed issue lookup still
refreshes states; the retired-generation guard covers the new lookup. The
session list prefers sidecar `issues` over the live entry; the bridge echoes
seeded issues and keeps them across a state-only re-bind; the SDK guard
accepts the three issue states and rejects `javascript:` issue urls.

## Group C: real gh against a real repository

From a checkout of a GitHub repository with `gh auth` configured, seed a
temporary runtime dir with one session bound to an open PR that references
an open issue, a merged PR that references a closed issue, and a foreign-repo
PR number, then call `refreshWorkspaceSessionPrStates` from the built cli
dist twice.

Expected: round one reports `updated: 2` and the sidecar carries
`state: "open"` / `state: "completed"` issues on the two real PRs while the
foreign entry is untouched; round two reports `updated: 0`; each round
finishes well inside the 10s gh timeout.

## Group D: Web Shell

```bash
cd packages/web-shell
npx vitest run client/components/sidebar/SessionDetailsTooltip.test.tsx client/components/sidebar/sessionSearch.test.ts
```

Manual: run `qwen serve`, open the Web Shell, create a PR from the Git dialog
with `Fixes #N` in the body, wait for the first sweep (60s after daemon
start, then every 5 minutes), hover the session row.

Expected: the tooltip lists `Issue #N` under the PR rows with a green
circle-dot icon (purple check once the issue is closed as completed, muted
slash for not planned), the link opens the issue, a stacked PR closing the
same issue lists it once, and typing `N` or `#N` in the sidebar search finds
the session. The session-row badge still shows only the PR.
103 changes: 103 additions & 0 deletions docs/design/2026-08-27-webshell-session-issue-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Web Shell 会话绑定 GitHub Issue 号(由 PR 派生)

日期:2026-08-27
状态:已确认范围(路线 A)

## 问题

[会话绑定 PR 号](2026-08-20-webshell-session-pr-binding.md)落地后,侧栏能回答"哪个会话产出了 PR #N",但回答不了"哪个会话在处理 issue #N"。维护者的主力流程(bugfix / triage / autofix develop-issue)都从 issue 出发,最终以 PR 收口。

Issue 和 PR 的语义不同:PR 是会话的**产出**,会话自己在 GitDialog 里点了 Create PR,daemon 有一个精确的写入点;Issue 是会话的**意图**,web-shell 里没有任何结构化写入点(`/bugfix #N` 是提示词参数,`create-issue` skill 是模型自己跑 `gh issue create`),而 PR 与 Issue 共用编号空间,扫描提示词或分支名会误绑。

## 方案:从已绑 PR 派生

GitHub 上 PR 与 Issue 的关联已经存在——PR 正文里的 `Fixes #N` 就是 `closingIssuesReferences`。不新增写入点,也不新增 sidecar;daemon 现有的 PR 状态刷新定时器顺手把每个已绑 PR 关闭的 issue 及其状态快照进同一个 sidecar 条目。

### 数据模型

`SessionPr` 条目增加可选字段:

```json
{
"number": 10303,
"url": "https://github.com/QwenLM/qwen-code/pull/10303",
"createdAt": "...",
"state": "open",
"issues": [
{
"number": 10293,
"url": "https://github.com/QwenLM/qwen-code/issues/10293",
"state": "open"
}
]
}
```

- `issues` 缺省表示"尚未抓取";空数组表示"抓过,没有"。二者区别决定 sweep 是否还需要为该 PR 发查询。
- `state`:`open` / `completed` / `not_planned`(GitHub 的 `stateReason` NOT_PLANNED 与 DUPLICATE 都归 `not_planned`)。
- 每个 PR 最多保留 10 个 issue(`SESSION_PR_ISSUE_LIST_LIMIT`,GraphQL 也只取 `first: 10`);url 与 PR url 同样只接受 http(s)、限长、无控制字符——core sidecar 校验、bridge 类型、SDK `isDaemonSessionPrInfo` 三层同步。
- 派生数据跟着来源走:PR 条目被 cap 淘汰时 issue 一起走;同 PR 重绑(`upsertSessionPr` / bridge `updateSessionMetadata`)保留已有 `issues`,跨仓库同号 PR 不继承。客户端永远不能写 `issues`。

### 抓取

新增 core 工具 `fetchGitHubPullRequestIssues(cwd, env, numbers)`,一条 `gh api graphql`,用 gh 自带的 `{owner}` / `{repo}` 占位符解析仓库,按编号别名查询:

```graphql
p10303: pullRequest(number: 10303) {
number url
closingIssuesReferences(first: 10) { nodes { number url state stateReason } }
}
```

为什么不在 `gh pr list --json` 上追加 `closingIssuesReferences`:

1. 该字段不带 issue state,仍需第二条查询;
2. 实测 `--state all --limit 500` 加该字段从 4.9s 涨到 6.7s(gh 超时 10s),现有 sweep 的 list 查询保持原样零风险;
3. 按编号查询不受 500 条窗口限制,老 PR 也能补齐。

每次调用最多 100 个别名,超出分批;任一批失败整体返回 `failed`。gh 对 NOT_FOUND 别名(绑定指向别的仓库的同号 PR)以非零退出码返回,但 stdout 仍带其它别名的完整数据——包装器在 stdout 有 JSON 时照常解析,未解析的别名直接缺席。

### Sweep 集成

`refreshWorkspaceSessionPrStates` 每 workspace 每轮:

1. 扫描 sidecar,挑出 `state !== 'merged' || issues === undefined` 的绑定(open/closed 的 closing references 会随正文编辑变化;merged 但无快照的是升级前的存量,只补一次)。
2. 若存在非 merged 绑定,跑原有 slim `gh pr list --state all` 刷 PR state(不变)。
3. 对第 1 步的编号去重后跑一次 GraphQL 拿 issues。
4. 每个 sidecar 一次 `updateSessionPrStates` 原地写入 state + issues:url 不匹配不写;`state` / `issues` 任一缺省则保留原值;都无变化则不写文件。

成本:全 merged 且已有快照的 workspace 零调用;否则多一条 ~1–3s 的 GraphQL。已合入 PR 的 issue 之后被 reopen 不再跟踪(与"merged 是终态"同一取舍)。

### 线协议与展示

- bridge `SessionPrInfo` / SDK `DaemonSessionPrInfo` 增加 `issues?`;所有 sidecar → 线协议投影统一走 core 新增的 `toSessionPrInfo`(原先散落在 session-list / session.ts / dispatch / backfill / bridge 共 8 处手写的 `{number, url, state?}`)。
- `mergeSummaryPrs`:`issues` 与 `state` 一样以 sidecar 为准(live entry 停在绑定时刻)。
- web-shell:
- `SessionDetailsTooltip` 在 PR 行之后列出 issue(按 url 去重,stacked PR 关同一个 issue 只列一次),复用 GitHub 视觉词汇:open 绿 circle-dot、completed 紫 circle-check、not planned 灰 circle-slash;可见文本 `Issue #N`,sr-only 追加状态。
- 侧栏搜索 `sessionMatchesGitQuery` 命中 issue 号(带不带 `#` 都行)。
- 会话行 badge 保持只显示 PR,避免 `#N` 并排歧义。
- 时延:GitDialog 创建 PR 后,issue 在下一轮 sweep(首轮 60s 延迟、之后 5 分钟)出现;`QWEN_SESSION_PR_REFRESH_MINUTES` 不变。

## 关键决策

- **派生而非绑定**:唯一高精度的来源是 GitHub 自己的 closing references;显式输入绑定(`/bugfix #N` 拦截、欢迎页入口)留作后续路线,届时再考虑独立 sidecar。
- **挂在 PR 条目上而非独立 sidecar**:派生数据的生命周期等于来源 PR,独立文件会引入第二套 cap、归档移动和写入 lane。
- **保留 `gh pr list` 不动**:sweep 的 list 查询已经贴近超时上限,issue 单独走按编号 GraphQL。

## 影响文件

| 层 | 文件 |
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| core | `services/session-pr-service.ts`(`SessionPrIssue`、校验、`toSessionPrInfo`、`updateSessionPrStates` 扩展)、`utils/github-pr-issues.ts`(新增) |
| daemon | `serve/server/session-pr-refresh.ts`(sweep 第二阶段)、`session-list.ts`、`routes/session.ts`、`acp-http/dispatch.ts`、`routes/session-pr-backfill.ts`(投影) |
| bridge | `bridgeTypes.ts`、`bridge.ts`(类型、重绑保留、投影) |
| SDK | `daemon/types.ts`、`daemon/session-pr.ts`、`daemon/index.ts` |
| web-shell | `SessionPrStateIcon.tsx`(+css)、`sidebar/SessionDetailsTooltip.tsx`、`sidebar/sessionSearch.ts`、`i18n.tsx` |
| 测试 | 上述各层 collocated 单测 |

## 范围边界(明确不做)

- 显式 issue 绑定入口与独立 issue sidecar。
- 从提示词 / 分支名 / commit trailer 反推 issue 号。
- 会话行 badge 显示 issue;回填路由立即抓 issue(下一轮 sweep 兜底)。
- 已合入 PR 的 issue 被 reopen 后的状态跟踪。
42 changes: 42 additions & 0 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27961,11 +27961,19 @@ describe('createAcpSessionBridge', () => {
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });

const issues = [
{
number: 7,
url: 'https://github.com/o/r/issues/7',
state: 'completed' as const,
},
];
bridge.seedSessionPrs?.(session.sessionId, [
{
number: 9500,
url: 'https://github.com/o/r/pull/9500',
state: 'merged',
issues,
},
]);

Expand All @@ -27974,6 +27982,40 @@ describe('createAcpSessionBridge', () => {
number: 9500,
url: 'https://github.com/o/r/pull/9500',
state: 'merged',
issues,
},
]);

await bridge.closeSession(session.sessionId);
await bridge.shutdown();
});

it('keeps the seeded issue snapshot on a re-bind of the same pr', async () => {
const bridge = makeBridge({
channelFactory: async () => makeChannel().channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const issues = [{ number: 7, url: 'https://github.com/o/r/issues/7' }];
bridge.seedSessionPrs?.(session.sessionId, [
{ number: 9500, url: 'https://github.com/o/r/pull/9500', issues },
]);

// The client never binds issues; the daemon-derived snapshot survives
// a re-bind that only carries a new state.
Comment thread
wenshao marked this conversation as resolved.
bridge.updateSessionMetadata(session.sessionId, {
pr: {
number: 9500,
url: 'https://github.com/o/r/pull/9500',
state: 'merged',
},
});

expect(bridge.getSessionSummary(session.sessionId).prs).toEqual([
{
number: 9500,
url: 'https://github.com/o/r/pull/9500',
state: 'merged',
issues,
},
]);

Expand Down
19 changes: 5 additions & 14 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
TURN_RESULT_TEXT_MAX_CHARS,
TrustGateError,
canonicalSessionPrUrl,
toSessionPrInfo,
normalizeTurnResultError,
normalizeSnapshotPayload,
ShellExecutionService,
Expand Down Expand Up @@ -10396,6 +10397,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
known?.state) as SessionPrInfo['state'],
}
: {}),
// The issue snapshot is daemon-derived, never client-bound.
...(known?.issues ? { issues: known.issues } : {}),
Comment thread
wenshao marked this conversation as resolved.
},
].slice(-SESSION_PR_LIST_LIMIT);
markSessionCatalogChanged();
Expand Down Expand Up @@ -10436,25 +10439,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
seedSessionPrs(sessionId, prs) {
const entry = byId.get(sessionId);
if (!entry || (entry.prs && entry.prs.length > 0)) return;
entry.prs = prs
.map(({ number, url, state }) => ({
number,
url,
...(state ? { state } : {}),
}))
.slice(-SESSION_PR_LIST_LIMIT);
entry.prs = prs.map(toSessionPrInfo).slice(-SESSION_PR_LIST_LIMIT);
},

setSessionPrs(sessionId, prs) {
const entry = byId.get(sessionId);
if (!entry) return;
entry.prs = prs
.map(({ number, url, state }) => ({
number,
url,
...(state ? { state } : {}),
}))
.slice(-SESSION_PR_LIST_LIMIT);
entry.prs = prs.map(toSessionPrInfo).slice(-SESSION_PR_LIST_LIMIT);
},

async getSessionArtifacts(sessionId, context) {
Expand Down
8 changes: 8 additions & 0 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,14 @@ export interface SessionPrInfo {
url: string;
/** Snapshot of the PR's state at last bind/refresh; optional. */
state?: 'open' | 'merged' | 'closed';
/** Issues the PR closes, snapshotted by the daemon refresh; optional. */
issues?: SessionPrIssueInfo[];
Comment thread
wenshao marked this conversation as resolved.
}

export interface SessionPrIssueInfo {
number: number;
url: string;
state?: 'open' | 'completed' | 'not_planned';
}

export interface SessionMetadataUpdate {
Expand Down
7 changes: 2 additions & 5 deletions packages/cli/src/serve/acp-http/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
WorkspaceMemoryWriteTimeoutError,
writeWorkspaceContextFile,
readSessionPrs,
toSessionPrInfo,
upsertSessionPr,
type SessionArchiveState,
type SubagentLevel,
Expand Down Expand Up @@ -3068,11 +3069,7 @@ export class AcpDispatcher {
: {}),
},
)
).map(({ number, url, state }) => ({
number,
url,
...(state ? { state } : {}),
}));
).map(toSessionPrInfo);
// Reply with the authoritative persisted list, mirroring the
// REST metadata routes.
result = { ...result, prs: persistedPrs };
Expand Down
7 changes: 2 additions & 5 deletions packages/cli/src/serve/routes/session-pr-backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
readSessionPrs,
readWorktreeSession,
replaceSessionPrs,
toSessionPrInfo,
type SessionArchiveState,
type SessionPr,
} from '@qwen-code/qwen-code-core';
Expand Down Expand Up @@ -507,11 +508,7 @@ export async function backfillWorkspaceSessionPrs(
assertGenerationOpen();
runtime.bridge.setSessionPrs?.(
candidate.sessionId,
fresh.map(({ number, url, state }) => ({
number,
url,
...(state ? { state } : {}),
})),
fresh.map(toSessionPrInfo),
);
return null;
});
Expand Down
19 changes: 4 additions & 15 deletions packages/cli/src/serve/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
writeWorktreeSession,
readWorktreeSession,
readSessionPrs,
toSessionPrInfo,
upsertSessionPr,
SESSION_PR_URL_MAX_LENGTH,
type ApprovalMode,
Expand Down Expand Up @@ -5788,11 +5789,7 @@ export function registerSessionRoutes(
service.getPrSessionPathForArchiveState(sessionId, 'active'),
pr,
)
).map(({ number, url, state }) => ({
number,
url,
...(state ? { state } : {}),
}));
).map(toSessionPrInfo);
effective = { ...effective, prs: persistedPrs };
}
} finally {
Expand Down Expand Up @@ -5955,11 +5952,7 @@ export function registerSessionRoutes(
),
pr,
)
).map(({ number, url, state }) => ({
number,
url,
...(state ? { state } : {}),
}));
).map(toSessionPrInfo);
assertRuntimeGenerationOpen?.();
effective = { ...effective, prs: persistedPrs };
}
Expand All @@ -5985,11 +5978,7 @@ export function registerSessionRoutes(
pr,
);
assertRuntimeGenerationOpen?.();
effective.prs = persisted.map(({ number, url, state }) => ({
number,
url,
...(state ? { state } : {}),
}));
effective.prs = persisted.map(toSessionPrInfo);
}
if (displayName !== undefined) {
const renamed = await service.renameSession(
Expand Down
Loading
Loading