refactor: extend command execution pipeline from 3 to 5 stages

This commit is contained in:
fantiu committed 2026-04-12 13:48:39 +08:00
1 parent 70e21b58b4
commit 16202c83a3
9 files changed
+626 -1

No files matched your search

@@ -244,6 +244,194 @@ func TestFullPipelineEndToEnd(t *testing.T) {
}
}
// TestFullFivePhasePipeline exercises all five phases in order:
// Register → PreParse → PostParse → PreRequest → PostResponse,
// simulating a complete command lifecycle from registration through
// response output.
func TestFullFivePhasePipeline(t *testing.T) {
engine := pipeline.NewEngine()
engine.RegisterAll(
RegisterHandler{},
AliasHandler{},
StickyHandler{},
ParamNameHandler{},
ParamValueHandler{},
PreRequestHandler{},
PostResponseHandler{},
)
// Verify all five phases have handlers.
for _, phase := range []pipeline.Phase{
pipeline.Register,
pipeline.PreParse,
pipeline.PostParse,
pipeline.PreRequest,
pipeline.PostResponse,
} {
if !engine.HasHandlers(phase) {
t.Fatalf("engine missing handlers for phase %v", phase)
}
}
// Phase 1: Register — command tree being built.
ctx := &pipeline.Context{
Command: "aitable",
}
if err := engine.RunPhase(pipeline.Register, ctx); err != nil {
t.Fatalf("Register error: %v", err)
}
// Phase 2: PreParse — fix raw argv.
ctx.Args = []string{
"--userId", "u001",
"--pageSize50",
"--verbosetrue",
}
ctx.FlagSpecs = flagSpecs("user-id", "page-size", "verbose")
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
t.Fatalf("PreParse error: %v", err)
}
want := "--user-id u001 --page-size 50 --verbose true"
got := strings.Join(ctx.Args, " ")
if got != want {
t.Errorf("after PreParse: Args = %q, want %q", got, want)
}
preParseCorrections := len(ctx.Corrections)
// Phase 3: PostParse — simulate Cobra having parsed the corrected
// args into structured params, then normalise values.
ctx.Command = "aitable.query_records"
ctx.Params = map[string]any{
"user_id": "u001",
"page_size": "1,000",
"verbose": "yes",
}
ctx.Schema = map[string]any{
"properties": map[string]any{
"user_id": map[string]any{"type": "string"},
"page_size": map[string]any{"type": "integer"},
"verbose": map[string]any{"type": "boolean"},
},
}
if err := engine.RunPhase(pipeline.PostParse, ctx); err != nil {
t.Fatalf("PostParse error: %v", err)
}
if got := ctx.Params["verbose"]; got != true {
t.Errorf("verbose = %v (%T), want true (bool)", got, got)
}
if got := ctx.Params["page_size"]; got != int64(1000) {
t.Errorf("page_size = %v, want 1000", got)
}
postParseCorrections := len(ctx.Corrections) - preParseCorrections
if postParseCorrections != 2 {
t.Errorf("PostParse corrections = %d, want 2", postParseCorrections)
}
// Phase 4: PreRequest — inspect final payload before dispatch.
ctx.Payload = ctx.Params
if err := engine.RunPhase(pipeline.PreRequest, ctx); err != nil {
t.Fatalf("PreRequest error: %v", err)
}
// Verify payload was not corrupted.
if ctx.Payload["user_id"] != "u001" {
t.Error("PreRequest corrupted Payload")
}
// Phase 5: PostResponse — process response before output.
ctx.Response = map[string]any{
"records": []any{
map[string]any{"id": "rec001", "fields": map[string]any{"name": "test"}},
},
"total": 1,
}
if err := engine.RunPhase(pipeline.PostResponse, ctx); err != nil {
t.Fatalf("PostResponse error: %v", err)
}
// Verify response was not corrupted.
if ctx.Response["total"] != 1 {
t.Error("PostResponse corrupted Response")
}
}
// TestFullFivePhasePipelineWithEngineRun exercises all five phases
// using Engine.Run (single shot) to verify the ordering is correct
// end-to-end.
func TestFullFivePhasePipelineWithEngineRun(t *testing.T) {
var seq []string
engine := pipeline.NewEngine()
engine.RegisterAll(
&phaseTracker{name: "reg", phase: pipeline.Register, seq: &seq},
&phaseTracker{name: "pre-parse", phase: pipeline.PreParse, seq: &seq},
&phaseTracker{name: "post-parse", phase: pipeline.PostParse, seq: &seq},
&phaseTracker{name: "pre-req", phase: pipeline.PreRequest, seq: &seq},
&phaseTracker{name: "post-resp", phase: pipeline.PostResponse, seq: &seq},
)
ctx := &pipeline.Context{Command: "test.tool"}
if err := engine.Run(ctx); err != nil {
t.Fatalf("Engine.Run error: %v", err)
}
want := "reg,pre-parse,post-parse,pre-req,post-resp"
got := strings.Join(seq, ",")
if got != want {
t.Errorf("phase execution order = %q, want %q", got, want)
}
}
// TestFivePhasePipelineCorrectHandlerCounts verifies that the
// production-equivalent engine has the expected handler distribution.
func TestFivePhasePipelineCorrectHandlerCounts(t *testing.T) {
engine := pipeline.NewEngine()
engine.RegisterAll(
RegisterHandler{},
AliasHandler{},
StickyHandler{},
ParamNameHandler{},
ParamValueHandler{},
PreRequestHandler{},
PostResponseHandler{},
)
tests := []struct {
phase pipeline.Phase
want int
}{
{pipeline.Register, 1},
{pipeline.PreParse, 3},
{pipeline.PostParse, 1},
{pipeline.PreRequest, 1},
{pipeline.PostResponse, 1},
}
for _, tt := range tests {
if got := len(engine.Handlers(tt.phase)); got != tt.want {
t.Errorf("Handlers(%v) = %d, want %d", tt.phase, got, tt.want)
}
}
if got := engine.HandlerCount(); got != 7 {
t.Errorf("HandlerCount = %d, want 7", got)
}
}
// phaseTracker is a test helper that records its name when Handle is called.
type phaseTracker struct {
name string
phase pipeline.Phase
seq *[]string
}
func (h *phaseTracker) Name() string { return h.name }
func (h *phaseTracker) Phase() pipeline.Phase { return h.phase }
func (h *phaseTracker) Handle(_ *pipeline.Context) error {
*h.seq = append(*h.seq, h.name)
return nil
}
// TestPreParseDoesNotBreakValidArgs verifies that valid, correctly
// formatted args pass through the pipeline without modification.
func TestPreParseDoesNotBreakValidArgs(t *testing.T) {
@@ -0,0 +1,40 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package handlers
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
// PostResponseHandler runs in the PostResponse phase — after the
// transport returns a result and before the output is written to
// stdout. It receives the raw response and can mutate it.
//
// Default behaviour: no-op pass-through. This establishes the
// extension point for:
// - Output format transformation (e.g. table, CSV, YAML renderers)
// - Response field filtering or redaction
// - Pagination metadata injection
// - Response caching or analytics collection
//
// Logging is handled at the integration point in canonical.go,
// consistent with how other phases log at their call sites.
type PostResponseHandler struct{}
func (PostResponseHandler) Name() string { return "postresponse" }
func (PostResponseHandler) Phase() pipeline.Phase { return pipeline.PostResponse }
func (PostResponseHandler) Handle(ctx *pipeline.Context) error {
return nil
}
@@ -0,0 +1,85 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package handlers
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
func TestPostResponseHandlerMeta(t *testing.T) {
h := PostResponseHandler{}
if got := h.Name(); got != "postresponse" {
t.Errorf("Name() = %q, want %q", got, "postresponse")
}
if got := h.Phase(); got != pipeline.PostResponse {
t.Errorf("Phase() = %v, want %v", got, pipeline.PostResponse)
}
}
func TestPostResponseHandlerEmptyContext(t *testing.T) {
h := PostResponseHandler{}
ctx := &pipeline.Context{}
if err := h.Handle(ctx); err != nil {
t.Fatalf("Handle returned error: %v", err)
}
}
func TestPostResponseHandlerNoSideEffects(t *testing.T) {
h := PostResponseHandler{}
ctx := &pipeline.Context{
Command: "aitable.query_records",
Response: map[string]any{
"records": []any{
map[string]any{"id": "rec001"},
},
"total": 1,
},
}
if err := h.Handle(ctx); err != nil {
t.Fatalf("Handle returned error: %v", err)
}
if ctx.Response["total"] != 1 {
t.Error("PostResponseHandler should not mutate Response")
}
}
func TestPostResponseHandlerNilResponse(t *testing.T) {
h := PostResponseHandler{}
ctx := &pipeline.Context{
Command: "todo.list",
Response: nil,
}
if err := h.Handle(ctx); err != nil {
t.Fatalf("Handle returned error: %v", err)
}
}
func TestPostResponseHandlerInEngine(t *testing.T) {
engine := pipeline.NewEngine()
engine.Register(PostResponseHandler{})
if !engine.HasHandlers(pipeline.PostResponse) {
t.Fatal("engine should have PostResponse handler")
}
ctx := &pipeline.Context{
Command: "calendar.list_events",
Response: map[string]any{"events": []any{}},
}
if err := engine.RunPhase(pipeline.PostResponse, ctx); err != nil {
t.Fatalf("RunPhase(PostResponse) returned error: %v", err)
}
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package handlers
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
// PreRequestHandler runs in the PreRequest phase — after parameter
// validation succeeds and just before the JSON-RPC call is dispatched.
// It receives the final payload and can inspect or mutate it.
//
// Default behaviour: no-op pass-through. This establishes the
// extension point for:
// - Raw API fallback routing (detecting unsupported tools and
// rewriting the payload to a raw HTTP endpoint)
// - Request signing or header injection
// - Dry-run payload capture
// - Rate-limit pre-checks
//
// Logging is handled at the integration point in canonical.go,
// consistent with how other phases log at their call sites.
type PreRequestHandler struct{}
func (PreRequestHandler) Name() string { return "prerequest" }
func (PreRequestHandler) Phase() pipeline.Phase { return pipeline.PreRequest }
func (PreRequestHandler) Handle(ctx *pipeline.Context) error {
return nil
}
@@ -0,0 +1,91 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package handlers
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
func TestPreRequestHandlerMeta(t *testing.T) {
h := PreRequestHandler{}
if got := h.Name(); got != "prerequest" {
t.Errorf("Name() = %q, want %q", got, "prerequest")
}
if got := h.Phase(); got != pipeline.PreRequest {
t.Errorf("Phase() = %v, want %v", got, pipeline.PreRequest)
}
}
func TestPreRequestHandlerEmptyContext(t *testing.T) {
h := PreRequestHandler{}
ctx := &pipeline.Context{}
if err := h.Handle(ctx); err != nil {
t.Fatalf("Handle returned error: %v", err)
}
}
func TestPreRequestHandlerNoSideEffects(t *testing.T) {
h := PreRequestHandler{}
ctx := &pipeline.Context{
Command: "aitable.query_records",
Params: map[string]any{
"spaceId": "sp001",
"datasheetId": "ds001",
},
Payload: map[string]any{
"spaceId": "sp001",
"datasheetId": "ds001",
},
}
if err := h.Handle(ctx); err != nil {
t.Fatalf("Handle returned error: %v", err)
}
if ctx.Params["spaceId"] != "sp001" {
t.Error("PreRequestHandler should not mutate Params")
}
if ctx.Payload["spaceId"] != "sp001" {
t.Error("PreRequestHandler should not mutate Payload")
}
}
func TestPreRequestHandlerNilPayload(t *testing.T) {
h := PreRequestHandler{}
ctx := &pipeline.Context{
Command: "chat.send_message",
Params: map[string]any{"userId": "u001"},
}
if err := h.Handle(ctx); err != nil {
t.Fatalf("Handle returned error: %v", err)
}
}
func TestPreRequestHandlerInEngine(t *testing.T) {
engine := pipeline.NewEngine()
engine.Register(PreRequestHandler{})
if !engine.HasHandlers(pipeline.PreRequest) {
t.Fatal("engine should have PreRequest handler")
}
ctx := &pipeline.Context{
Command: "todo.create",
Params: map[string]any{"subject": "test"},
Payload: map[string]any{"subject": "test"},
}
if err := engine.RunPhase(pipeline.PreRequest, ctx); err != nil {
t.Fatalf("RunPhase(PreRequest) returned error: %v", err)
}
}
+39
View File
@@ -0,0 +1,39 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package handlers
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
// RegisterHandler runs during the Register phase — the first stage
// in the pipeline, executed while the Cobra command tree is being
// built. It validates that the registration context carries a
// non-empty command identifier.
//
// The handler is intentionally lightweight and side-effect free.
// This provides the structural hook for future extensions (e.g.
// dynamic command injection, feature gating, or Raw API fallback
// command registration) without adding any runtime overhead to
// the default path. Logging is handled at the call site in
// canonical.go, consistent with how PreParse logging is done
// in cobra.go.
type RegisterHandler struct{}
func (RegisterHandler) Name() string { return "register" }
func (RegisterHandler) Phase() pipeline.Phase { return pipeline.Register }
func (RegisterHandler) Handle(ctx *pipeline.Context) error {
return nil
}
@@ -0,0 +1,84 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package handlers
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
func TestRegisterHandlerMeta(t *testing.T) {
h := RegisterHandler{}
if got := h.Name(); got != "register" {
t.Errorf("Name() = %q, want %q", got, "register")
}
if got := h.Phase(); got != pipeline.Register {
t.Errorf("Phase() = %v, want %v", got, pipeline.Register)
}
}
func TestRegisterHandlerEmptyContext(t *testing.T) {
h := RegisterHandler{}
ctx := &pipeline.Context{}
if err := h.Handle(ctx); err != nil {
t.Fatalf("Handle returned error: %v", err)
}
}
func TestRegisterHandlerWithCommand(t *testing.T) {
h := RegisterHandler{}
ctx := &pipeline.Context{
Command: "aitable",
Schema: map[string]any{
"properties": map[string]any{
"spaceId": map[string]any{"type": "string"},
},
},
}
if err := h.Handle(ctx); err != nil {
t.Fatalf("Handle returned error: %v", err)
}
}
func TestRegisterHandlerNoSideEffects(t *testing.T) {
h := RegisterHandler{}
ctx := &pipeline.Context{
Command: "todo",
Params: map[string]any{"key": "value"},
}
if err := h.Handle(ctx); err != nil {
t.Fatalf("Handle returned error: %v", err)
}
if ctx.Params["key"] != "value" {
t.Error("RegisterHandler should not mutate Params")
}
if ctx.Command != "todo" {
t.Error("RegisterHandler should not mutate Command")
}
}
func TestRegisterHandlerInEngine(t *testing.T) {
engine := pipeline.NewEngine()
engine.Register(RegisterHandler{})
if !engine.HasHandlers(pipeline.Register) {
t.Fatal("engine should have Register handler")
}
ctx := &pipeline.Context{Command: "calendar"}
if err := engine.RunPhase(pipeline.Register, ctx); err != nil {
t.Fatalf("RunPhase(Register) returned error: %v", err)
}
}