Skip to content

Commit b9ffad3

Browse files
committed
feat(gateway,runtime): 会话附件删除接口与图片输入模型兼容投影
- 从 RuntimePort 拆出 SessionAssetPort 独立端口,新增 DeleteSessionAsset 方法 - Gateway 注册 DELETE /api/session-assets 端点,多工作区路由和安全 ACL 适配 - CLI Bridge 实现 DeleteSessionAsset,修复 Save 缺少会话存在性校验、Open 缺少 os.ErrNotExist 映射 - Runtime 图片投影:不支持图片的模型对历史图片替换为占位文本,不修改持久化消息 - Runtime 拒绝不支持的当前图片输入,避免上游 400 - Web ChatInput 取消上传时调用 deleteSessionAsset 释放服务端资源 - Web useSessionStore 新增 _pendingNewSession 防止新会话期间自动切换
1 parent eac671d commit b9ffad3

40 files changed

Lines changed: 1039 additions & 198 deletions

internal/cli/cli_ux_test.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,3 @@ func TestLegacyFeishuAdapterCommandShowsMigrationHint(t *testing.T) {
100100
t.Fatalf("err = %v, want contains adapter feishu", err)
101101
}
102102
}
103-

internal/cli/gateway_runtime_bridge.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ type runtimeRunCanceler interface {
4040
CancelRun(runID string) bool
4141
}
4242

43+
type sessionAssetDeleter interface {
44+
DeleteAsset(ctx context.Context, sessionID string, assetID string) error
45+
}
46+
4347
type runtimeSessionCreator interface {
4448
CreateSession(ctx context.Context, id string) (agentsession.Session, error)
4549
}
@@ -709,6 +713,19 @@ func (b *gatewayRuntimePortBridge) SaveSessionAsset(
709713
if sessionID == "" {
710714
return gateway.SessionAssetMeta{}, gateway.ErrRuntimeResourceNotFound
711715
}
716+
if b.sessionStore == nil {
717+
return gateway.SessionAssetMeta{}, fmt.Errorf("gateway runtime bridge: session store is unavailable")
718+
}
719+
loader, ok := b.sessionStore.(bridgeSessionLoader)
720+
if !ok {
721+
return gateway.SessionAssetMeta{}, fmt.Errorf("gateway runtime bridge: session asset store is unavailable")
722+
}
723+
if _, err := loader.LoadSession(ctx, sessionID); err != nil {
724+
if isRuntimeNotFoundError(err) {
725+
return gateway.SessionAssetMeta{}, gateway.ErrRuntimeResourceNotFound
726+
}
727+
return gateway.SessionAssetMeta{}, err
728+
}
712729
assetStore, ok := b.sessionStore.(agentsession.AssetStore)
713730
if !ok || assetStore == nil {
714731
return gateway.SessionAssetMeta{}, fmt.Errorf("gateway runtime bridge: session asset store is unavailable")
@@ -744,6 +761,9 @@ func (b *gatewayRuntimePortBridge) OpenSessionAsset(
744761
}
745762
reader, meta, err := assetStore.Open(ctx, sessionID, assetID)
746763
if err != nil {
764+
if isRuntimeNotFoundError(err) || errors.Is(err, os.ErrNotExist) {
765+
return gateway.OpenSessionAssetResult{}, gateway.ErrRuntimeResourceNotFound
766+
}
747767
return gateway.OpenSessionAssetResult{}, err
748768
}
749769
return gateway.OpenSessionAssetResult{
@@ -757,6 +777,32 @@ func (b *gatewayRuntimePortBridge) OpenSessionAsset(
757777
}, nil
758778
}
759779

780+
// DeleteSessionAsset 删除当前工作区的会话附件,供 Web 在取消上传引用时释放服务端文件。
781+
func (b *gatewayRuntimePortBridge) DeleteSessionAsset(ctx context.Context, input gateway.DeleteSessionAssetInput) error {
782+
if err := b.ensureRuntimeAccess(input.SubjectID); err != nil {
783+
return err
784+
}
785+
sessionID := strings.TrimSpace(input.SessionID)
786+
assetID := strings.TrimSpace(input.AssetID)
787+
if sessionID == "" || assetID == "" {
788+
return gateway.ErrRuntimeResourceNotFound
789+
}
790+
if b.sessionStore == nil {
791+
return fmt.Errorf("gateway runtime bridge: session store is unavailable")
792+
}
793+
deleter, ok := b.sessionStore.(sessionAssetDeleter)
794+
if !ok || deleter == nil {
795+
return fmt.Errorf("gateway runtime bridge: session asset store does not support delete")
796+
}
797+
if err := deleter.DeleteAsset(ctx, sessionID, assetID); err != nil {
798+
if isRuntimeNotFoundError(err) {
799+
return nil
800+
}
801+
return err
802+
}
803+
return nil
804+
}
805+
760806
// DeleteSession 删除/归档指定会话。
761807
func (b *gatewayRuntimePortBridge) DeleteSession(ctx context.Context, input gateway.DeleteSessionInput) (bool, error) {
762808
if err := b.ensureRuntimeAccess(input.SubjectID); err != nil {
@@ -2645,6 +2691,7 @@ type manualModelPayload struct {
26452691
}
26462692

26472693
var _ gateway.RuntimePort = (*gatewayRuntimePortBridge)(nil)
2694+
var _ gateway.SessionAssetPort = (*gatewayRuntimePortBridge)(nil)
26482695

26492696
func (b *gatewayRuntimePortBridge) ListCheckpoints(ctx context.Context, input gateway.ListCheckpointsInput) ([]gateway.CheckpointEntry, error) {
26502697
cp, ok := b.runtime.(runtimeCheckpointer)

internal/cli/gateway_runtime_bridge_test.go

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1594,6 +1594,7 @@ func TestGatewayRuntimePortBridgeSessionAssets(t *testing.T) {
15941594

15951595
workdir := t.TempDir()
15961596
store := agentsession.NewSQLiteStore(t.TempDir(), workdir)
1597+
t.Cleanup(func() { _ = store.Close() })
15971598
session := agentsession.NewWithWorkdir("asset session", workdir)
15981599
if _, err := store.CreateSession(context.Background(), agentsession.CreateSessionInput{
15991600
ID: session.ID,
@@ -1637,14 +1638,38 @@ func TestGatewayRuntimePortBridgeSessionAssets(t *testing.T) {
16371638
if err != nil {
16381639
t.Fatalf("OpenSessionAsset() error = %v", err)
16391640
}
1640-
defer opened.Reader.Close()
16411641
got, err := io.ReadAll(opened.Reader)
16421642
if err != nil {
16431643
t.Fatalf("ReadAll() error = %v", err)
16441644
}
16451645
if string(got) != string(payload) || opened.Meta.AssetID != meta.AssetID || opened.Meta.MimeType != "image/png" {
16461646
t.Fatalf("unexpected opened asset meta=%+v payload=%q", opened.Meta, string(got))
16471647
}
1648+
if err := opened.Reader.Close(); err != nil {
1649+
t.Fatalf("Close opened asset reader: %v", err)
1650+
}
1651+
1652+
if err := bridge.DeleteSessionAsset(context.Background(), gateway.DeleteSessionAssetInput{
1653+
SubjectID: testBridgeSubjectID,
1654+
SessionID: session.ID,
1655+
AssetID: meta.AssetID,
1656+
}); err != nil {
1657+
t.Fatalf("DeleteSessionAsset() error = %v", err)
1658+
}
1659+
if err := bridge.DeleteSessionAsset(context.Background(), gateway.DeleteSessionAssetInput{
1660+
SubjectID: testBridgeSubjectID,
1661+
SessionID: session.ID,
1662+
AssetID: meta.AssetID,
1663+
}); err != nil {
1664+
t.Fatalf("DeleteSessionAsset() should be idempotent, got %v", err)
1665+
}
1666+
if _, err := bridge.OpenSessionAsset(context.Background(), gateway.OpenSessionAssetInput{
1667+
SubjectID: testBridgeSubjectID,
1668+
SessionID: session.ID,
1669+
AssetID: meta.AssetID,
1670+
}); !errors.Is(err, gateway.ErrRuntimeResourceNotFound) {
1671+
t.Fatalf("OpenSessionAsset() after delete error = %v, want resource not found", err)
1672+
}
16481673
}
16491674

16501675
func TestGatewayRuntimePortBridgeSessionAssetErrors(t *testing.T) {
@@ -1683,6 +1708,13 @@ func TestGatewayRuntimePortBridgeSessionAssetErrors(t *testing.T) {
16831708
}); err == nil || !strings.Contains(err.Error(), "asset store is unavailable") {
16841709
t.Fatalf("expected unavailable asset store save error, got %v", err)
16851710
}
1711+
if err := bridge.DeleteSessionAsset(context.Background(), gateway.DeleteSessionAssetInput{
1712+
SubjectID: testBridgeSubjectID,
1713+
SessionID: "session-1",
1714+
AssetID: "asset-1",
1715+
}); err == nil || !strings.Contains(err.Error(), "does not support delete") {
1716+
t.Fatalf("expected unavailable asset store delete error, got %v", err)
1717+
}
16861718
if _, err := bridge.OpenSessionAsset(context.Background(), gateway.OpenSessionAssetInput{
16871719
SubjectID: testBridgeSubjectID,
16881720
SessionID: "session-1",
@@ -1692,6 +1724,32 @@ func TestGatewayRuntimePortBridgeSessionAssetErrors(t *testing.T) {
16921724
}
16931725
}
16941726

1727+
func TestGatewayRuntimePortBridgeSessionAssetSaveRequiresExistingSession(t *testing.T) {
1728+
t.Parallel()
1729+
1730+
store := agentsession.NewSQLiteStore(t.TempDir(), t.TempDir())
1731+
t.Cleanup(func() { _ = store.Close() })
1732+
bridge, err := newGatewayRuntimePortBridge(
1733+
context.Background(),
1734+
&runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)},
1735+
store,
1736+
)
1737+
if err != nil {
1738+
t.Fatalf("new bridge: %v", err)
1739+
}
1740+
defer bridge.Close()
1741+
1742+
_, err = bridge.SaveSessionAsset(context.Background(), gateway.SaveSessionAssetInput{
1743+
SubjectID: testBridgeSubjectID,
1744+
SessionID: "missing-session",
1745+
Reader: strings.NewReader("x"),
1746+
MimeType: "image/png",
1747+
})
1748+
if !errors.Is(err, gateway.ErrRuntimeResourceNotFound) {
1749+
t.Fatalf("SaveSessionAsset() missing session error = %v, want resource not found", err)
1750+
}
1751+
}
1752+
16951753
func TestConvertRuntimeSessionToGatewaySessionIncludesCurrentPlan(t *testing.T) {
16961754
required := true
16971755
session := agentsession.New("plan session")

internal/config/runtime_hooks.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -286,14 +286,14 @@ func (c RuntimeHookItemConfig) Validate(defaultFailurePolicy string) error {
286286
default:
287287
return fmt.Errorf("handler %q is not supported", c.Handler)
288288
}
289-
if handler == runtimeHookHandlerWarnOnToolCall && !hooks.HasHookMatcherConfig(c.Match) {
290-
return fmt.Errorf("handler %q requires match", c.Handler)
291-
}
292-
if hooks.HasHookMatcherConfig(c.Match) {
293-
if err := hooks.ValidateHookMatcher(point, c.Match); err != nil {
294-
return fmt.Errorf("match: %w", err)
295-
}
289+
if handler == runtimeHookHandlerWarnOnToolCall && !hooks.HasHookMatcherConfig(c.Match) {
290+
return fmt.Errorf("handler %q requires match", c.Handler)
291+
}
292+
if hooks.HasHookMatcherConfig(c.Match) {
293+
if err := hooks.ValidateHookMatcher(point, c.Match); err != nil {
294+
return fmt.Errorf("match: %w", err)
296295
}
296+
}
297297
case runtimeHookKindCommand:
298298
if normalizedMode != runtimeHookModeSync {
299299
return fmt.Errorf("mode %q is not supported for kind command (only sync)", c.Mode)

internal/config/runtime_hooks_test.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ func TestRuntimeHooksConfigItemDefaultsAndClone(t *testing.T) {
398398
},
399399
},
400400
},
401-
}
401+
}
402402
cfg.ApplyDefaults(defaultRuntimeHooksConfig())
403403

404404
item := cfg.Items[0]
@@ -666,7 +666,6 @@ func TestRuntimeHooksConfigEdgeBranches(t *testing.T) {
666666
t.Fatal("expected deep clone for nested map in slice")
667667
}
668668

669-
670669
matchCfg := RuntimeHookItemConfig{
671670
Match: map[string]any{
672671
"tool_name_regex": []any{`^bash$`},

internal/gateway/contracts.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,16 @@ type OpenSessionAssetInput struct {
262262
AssetID string
263263
}
264264

265+
// DeleteSessionAssetInput 表示删除会话附件的下游输入。
266+
type DeleteSessionAssetInput struct {
267+
// SubjectID 是请求方身份主体标识。
268+
SubjectID string
269+
// SessionID 是附件所属会话标识。
270+
SessionID string
271+
// AssetID 是附件标识。
272+
AssetID string
273+
}
274+
265275
// OpenSessionAssetResult 表示打开会话附件后的读取结果。
266276
type OpenSessionAssetResult struct {
267277
// Reader 是附件内容流,调用方负责关闭。
@@ -965,10 +975,6 @@ type RuntimePort interface {
965975
GetRuntimeSnapshot(ctx context.Context, input GetRuntimeSnapshotInput) (RuntimeSnapshot, error)
966976
// CreateSession 创建并返回可用会话标识。
967977
CreateSession(ctx context.Context, input CreateSessionInput) (string, error)
968-
// SaveSessionAsset 保存会话附件并返回元数据。
969-
SaveSessionAsset(ctx context.Context, input SaveSessionAssetInput) (SessionAssetMeta, error)
970-
// OpenSessionAsset 打开会话附件供 HTTP 读取接口返回。
971-
OpenSessionAsset(ctx context.Context, input OpenSessionAssetInput) (OpenSessionAssetResult, error)
972978
// DeleteSession 删除/归档指定会话。
973979
DeleteSession(ctx context.Context, input DeleteSessionInput) (bool, error)
974980
// RenameSession 重命名指定会话。
@@ -997,6 +1003,16 @@ type RuntimePort interface {
9971003
CheckpointDiff(ctx context.Context, input CheckpointDiffInput) (CheckpointDiffResult, error)
9981004
}
9991005

1006+
// SessionAssetPort 定义 Gateway HTTP 资产端点访问会话附件的独立下游端口。
1007+
type SessionAssetPort interface {
1008+
// SaveSessionAsset 保存会话附件并返回元数据。
1009+
SaveSessionAsset(ctx context.Context, input SaveSessionAssetInput) (SessionAssetMeta, error)
1010+
// OpenSessionAsset 打开会话附件供 HTTP 读取接口返回。
1011+
OpenSessionAsset(ctx context.Context, input OpenSessionAssetInput) (OpenSessionAssetResult, error)
1012+
// DeleteSessionAsset 删除已上传但不再需要的会话附件。
1013+
DeleteSessionAsset(ctx context.Context, input DeleteSessionAssetInput) error
1014+
}
1015+
10001016
// PlanApprovalRuntimePort 定义批准计划的可选下游能力。
10011017
type PlanApprovalRuntimePort interface {
10021018
// ApprovePlan 将指定 draft 计划 revision 推进到 approved。

internal/gateway/contracts_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,10 @@ func (s *runtimePortCompileStub) OpenSessionAsset(_ context.Context, _ OpenSessi
155155
return OpenSessionAssetResult{}, nil
156156
}
157157

158+
func (s *runtimePortCompileStub) DeleteSessionAsset(_ context.Context, _ DeleteSessionAssetInput) error {
159+
return nil
160+
}
161+
158162
func (s *runtimePortCompileStub) ListCheckpoints(_ context.Context, _ ListCheckpointsInput) ([]CheckpointEntry, error) {
159163
return nil, nil
160164
}
@@ -172,5 +176,6 @@ func (s *runtimePortCompileStub) CheckpointDiff(_ context.Context, _ CheckpointD
172176
}
173177

174178
var _ RuntimePort = (*runtimePortCompileStub)(nil)
179+
var _ SessionAssetPort = (*runtimePortCompileStub)(nil)
175180
var _ TransportAdapter = (*Server)(nil)
176181
var _ TransportAdapter = (*NetworkServer)(nil)

internal/gateway/multi_workspace_runtime.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -407,15 +407,36 @@ func (m *MultiWorkspaceRuntime) SaveSessionAsset(ctx context.Context, input Save
407407
if err != nil {
408408
return SessionAssetMeta{}, err
409409
}
410-
return port.SaveSessionAsset(ctx, input)
410+
assetPort, ok := port.(SessionAssetPort)
411+
if !ok {
412+
return SessionAssetMeta{}, ErrRuntimeUnavailable
413+
}
414+
return assetPort.SaveSessionAsset(ctx, input)
411415
}
412416

413417
func (m *MultiWorkspaceRuntime) OpenSessionAsset(ctx context.Context, input OpenSessionAssetInput) (OpenSessionAssetResult, error) {
414418
port, err := m.getPort(ctx)
415419
if err != nil {
416420
return OpenSessionAssetResult{}, err
417421
}
418-
return port.OpenSessionAsset(ctx, input)
422+
assetPort, ok := port.(SessionAssetPort)
423+
if !ok {
424+
return OpenSessionAssetResult{}, ErrRuntimeUnavailable
425+
}
426+
return assetPort.OpenSessionAsset(ctx, input)
427+
}
428+
429+
// DeleteSessionAsset 按请求上下文中的工作区选择对应运行桥,并转发会话附件删除。
430+
func (m *MultiWorkspaceRuntime) DeleteSessionAsset(ctx context.Context, input DeleteSessionAssetInput) error {
431+
port, err := m.getPort(ctx)
432+
if err != nil {
433+
return err
434+
}
435+
assetPort, ok := port.(SessionAssetPort)
436+
if !ok {
437+
return ErrRuntimeUnavailable
438+
}
439+
return assetPort.DeleteSessionAsset(ctx, input)
419440
}
420441

421442
func (m *MultiWorkspaceRuntime) DeleteSession(ctx context.Context, input DeleteSessionInput) (bool, error) {

internal/gateway/multi_workspace_runtime_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type recordingPort struct {
3030
cancelCalls atomic.Int32
3131
saveAssetCalls atomic.Int32
3232
openAssetCalls atomic.Int32
33+
deleteAssetCalls atomic.Int32
3334
closed atomic.Int32
3435
closeOnce sync.Once
3536

@@ -147,6 +148,11 @@ func (p *recordingPort) OpenSessionAsset(_ context.Context, input OpenSessionAss
147148
return OpenSessionAssetResult{Meta: SessionAssetMeta{SessionID: input.SessionID, AssetID: input.AssetID}}, nil
148149
}
149150

151+
func (p *recordingPort) DeleteSessionAsset(_ context.Context, _ DeleteSessionAssetInput) error {
152+
p.deleteAssetCalls.Add(1)
153+
return nil
154+
}
155+
150156
func (p *recordingPort) DeleteSession(_ context.Context, _ DeleteSessionInput) (bool, error) {
151157
return true, nil
152158
}
@@ -801,6 +807,9 @@ func TestMultiWorkspaceRuntime_RoutingMatrix(t *testing.T) {
801807
if _, err := mw.OpenSessionAsset(alphaCtx, OpenSessionAssetInput{SessionID: "s-1", AssetID: "asset-1"}); err != nil {
802808
t.Fatalf("OpenSessionAsset alpha: %v", err)
803809
}
810+
if err := mw.DeleteSessionAsset(betaCtx, DeleteSessionAssetInput{SessionID: "s-1", AssetID: "asset-1"}); err != nil {
811+
t.Fatalf("DeleteSessionAsset beta: %v", err)
812+
}
804813

805814
alphaPort := builder.portFor(alpha.Path)
806815
betaPort := builder.portFor(beta.Path)
@@ -825,6 +834,9 @@ func TestMultiWorkspaceRuntime_RoutingMatrix(t *testing.T) {
825834
if got := alphaPort.openAssetCalls.Load(); got != 1 {
826835
t.Fatalf("alpha OpenSessionAsset calls = %d, want 1", got)
827836
}
837+
if got := betaPort.deleteAssetCalls.Load(); got != 1 {
838+
t.Fatalf("beta DeleteSessionAsset calls = %d, want 1", got)
839+
}
828840
}
829841

830842
func TestMultiWorkspaceRuntime_ListWorkspacesMatchesIndex(t *testing.T) {
@@ -848,6 +860,7 @@ func TestMultiWorkspaceRuntime_ListWorkspacesMatchesIndex(t *testing.T) {
848860

849861
// guard against future drift: MultiWorkspaceRuntime must implement RuntimePort and ManagementRuntimePort.
850862
var _ RuntimePort = (*MultiWorkspaceRuntime)(nil)
863+
var _ SessionAssetPort = (*MultiWorkspaceRuntime)(nil)
851864
var _ ManagementRuntimePort = (*MultiWorkspaceRuntime)(nil)
852865
var _ PlanApprovalRuntimePort = (*MultiWorkspaceRuntime)(nil)
853866

0 commit comments

Comments
 (0)