Skip to content

Commit 7d583b7

Browse files
authored
Merge pull request #714 from wynxing/feat-session-asset-delete-v2
feat: 会话附件删除接口与图片输入模型兼容投影 (clean v2)
2 parents eac671d + 6fb861a commit 7d583b7

35 files changed

Lines changed: 1255 additions & 189 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ workspace.xml
4242
.vscode/
4343
.claude/
4444
.windsurf/
45+
.codebuddy/
4546
# VitePress / frontend build artifacts
4647
www/.vitepress/cache/
4748
www/.vitepress/dist/

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/context/builder.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ func newStablePromptSources(extra ...SectionSource) []promptSectionSource {
1919
newRulesPromptSource(nil),
2020
}
2121
for _, src := range extra {
22-
sources = append(sources, src)
22+
if src != nil {
23+
sources = append(sources, src)
24+
}
2325
}
2426
return sources
2527
}

internal/context/builder_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,40 @@ func TestNewConfiguredBuilder(t *testing.T) {
645645
}
646646
})
647647

648+
t.Run("nil extra source is safely ignored", func(t *testing.T) {
649+
t.Parallel()
650+
builder := NewConfiguredBuilder(nil)
651+
input := BuildInput{
652+
Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}},
653+
Metadata: testMetadata(t.TempDir()),
654+
}
655+
result, err := builder.Build(stdcontext.Background(), input)
656+
if err != nil {
657+
t.Fatalf("Build() with nil source error = %v", err)
658+
}
659+
if result.SystemPrompt == "" {
660+
t.Fatal("expected non-empty system prompt even with nil extra source")
661+
}
662+
})
663+
664+
t.Run("mixed nil and valid extra sources", func(t *testing.T) {
665+
t.Parallel()
666+
builder := NewConfiguredBuilder(nil, stubPromptSectionSource{
667+
sections: []promptSection{{Title: "Valid", Content: "valid section"}},
668+
}, nil)
669+
input := BuildInput{
670+
Messages: []providertypes.Message{{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}}},
671+
Metadata: testMetadata(t.TempDir()),
672+
}
673+
result, err := builder.Build(stdcontext.Background(), input)
674+
if err != nil {
675+
t.Fatalf("Build() with mixed nil/valid sources error = %v", err)
676+
}
677+
if !strings.Contains(result.SystemPrompt, "## Valid") {
678+
t.Fatal("expected valid section to be present while nil sources are ignored")
679+
}
680+
})
681+
648682
t.Run("multiple extra section sources are appended", func(t *testing.T) {
649683
builder := NewConfiguredBuilder(stubPromptSectionSource{
650684
sections: []promptSection{{Title: "First", Content: "first body"}},

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) {

0 commit comments

Comments
 (0)