Browse Source

fix(session): align browser fixture forks and legal log regressions

Dudu-0223 1 tuần trước cách đây
mục cha
commit
88c3d05fbf

+ 2 - 2
packages/api/session-controller/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: 8429b2f73199946944ca5a4e634b27d5d2f91f75
-README.zh.md: 3c20a2bfad7d29b764e9c47641e665721da8dce9
+README.md: 15f16200d66949163ac8c1e5dbb2994756598dfc
+README.zh.md: 63641025575b61a38d1e06485dc5ea546101f5b6

+ 0 - 1
packages/api/session-controller/README.md

@@ -39,7 +39,6 @@ The user-invocable `skills/list` metadata includes the winning provider’s opti
 
 Fork copies history through the selected completed turn, including its `turn/end`. Events after that point, including queued input and model-setting changes, are excluded. An omitted or past-end anchor selects the last completed turn; an anchor inside an unfinished turn is rejected.
 
-
 <a id="session-media-references"></a>
 ## Session media references
 

+ 0 - 1
packages/api/session-controller/README.zh.md

@@ -39,7 +39,6 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
 
 分叉复制截至选中已结束轮次的历史,并包含其 `turn/end`。该位置之后的事件均被排除,包括排队输入和模型设置变更。省略锚点或锚点超出日志末尾时,选择最后一个已结束轮次;位于未结束轮次内的锚点会被拒绝。
 
-
 <a id="session-media-references"></a>
 ## 会话媒体引用
 

+ 9 - 2
packages/api/session-controller/tests/session-fork.host.spec.ts

@@ -331,16 +331,23 @@ describe('sessions.fork', () => {
       },
       reason: 'initial',
     })
-    source.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    const boundary = source.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    const inherited = source.snapshotEvents()
+    source.append('session/title', {
+      title: 'Title after the selected turn', messageSeqs: [], source: { kind: 'user' },
+    })
+    source.append('turn/start', { turn: 2 })
     source.append('request/header', {
       header: { config: { provider: 'later-provider', model: 'later-model' } },
       reason: 'change',
     })
-    const response = await remote(ctx).fork(request({ sessionId: source.id }))
+    source.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
+    const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: boundary.seq }))
     expect(response.ok).toBe(true)
     if (!response.ok) return
     const child = ctx.agents.get(response.value.sessionId)
     if (child === undefined) throw new Error('fork did not publish the child agent')
+    expect(child.session.snapshotEvents().slice(0, child.session.inheritedEventCount)).toEqual(inherited)
     const assembly = await child.ctx.systemPrompt.assemble()
     expect(assembly.variables).toMatchObject({
       provider: 'inherited-provider',

+ 2 - 2
packages/client/connection/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/connection/README.md
-README.md: 865c894403d2177e15085d0616289cf8950b555d
-README.zh.md: d3d0a62035b1770ecc1b9f81aa775dad0c86cb11
+README.md: 606f27f37a80c11c105a1431858efae7b8e9d86a
+README.zh.md: d7885ad873fb1d7d82c7a613c8f66bc7356b2542

+ 2 - 0
packages/client/connection/README.md

@@ -27,6 +27,8 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con
 
 The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md).
 
+The browser fixture follows the [Session Controller fork semantics](../../api/session-controller/README.md#use-this-package).
+
 -----
 
 <a id="browser-authentication-and-request-trust"></a>

+ 2 - 0
packages/client/connection/README.zh.md

@@ -27,6 +27,8 @@ kind: "package-reference"
 
 浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。
 
+浏览器 fixture 遵循 [Session Controller 的 fork 语义](../../api/session-controller/README.zh.md#use-this-package)。
+
 -----
 
 <a id="browser-authentication-and-request-trust"></a>

+ 1 - 2
packages/client/connection/src/client/fixture.ts

@@ -3249,8 +3249,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
           details: { sessionId },
         })
       }
-      let cut = boundary.seq + 1
-      while (cut < log.length && log[cut]?.type !== 'turn/start') cut++
+      const cut = boundary.seq + 1
       const child: FixtureSessionSummary = {
         sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false,
         parentSessionId: sessionId,

+ 29 - 0
packages/client/connection/tests/fixture.client.spec.ts

@@ -1635,6 +1635,35 @@ describe('fixture Connection RPC', () => {
     vi.unstubAllGlobals()
   })
 
+  it.each(['turn-end', 'omitted', 'past-end'] as const)('forks through the completed turn for a %s anchor', async (anchor) => {
+    vi.stubGlobal('__fxTiming', (globalThis as Record<string, unknown>).__fxTiming)
+    const { rpc } = createFixtureFaces()
+    const sessions = createSessionClient(rpc)
+    const sourceId = sid('fx-alpha')
+    const initial = await sessions.history({ sessionId: sourceId, maxMessages: 1000 })
+    if (!initial.result.ok) throw new Error('source history failed')
+    const events = historyEvents(initial.result.value.records)
+    const boundary = events.findLast(event => event.type === 'turn/end')
+    if (boundary === undefined) throw new Error('source has no completed turn')
+    expect(initial.result.value.hasMore).toBe(false)
+    expect((await sessions.rename({ sessionId: sourceId, title: 'Title after the selected turn' })).result.ok).toBe(true)
+    const source = await sessions.history({ sessionId: sourceId, maxMessages: 1000 })
+    if (!source.result.ok) throw new Error('source history after rename failed')
+    const fork = await rpc.call('/api', 'session/fork', {
+      args: { request: {
+        sessionId: sourceId,
+        ...(anchor === 'omitted' ? {} : { atSeq: anchor === 'turn-end' ? boundary.seq : events.length + 100 }),
+      } },
+    })
+    if (!fork.ok) throw new Error('fork failed')
+    const childId = (fork.value as { sessionId: SessionId }).sessionId
+    const child = await sessions.history({ sessionId: childId, maxMessages: 1000 })
+    if (!child.result.ok) throw new Error('child history failed')
+    expect(child.result.value.hasMore).toBe(false)
+    expect(historyEvents(child.result.value.records)).toEqual(events.slice(0, boundary.seq + 1))
+    expect(await sessions.history({ sessionId: sourceId, maxMessages: 1000 })).toMatchObject({ result: source.result })
+  })
+
   it('covers the migrated Remote dispatch table', async () => {
     const rpc = createFixtureConnectionRpc()
     const sessions = createSessionClient(rpc)